);
}
@@ -114,13 +133,18 @@ interface SuggestionRowProps {
onHover: () => void;
/** How the row lines up its icon and text. */
align: 'center' | 'flex-start';
+ /** Set inside a listbox: the row becomes an option the input can point at. */
+ optionId?: string;
children: React.ReactNode;
}
-export function SuggestionRow({ active, onPick, onHover, align, children }: SuggestionRowProps) {
+export function SuggestionRow({ active, onPick, onHover, align, optionId, children }: SuggestionRowProps) {
return (
{
+ it('selects controlled type icons without enabling any types', () => {
+ expect(tourDayRowIcon('hike')).toBe(Mountain);
+ expect(tourDayRowIcon('bike')).toBe(Bike);
+ expect(tourDayRowIcon('walk')).toBe(Footprints);
+ expect(tourDayRowIcon('unknown')).toBe(Route);
+ render( );
+ expect(screen.getByTestId('plan-tour-icon').querySelector('.lucide-mountain')).toBeTruthy();
+ });
+
+ it.each([false, true])('renders compact facts with accessible meaning (mobile=%s)', (mobile) => {
+ render( );
+ expect(screen.getByTestId('plan-tour-distance')).toHaveTextContent('4');
+ expect(screen.getByTestId('plan-tour-ascent')).toHaveTextContent('100');
+ expect(screen.getByTestId('trip-plan-tour-walking-time')).toHaveAccessibleName('Walking time: 53 min');
+ expect(screen.getByTestId('trip-plan-tour-breaks')).toHaveAccessibleName('Breaks / additional time: 10 min');
+ expect(screen.getByTestId('trip-plan-tour-planned-duration')).toHaveTextContent('1 h 3 min');
+ expect(screen.queryByText('80 m')).not.toBeInTheDocument();
+ });
+
+ it.each([null, 0])('omits missing metrics and empty breaks (%s)', (breaks) => {
+ render(
+
+ );
+ expect(screen.queryByTestId('plan-tour-distance')).not.toBeInTheDocument();
+ expect(screen.queryByTestId('plan-tour-ascent')).not.toBeInTheDocument();
+ expect(screen.queryByTestId('trip-plan-tour-breaks')).not.toBeInTheDocument();
+ expect(screen.getByTestId('trip-plan-tour-planned-duration')).toHaveTextContent('53 min');
+ });
+
+ it('supports hover, keyboard focus and touch help without opening the containing row', async () => {
+ const openRow = vi.fn();
+ render(
+
+
+
+ );
+ const walking = screen.getByTestId('trip-plan-tour-walking-time');
+ fireEvent.mouseEnter(walking);
+ expect(await screen.findByRole('tooltip')).toHaveTextContent('Walking time: 53 min');
+ fireEvent.mouseLeave(walking);
+ const matches = vi.spyOn(walking, 'matches').mockImplementation((selector) => selector === ':focus-visible');
+ fireEvent.focus(walking);
+ expect(await screen.findByRole('tooltip')).toHaveTextContent('Walking time: 53 min');
+ fireEvent.blur(walking);
+ matches.mockRestore();
+ fireEvent.click(walking);
+ expect(screen.getByRole('note')).toHaveTextContent('Walking time: 53 min');
+ expect(openRow).not.toHaveBeenCalled();
+ });
+});
diff --git a/client/src/components/Tours/TourDayRowFacts.tsx b/client/src/components/Tours/TourDayRowFacts.tsx
new file mode 100644
index 000000000..a90f6dd69
--- /dev/null
+++ b/client/src/components/Tours/TourDayRowFacts.tsx
@@ -0,0 +1,141 @@
+import type { TourListItem } from '@trek/shared';
+import {
+ ArrowUp,
+ Bike,
+ Clock,
+ Footprints,
+ Mountain,
+ Pause,
+ Route,
+ Ruler,
+ Sailboat,
+ type LucideIcon,
+} from 'lucide-react';
+import { useId, useState } from 'react';
+import { useTranslation } from '../../i18n';
+import { useSettingsStore } from '../../store/settingsStore';
+import { formatDistance, formatElevation } from '../../utils/units';
+import { Tooltip } from '../shared/Tooltip';
+import { formatPlannedTourDuration, tourPlannedTimes } from './tourPresentation';
+
+export function tourDayRowIcon(type: string): LucideIcon {
+ switch (type) {
+ case 'hike':
+ return Mountain;
+ case 'bike':
+ return Bike;
+ case 'walk':
+ return Footprints;
+ case 'kayak':
+ return Sailboat;
+ default:
+ return Route;
+ }
+}
+
+export function TourDayRowIcon({ type, size = 27 }: { type: string; size?: number }) {
+ const { t } = useTranslation();
+ const Icon = tourDayRowIcon(type);
+ const known = ['hike', 'bike', 'walk', 'kayak'].includes(type);
+ return (
+
+
+
+ );
+}
+
+function Fact({
+ icon: Icon,
+ value,
+ label,
+ testId,
+ mobile,
+}: {
+ icon: LucideIcon;
+ value: string;
+ label: string;
+ testId: string;
+ mobile: boolean;
+}) {
+ const id = useId();
+ const [open, setOpen] = useState(false);
+ return (
+
+
+ {
+ event.stopPropagation();
+ setOpen((current) => !current);
+ }}
+ onKeyDown={(event) => {
+ event.stopPropagation();
+ if (event.key === 'Escape') setOpen(false);
+ }}
+ className={`inline-flex items-center gap-1 whitespace-nowrap rounded-[6px] px-1.5 py-0.5 text-[0.65625rem] tabular-nums focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent ${mobile ? 'bg-[color:var(--m-ic)] text-m-muted' : 'bg-surface-card text-content-secondary'}`}
+ >
+
+ {value}
+
+
+
+ {label}
+
+
+ );
+}
+
+export default function TourDayRowFacts({ tour, mobile = false }: { tour: TourListItem; mobile?: boolean }) {
+ const { t } = useTranslation();
+ const unit = useSettingsStore((state) => state.settings.distance_unit);
+ const times = tourPlannedTimes(tour);
+ const facts: Array<{ icon: LucideIcon; value: string; label: string; testId: string }> = [];
+ const add = (icon: LucideIcon, value: string, label: string, testId: string) =>
+ facts.push({ icon, value, label, testId });
+ if (tour.distance != null)
+ add(Ruler, formatDistance(tour.distance, unit), t('tours.detail.distance'), 'plan-tour-distance');
+ if (tour.elevation_gain != null)
+ add(ArrowUp, formatElevation(tour.elevation_gain, unit), t('tours.detail.ascent'), 'plan-tour-ascent');
+ if (times.walkingMinutes != null)
+ add(
+ Footprints,
+ formatPlannedTourDuration(times.walkingMinutes),
+ t('tours.planner.inspector.duration'),
+ 'trip-plan-tour-walking-time'
+ );
+ if (times.breakMinutes != null && times.breakMinutes > 0)
+ add(
+ Pause,
+ formatPlannedTourDuration(times.breakMinutes),
+ t('tours.planner.breaksAdditional'),
+ 'trip-plan-tour-breaks'
+ );
+ if (times.plannedTotalMinutes != null)
+ add(
+ Clock,
+ formatPlannedTourDuration(times.plannedTotalMinutes),
+ `${t('tours.planner.plannedTotalDuration')}${times.manuallyOverridden ? `, ${t('tours.planner.plannedTotalManual')}` : ''}`,
+ 'trip-plan-tour-planned-duration'
+ );
+ return (
+
+ {facts.map((fact) => (
+
+ ))}
+
+ );
+}
diff --git a/client/src/components/Tours/TourDetailDialog.test.tsx b/client/src/components/Tours/TourDetailDialog.test.tsx
index c41025c53..7a65ff216 100644
--- a/client/src/components/Tours/TourDetailDialog.test.tsx
+++ b/client/src/components/Tours/TourDetailDialog.test.tsx
@@ -104,6 +104,43 @@ describe('TourDetailDialog', () => {
expect(screen.queryByText('Participants')).not.toBeInTheDocument()
})
+ it('RS-01: shows Tour description and classified HTTPS information link without fetching it', () => {
+ const fetchSpy = vi.spyOn(globalThis, 'fetch')
+ render(
+
+ )
+
+ expect(screen.getByText('Ridge above the lake')).toBeInTheDocument()
+ const link = screen.getByRole('link', { name: 'Komoot · komoot.com' })
+ expect(link).toHaveAttribute('href', 'https://www.komoot.com/tour/42')
+ expect(link).toHaveAttribute('rel', 'noopener noreferrer nofollow')
+ expect(fetchSpy).not.toHaveBeenCalled()
+ fetchSpy.mockRestore()
+ })
+
+ it('RS-02: labels calculated walking time and planned total as separate values', () => {
+ render( )
+
+ expect(screen.getByText('Walking time: 1 h')).toBeInTheDocument()
+ expect(screen.getByText('Breaks / additional time: 35 min')).toBeInTheDocument()
+ expect(screen.getByText('Planned total duration: 2 h 5 min · Manual override')).toBeInTheDocument()
+ })
+
+ it('RS-01: rejects an unsafe legacy Tour website for rendering', () => {
+ render(
+
+ )
+ expect(screen.queryByRole('link')).not.toBeInTheDocument()
+ })
+
it('uses existing assignment callbacks for add and remove actions', () => {
const { rerender } = render( )
fireEvent.click(screen.getByRole('button', { name: /Add to Day/i }))
diff --git a/client/src/components/Tours/TourDetailDialog.tsx b/client/src/components/Tours/TourDetailDialog.tsx
index 55143b59c..59e9f55a1 100644
--- a/client/src/components/Tours/TourDetailDialog.tsx
+++ b/client/src/components/Tours/TourDetailDialog.tsx
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState } from 'react'
-import { AlertTriangle, Check, ChevronDown, ChevronUp, File, FileImage, FileText, Info, MapPin, Mountain, Navigation, Pencil, Plus, Minus, Upload } from 'lucide-react'
+import { AlertTriangle, Check, ChevronDown, ChevronUp, ExternalLink, File, FileImage, FileText, Info, MapPin, Mountain, Navigation, Pencil, Plus, Minus, Upload } from 'lucide-react'
import Markdown from 'react-markdown'
import remarkBreaks from 'remark-breaks'
import remarkGfm from 'remark-gfm'
@@ -22,7 +22,7 @@ import { analyzeRouteGeometry } from '../../utils/routeGeometry'
import { filesForPlace } from '../../utils/placeFiles'
import { openFile } from '../../utils/fileDownload'
import { getNavigationTargets, navigationTargetLabel, openNavigationTarget } from '../Planner/placeNavigation'
-import { hikeSourceBadgeLabel, tourSource } from './tourPresentation'
+import { formatPlannedTourDuration, hikeSourceBadgeLabel, tourPlannedTimes, tourSource, tourWebsitePresentation } from './tourPresentation'
import { TourMetricFields, TourNotice, TourTile } from './tourParts'
import { useTourPermissions, type TourPermissionProps } from './useTourPermissions'
@@ -137,6 +137,9 @@ export default function TourDetailDialog({
const navigationTargets = getNavigationTargets(place)
const navigationLabel = navigationTargets.length === 1 ? navigationTargetLabel(navigationTargets[0], t) : t('inspector.navigation')
const source = tourSource(tour)
+ const tourTimes = tourPlannedTimes(tour)
+ const description = tour.description !== undefined ? tour.description : place.description
+ const website = tourWebsitePresentation(tour.website !== undefined ? tour.website : place.website)
const colorLabel = place.route_color ? place.route_color.toUpperCase() : t('inspector.trackColorAuto')
const saveName = async () => {
@@ -217,7 +220,27 @@ export default function TourDetailDialog({
}>{t(`tourTypes.${tour.tour_type}`)}
{tour.difficulty &&
{t('tours.detail.difficulty')}: {tour.difficulty} }
- {tour.duration != null &&
{t('tours.durationMinutes', { count: tour.duration })} }
+ {tourTimes.walkingMinutes != null && (
+
+
+ {t('tours.planner.inspector.duration')}: {formatPlannedTourDuration(tourTimes.walkingMinutes)}
+
+
+ )}
+ {tourTimes.breakMinutes != null && (
+
+
+ {t('tours.planner.breaksAdditional')}: {formatPlannedTourDuration(tourTimes.breakMinutes)}
+
+
+ )}
+ {tourTimes.plannedTotalMinutes != null && (
+
+
+ {t('tours.planner.plannedTotalDuration')}: {formatPlannedTourDuration(tourTimes.plannedTotalMinutes)}{tourTimes.manuallyOverridden ? ` · ${t('tours.planner.plannedTotalManual')}` : ''}
+
+
+ )}
{hikeSourceBadgeLabel(tour, t)}
{tour.caution && (
@@ -313,7 +336,15 @@ export default function TourDetailDialog({
)}
- {place.description && {place.description} }
+ {description && {description} }
+ {website && (
+
+
+
+ {website.provider ? `${website.provider} · ${website.domain}` : `${t('places.formWebsite')} · ${website.domain}`}
+
+
+ )}
{place.notes && {place.notes} }
{assignment?.notes && {assignment.notes} }
diff --git a/client/src/components/Tours/TourListRow.tsx b/client/src/components/Tours/TourListRow.tsx
index 08c2d6376..30c072175 100644
--- a/client/src/components/Tours/TourListRow.tsx
+++ b/client/src/components/Tours/TourListRow.tsx
@@ -1,7 +1,9 @@
import { useId, type ReactNode } from 'react'
import type { TourListItem } from '@trek/shared'
import { fs } from '../shared/DialogShell'
+import { useTranslation } from '../../i18n'
import { TourFactPills, TourTile } from './tourParts'
+import { tourWebsitePresentation } from './tourPresentation'
interface TourListRowProps {
tour: TourListItem
@@ -14,8 +16,10 @@ interface TourListRowProps {
/** A tour in a list, shaped like a place row: tile, name, its facts as pills and the day action on the right. */
export default function TourListRow({ tour, selected = false, disabled = false, onSelect, action, children }: TourListRowProps) {
+ const { t } = useTranslation()
const nameId = useId()
const tone = selected ? 'bg-surface-selected' : disabled ? '' : 'hover:bg-surface-hover'
+ const website = tourWebsitePresentation(tour.website)
return (
{tour.name}
+ {tour.description && (
+
+ {tour.description}
+
+ )}
+ {website && (
+
+ {website.provider ? `${website.provider} · ${website.domain}` : `${t('places.formWebsite')} · ${website.domain}`}
+
+ )}
{action && (
event.stopPropagation()}>
diff --git a/client/src/components/Tours/ToursSidebar.test.tsx b/client/src/components/Tours/ToursSidebar.test.tsx
index e55607fd5..abe1454ec 100644
--- a/client/src/components/Tours/ToursSidebar.test.tsx
+++ b/client/src/components/Tours/ToursSidebar.test.tsx
@@ -56,6 +56,16 @@ describe('ToursSidebar', () => {
expect(row).toHaveClass('bg-surface-selected')
})
+ it('RS-01: previews the Tour description and classified information-link host', () => {
+ const listed = { ...tour, description: 'A quiet path above the lake', website: 'https://www.komoot.com/tour/42', planned_duration_minutes: 95 }
+ render( )
+ const row = screen.getByRole('option', { name: /Selected ridge walk/i })
+ expect(row).toHaveTextContent('A quiet path above the lake')
+ expect(row).toHaveTextContent('Komoot · komoot.com')
+ expect(row).toHaveTextContent('1 h 35 min')
+ expect(row.querySelector('[data-testid="tour-description-preview"]')).toHaveStyle({ WebkitLineClamp: '2' })
+ })
+
it('selects the tour from keyboard activation', () => {
const onSelectTour = vi.fn()
render( )
diff --git a/client/src/components/Tours/planner/TourPlannerPanels.test.tsx b/client/src/components/Tours/planner/TourPlannerPanels.test.tsx
index 8cab05d07..01a388897 100644
--- a/client/src/components/Tours/planner/TourPlannerPanels.test.tsx
+++ b/client/src/components/Tours/planner/TourPlannerPanels.test.tsx
@@ -1,5 +1,5 @@
import { act, useState } from 'react'
-import { fireEvent, render, screen, waitFor } from '../../../../tests/helpers/render'
+import { fireEvent, render, screen, waitFor, within } from '../../../../tests/helpers/render';
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { TourListItem } from '@trek/shared'
@@ -48,6 +48,20 @@ function planner(overrides: Partial = {}): TourPlannerCon
maxHikingDifficulty: 2,
setMaxHikingDifficulty: vi.fn(),
name: '',
+ description: '',
+ setDescription: vi.fn(),
+ website: '',
+ setWebsite: vi.fn(),
+ websiteInvalid: false,
+ plannedDurationMinutes: null,
+ setPlannedDurationMinutes: vi.fn(),
+ plannedDurationInvalid: false,
+ breakAdditionalMinutes: null,
+ setBreakAdditionalMinutes: vi.fn(),
+ breakAdditionalInvalid: false,
+ walkingDurationMinutes: null,
+ plannedTotalMinutes: null,
+ plannedTotalInvalid: false,
startNewTour: vi.fn(),
newTourConfirmationOpen: false,
cancelNewTour: vi.fn(),
@@ -65,6 +79,9 @@ function planner(overrides: Partial = {}): TourPlannerCon
editingPlaceId: null,
openingTourId: null,
openTour: vi.fn().mockResolvedValue(true),
+ reorderWaypoint: vi.fn(),
+ moveWaypoint: vi.fn(),
+ removeWaypoint: vi.fn(),
saveOutcome: null,
hasUnsavedChanges: false,
discard: vi.fn(),
@@ -92,6 +109,16 @@ beforeEach(() => {
})
describe('TourPlannerToursRail', () => {
+ it('RS-02: keeps calculated Walking time visible in the right Tour metrics rail', () => {
+ render( )
+
+ expect(screen.getByText('Walking time')).toBeInTheDocument()
+ expect(screen.getByText('1 h 20 min')).toBeInTheDocument()
+ })
+
it('disables saved-tour switching and Plan another while a Save is pending', () => {
const controller = planner({
isSaving: true,
@@ -289,6 +316,227 @@ describe('TourPlannerToursRail', () => {
})
describe('TourPlannerRail', () => {
+ it('RS-01: edits Tour description and HTTPS information link without exposing editable fields in GPX mode', () => {
+ const setDescription = vi.fn()
+ const setWebsite = vi.fn()
+ const { rerender } = render(
+
+ )
+ const description = screen.getByLabelText('Description')
+ const website = screen.getByLabelText('Website')
+ fireEvent.change(description, { target: { value: 'A quiet ridge route' } })
+ fireEvent.change(website, { target: { value: 'https://www.komoot.com/tour/42' } })
+ expect(setDescription).toHaveBeenCalledWith('A quiet ridge route')
+ expect(setWebsite).toHaveBeenCalledWith('https://www.komoot.com/tour/42')
+
+ rerender(
+
+ )
+ expect(screen.queryByLabelText('Description')).not.toBeInTheDocument()
+ expect(screen.queryByLabelText('Website')).not.toBeInTheDocument()
+ })
+
+ it('RS-02: edits breaks and planned total directly, and clearing total returns it to automatic', () => {
+ const setPlannedDurationMinutes = vi.fn()
+ const setBreakAdditionalMinutes = vi.fn()
+ const controller = planner({
+ mode: { type: 'new-draft' }, hasUnsavedChanges: true, durationSeconds: 3600,
+ walkingDurationMinutes: 60, breakAdditionalMinutes: 35, setBreakAdditionalMinutes,
+ plannedDurationMinutes: null, plannedTotalMinutes: 95, setPlannedDurationMinutes,
+ })
+ const { rerender } = render( )
+
+ expect(screen.queryByTestId('tour-planner-walking-time')).not.toBeInTheDocument()
+ expect(screen.getByTestId('tour-planner-time-grid')).toHaveClass('grid')
+ expect(screen.getByTestId('tour-planner-time-grid')).toHaveClass('grid-cols-[repeat(auto-fit,minmax(min(100%,12rem),1fr))]')
+ const breaks = screen.getByLabelText('Breaks / additional time')
+ expect(breaks).toHaveValue(35)
+ fireEvent.change(breaks, { target: { value: '50' } })
+ expect(setBreakAdditionalMinutes).toHaveBeenCalledWith(50)
+ fireEvent.change(breaks, { target: { value: '' } })
+ expect(setBreakAdditionalMinutes).toHaveBeenLastCalledWith(null)
+ fireEvent.change(breaks, { target: { value: '50' } })
+ const input = screen.getByLabelText('Planned total duration')
+ expect(input).toHaveValue(95)
+ expect(input).toHaveStyle({ width: '6rem' })
+ expect(screen.getByText('Calculated total')).toBeInTheDocument()
+ expect(controller.durationSeconds).toBe(3600)
+ fireEvent.change(input, { target: { value: '125' } })
+ expect(setPlannedDurationMinutes).toHaveBeenCalledWith(125)
+
+ rerender( )
+ const overriddenInput = screen.getByLabelText(/Planned total duration/)
+ expect(overriddenInput).toHaveValue(125)
+ expect(document.querySelector('label[for="tour-planner-planned-duration"]')).toHaveTextContent('Manual override')
+ fireEvent.change(overriddenInput, { target: { value: '' } })
+ expect(setPlannedDurationMinutes).toHaveBeenCalledWith(null)
+
+ rerender( )
+ expect(screen.getByLabelText(/Planned total duration/)).toHaveAttribute('aria-invalid', 'true')
+ expect(screen.getByRole('alert')).toHaveTextContent('Enter a whole value from 0 to 1,440 minutes.')
+
+ rerender( )
+ expect(screen.getByLabelText('Breaks / additional time')).toHaveAttribute('aria-invalid', 'true')
+ expect(screen.getByRole('alert')).toHaveTextContent('Enter a whole value from 0 to 1,440 minutes.')
+ })
+
+ it('RS-02: Info controls are keyboard focusable, use shared tooltips, and expose touch disclosures', async () => {
+ render( )
+
+ const breaksInfo = screen.getByRole('button', { name: 'About breaks and additional time' })
+ expect(breaksInfo.tabIndex).toBe(0)
+ expect(breaksInfo).toHaveAttribute('aria-expanded', 'false')
+ expect(breaksInfo).toHaveAttribute('aria-controls')
+ expect(breaksInfo).toHaveClass('focus-visible:outline')
+ const matches = vi.spyOn(breaksInfo, 'matches').mockImplementation(selector => selector === ':focus-visible')
+ fireEvent.focus(breaksInfo)
+ expect(await screen.findByRole('tooltip')).toHaveTextContent('Optional whole minutes added to calculated walking time.')
+ fireEvent.blur(breaksInfo)
+ matches.mockRestore()
+ fireEvent.click(breaksInfo)
+ expect(breaksInfo).toHaveAttribute('aria-expanded', 'true')
+ expect(screen.getByRole('note')).toHaveTextContent('Optional whole minutes added to calculated walking time.')
+
+ const totalInfo = screen.getByRole('button', { name: 'About planned total duration' })
+ expect(totalInfo).toHaveAttribute('aria-expanded', 'false')
+ fireEvent.click(totalInfo)
+ expect(screen.getByText('Calculated planned total duration.')).toBeInTheDocument()
+ })
+
+ it('RS-01: marks unsafe website values invalid and does not offer Save', () => {
+ render( )
+ expect(screen.getByLabelText('Website')).toHaveAttribute('aria-invalid', 'true')
+ expect(screen.getByRole('alert')).toHaveTextContent('TREK cannot use that address.')
+ })
+
+ const reorderWaypoints = [
+ { id: 'a', lat: 48, lng: 11, role: 'start' as const },
+ { id: 'b', lat: 48.1, lng: 11.1, role: 'via' as const },
+ { id: 'c', lat: 48.2, lng: 11.2, role: 'via' as const },
+ { id: 'd', lat: 48.3, lng: 11.3, role: 'end' as const },
+ ];
+
+ it('RC-07: dragging before and after rows commits only on drop by stable waypoint ids', () => {
+ const reorderWaypoint = vi.fn();
+ render(
+
+ );
+ const rows = screen.getAllByTestId('tour-planner-waypoint-row');
+ const transfer = { setData: vi.fn(), getData: vi.fn(() => 'a'), effectAllowed: 'all', dropEffect: 'move' };
+ const dispatchDrag = (type: string, target: HTMLElement, dataTransfer: typeof transfer, clientY = 0) => {
+ const event = new Event(type, { bubbles: true, cancelable: true });
+ Object.defineProperty(event, 'dataTransfer', { value: dataTransfer });
+ Object.defineProperty(event, 'clientY', { value: clientY });
+ fireEvent(target, event);
+ };
+ for (const row of [rows[1], rows[2]]) {
+ Object.defineProperty(row, 'getBoundingClientRect', {
+ configurable: true,
+ value: () => ({
+ top: 0,
+ bottom: 20,
+ height: 20,
+ left: 0,
+ right: 100,
+ width: 100,
+ x: 0,
+ y: 0,
+ toJSON: () => ({}),
+ }),
+ });
+ }
+
+ dispatchDrag('dragstart', within(rows[0]).getByTestId('tour-planner-waypoint-drag-handle'), transfer);
+ dispatchDrag('dragover', rows[2], transfer, 1);
+ expect(reorderWaypoint).not.toHaveBeenCalled();
+ dispatchDrag('drop', rows[2], transfer, 1);
+ expect(reorderWaypoint).toHaveBeenCalledOnce();
+ expect(reorderWaypoint).toHaveBeenLastCalledWith('a', 'c', 'before');
+
+ reorderWaypoint.mockClear();
+ const lastTransfer = { ...transfer, getData: vi.fn(() => 'd') };
+ dispatchDrag('dragstart', within(rows[3]).getByTestId('tour-planner-waypoint-drag-handle'), lastTransfer);
+ dispatchDrag('dragover', rows[1], lastTransfer, 15);
+ expect(reorderWaypoint).not.toHaveBeenCalled();
+ dispatchDrag('drop', rows[1], lastTransfer, 15);
+ expect(reorderWaypoint).toHaveBeenCalledOnce();
+ expect(reorderWaypoint).toHaveBeenLastCalledWith('d', 'b', 'after');
+ });
+
+ it('RC-07: cancelled drags do not reorder and keyboard arrow controls remain buttons', async () => {
+ const user = userEvent.setup();
+ const reorderWaypoint = vi.fn();
+ const moveWaypoint = vi.fn();
+ render(
+
+ );
+ const rows = screen.getAllByTestId('tour-planner-waypoint-row');
+ const transfer = { setData: vi.fn(), getData: vi.fn(() => 'a'), effectAllowed: 'all', dropEffect: 'move' };
+ Object.defineProperty(rows[2], 'getBoundingClientRect', {
+ configurable: true,
+ value: () => ({
+ top: 0,
+ bottom: 20,
+ height: 20,
+ left: 0,
+ right: 100,
+ width: 100,
+ x: 0,
+ y: 0,
+ toJSON: () => ({}),
+ }),
+ });
+
+ fireEvent.dragStart(within(rows[0]).getByTestId('tour-planner-waypoint-drag-handle'), { dataTransfer: transfer });
+ fireEvent.dragOver(rows[2], { dataTransfer: transfer, clientY: -1 });
+ fireEvent.dragEnd(rows[0], { dataTransfer: transfer });
+ expect(reorderWaypoint).not.toHaveBeenCalled();
+
+ const moveDown = screen.getAllByRole('button', { name: 'Move waypoint down' })[0];
+ expect(moveDown).toBeEnabled();
+ moveDown.focus();
+ await user.keyboard(' ');
+ expect(moveWaypoint).toHaveBeenCalledWith('a', 1);
+ expect(reorderWaypoint).not.toHaveBeenCalled();
+ });
+
it('disables draft mutation and replacement controls while saving', () => {
const controller = planner({
mode: { type: 'new-draft' },
diff --git a/client/src/components/Tours/planner/TourPlannerPanels.tsx b/client/src/components/Tours/planner/TourPlannerPanels.tsx
index 5f4b72458..e5215d603 100644
--- a/client/src/components/Tours/planner/TourPlannerPanels.tsx
+++ b/client/src/components/Tours/planner/TourPlannerPanels.tsx
@@ -1,6 +1,6 @@
-import { useRef, useState, type ReactNode } from 'react'
+import { useId, useRef, useState, type ReactNode } from 'react'
import type { TourListItem } from '@trek/shared'
-import { AlertTriangle, ArrowDown, ArrowLeft, ArrowUp, CheckCircle2, Info, MapPin, Plus, Redo2, RotateCcw, Save, ShieldAlert, Trash2, Undo2 } from 'lucide-react'
+import { AlertTriangle, ArrowDown, ArrowLeft, ArrowUp, CheckCircle2, GripVertical, Info, MapPin, Plus, Redo2, RotateCcw, Save, ShieldAlert, Trash2, Undo2 } from 'lucide-react'
import type { Day } from '../../../types'
import { useTranslation } from '../../../i18n'
import { useSettingsStore } from '../../../store/settingsStore'
@@ -16,7 +16,7 @@ import { BOX } from '../../Planner/bookings/bookingParts'
import { BarButton, SoftPill } from '../../Planner/planParts'
import TourListRow from '../TourListRow'
import ElevationProfile from '../../shared/ElevationProfile'
-import { hikeSourceBadgeLabel, tourSource } from '../tourPresentation'
+import { formatPlannedTourDuration, hikeSourceBadgeLabel, tourSource } from '../tourPresentation'
import { FoldButton, TourDayMenu, TourMetricFields, TourNotice, TourSection } from '../tourParts'
import type { DistanceIndexedProfileSample, RouteProfileFocus } from '../../../utils/routeGeometry'
import type { TourPlannerController, TourPlannerStatus } from './useTourPlanner'
@@ -62,12 +62,38 @@ function RailTitle({ title, sub, aside }: { title: string; sub?: ReactNode; asid
)
}
+function DurationInfo({ description, label }: { description: string; label: string }) {
+ const id = useId()
+ const [open, setOpen] = useState(false)
+ return (
+
+
+ setOpen(value => !value)}
+ className="grid h-5 w-5 flex-none place-items-center rounded-full text-content-faint transition-colors hover:bg-surface-card hover:text-content focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
+ >
+
+
+
+
+ {description}
+
+
+ )
+}
+
export function TourPlannerRail({ planner, canEdit: editPermission, canAssign: assignPermission }: { planner: TourPlannerController } & TourPermissionProps) {
const { canEdit } = useTourPermissions({ canEdit: editPermission ?? planner.canEdit, canAssign: assignPermission ?? planner.canAssign })
const { t } = useTranslation()
const distanceUnit = useSettingsStore(state => state.settings.distance_unit)
const [confirmDiscard, setConfirmDiscard] = useState(false)
const [pendingDifficulty, setPendingDifficulty] = useState<1 | 2 | 3 | 4 | 5 | 6 | null>(null)
+ const [draggedWaypointId, setDraggedWaypointId] = useState(null);
+ const [dropTarget, setDropTarget] = useState<{ id: string; placement: 'before' | 'after' } | null>(null);
const alpineAcknowledged = useRef(false)
const isSaving = planner.isSaving
const statusKey = isSaving ? STATUS_KEY.saving : STATUS_KEY[planner.status]
@@ -179,6 +205,111 @@ export function TourPlannerRail({ planner, canEdit: editPermission, canAssign: a
className={INPUT}
/>
+
+ {t('places.formDescription')}
+
+
+
+
+
+ {t('tours.planner.breaksAdditional')}
+
+
+
{ if (canEdit && !isSaving) planner.setBreakAdditionalMinutes(event.target.value === '' ? null : Number(event.target.value)) }}
+ aria-invalid={planner.breakAdditionalInvalid || undefined}
+ aria-describedby={`tour-planner-breaks-hint${planner.breakAdditionalInvalid ? ' tour-planner-breaks-error' : ''}`}
+ className={INPUT}
+ />
+
+ {t('tours.planner.breaksAdditionalHint')}
+
+ {planner.breakAdditionalInvalid && (
+
+ {t('tours.planner.timeValueInvalid')}
+
+ )}
+
+
+
+
+ {t('tours.planner.plannedTotalDuration')}
+ {planner.plannedDurationMinutes != null && · {t('tours.planner.plannedTotalManual')} }
+
+
+
+
+ { if (canEdit && !isSaving) planner.setPlannedDurationMinutes(event.target.value === '' ? null : Number(event.target.value)) }}
+ aria-invalid={planner.plannedDurationInvalid || undefined}
+ aria-describedby={planner.plannedDurationInvalid ? 'tour-planner-planned-duration-error' : undefined}
+ className={`${INPUT} min-w-0 flex-none`}
+ style={{ width: '6rem' }}
+ />
+ {planner.plannedDurationMinutes == null && {t('tours.planner.plannedTotalAutomatic')} }
+
+ {planner.plannedDurationInvalid && (
+
+ {t('tours.planner.timeValueInvalid')}
+
+ )}
+
+
+
+
+ {t('places.formWebsite')}
+ { if (canEdit && !isSaving) planner.setWebsite(event.target.value) }}
+ maxLength={500}
+ placeholder={t('collab.notes.websitePlaceholder')}
+ aria-invalid={planner.websiteInvalid || undefined}
+ aria-describedby={planner.websiteInvalid ? 'tour-planner-website-error' : undefined}
+ className={INPUT}
+ />
+ {planner.websiteInvalid && (
+
+ {t('dawarich.error.invalid_url')}
+
+ )}
+
{t('tours.planner.maxDifficulty')}
{t('tours.planner.startingPoint')} {t('tours.planner.firstUseDetails')}
) : (
-
+
{planner.waypoints.map((point, index) => {
- const selected = planner.selectedWaypointId === point.id
+ const selected = planner.selectedWaypointId === point.id;
+ const isDropTarget = dropTarget?.id === point.id && draggedWaypointId !== point.id;
return (
-
- planner.setSelectedWaypointId(point.id)}
- className={`group flex w-full items-center gap-2.5 rounded-[12px] px-2 py-1.5 text-start outline-none transition-colors focus-visible:ring-2 focus-visible:ring-[color:var(--text-primary)] ${selected ? 'bg-surface-selected' : 'hover:bg-surface-hover'}`}
- aria-current={selected ? 'true' : undefined}
- >
+ {
+ if (!canEdit || isSaving) {
+ event.preventDefault();
+ return;
+ }
+ if (event.dataTransfer) {
+ event.dataTransfer.effectAllowed = 'move';
+ event.dataTransfer.setData('text/plain', point.id);
+ }
+ setDraggedWaypointId(point.id);
+ setDropTarget(null);
+ }}
+ onDragOver={(event) => {
+ if (!canEdit || isSaving || !draggedWaypointId || draggedWaypointId === point.id) return;
+ event.preventDefault();
+ if (event.dataTransfer) event.dataTransfer.dropEffect = 'move';
+ const bounds = event.currentTarget.getBoundingClientRect();
+ const placement = event.clientY >= bounds.top + bounds.height / 2 ? 'after' : 'before';
+ setDropTarget((current) =>
+ current?.id === point.id && current.placement === placement
+ ? current
+ : { id: point.id, placement }
+ );
+ }}
+ onDrop={(event) => {
+ event.preventDefault();
+ const draggedId = draggedWaypointId || event.dataTransfer.getData('text/plain');
+ if (
+ canEdit &&
+ !isSaving &&
+ draggedId &&
+ draggedId !== point.id &&
+ dropTarget?.id === point.id
+ ) {
+ planner.reorderWaypoint(draggedId, point.id, dropTarget.placement);
+ }
+ setDraggedWaypointId(null);
+ setDropTarget(null);
+ }}
+ onDragEnd={() => {
+ setDraggedWaypointId(null);
+ setDropTarget(null);
+ }}
+ className={`relative rounded-[12px] ${draggedWaypointId === point.id ? 'opacity-45' : ''} ${isDropTarget ? 'bg-surface-hover' : ''}`}
+ >
+ {isDropTarget && (
+
+ )}
+
+
+
+
{index + 1}
-
- {roleLabel(point.role, t)}
- {point.lat.toFixed(5)}, {point.lng.toFixed(5)}
-
+
planner.setSelectedWaypointId(point.id)}
+ className="flex min-w-0 flex-1 flex-col text-start outline-none focus-visible:rounded focus-visible:ring-2 focus-visible:ring-[color:var(--text-primary)]"
+ aria-current={selected ? 'true' : undefined}
+ >
+
+ {roleLabel(point.role, t)}
+
+
+ {point.lat.toFixed(5)}, {point.lng.toFixed(5)}
+
+
{canEdit && (
-
- planner.moveWaypoint(point.id, -1)}>
+
+ planner.moveWaypoint(point.id, -1)}
+ >
- planner.moveWaypoint(point.id, 1)}>
+ planner.moveWaypoint(point.id, 1)}
+ >
- planner.removeWaypoint(point.id)}>
+ planner.removeWaypoint(point.id)}
+ >
)}
-
+
- )
+ );
})}
)}
@@ -316,17 +531,14 @@ export function TourPlannerRail({ planner, canEdit: editPermission, canAssign: a
function WaypointAction({ label, disabled, danger = false, onAct, children }: { label: string; disabled: boolean; danger?: boolean; onAct: () => void; children: ReactNode }) {
return (
- { event.stopPropagation(); if (!disabled) onAct() }}
- onKeyDown={event => { if (!disabled && event.key === 'Enter') { event.stopPropagation(); onAct() } }}
className={`grid h-6 w-6 place-items-center rounded-full transition-colors ${disabled ? 'pointer-events-none opacity-25' : danger ? 'text-danger hover:bg-surface-card' : 'text-content-muted hover:bg-surface-card hover:text-content'}`}
>
{children}
-
+
)
}
@@ -421,7 +633,22 @@ export function TourPlannerToursRail({ planner, tours, days, loading, onAssignTo
selectTour(tour)
}
- const walkingTime = { label: t('tours.planner.inspector.duration'), value: t('tours.durationMinutes', { count: Math.max(1, Math.round((planner.durationSeconds ?? 0) / 60)) }) }
+ const tourTimeFields = [
+ {
+ label: t('tours.planner.inspector.duration'),
+ value: planner.walkingDurationMinutes == null
+ ? t('tours.planner.walkingTimeUnknown')
+ : formatPlannedTourDuration(planner.walkingDurationMinutes),
+ },
+ {
+ label: t('tours.planner.breaksAdditional'),
+ value: planner.breakAdditionalMinutes == null ? '—' : formatPlannedTourDuration(planner.breakAdditionalMinutes),
+ },
+ {
+ label: `${t('tours.planner.plannedTotalDuration')}${planner.plannedDurationMinutes != null ? ` · ${t('tours.planner.plannedTotalManual')}` : ''}`,
+ value: planner.plannedTotalMinutes == null ? t('tours.planner.walkingTimeUnknown') : formatPlannedTourDuration(planner.plannedTotalMinutes),
+ },
+ ]
return (