diff --git a/client/src/components/Tours/PlacesToursModeSwitch.tsx b/client/src/components/Tours/PlacesToursModeSwitch.tsx index d2b158b4b..0882fb41d 100644 --- a/client/src/components/Tours/PlacesToursModeSwitch.tsx +++ b/client/src/components/Tours/PlacesToursModeSwitch.tsx @@ -1,7 +1,7 @@ import React from 'react' import { MapPin, Mountain } from 'lucide-react' import { useTranslation } from '../../i18n/TranslationContext' -import { NEUTRAL_TINT } from '../shared/DialogShell' +import { NEUTRAL_TINT, fs } from '../shared/DialogShell' interface PlacesToursModeSwitchProps { active: boolean @@ -21,8 +21,8 @@ export default function PlacesToursModeSwitch({ active, onChange }: PlacesToursM [true, t('tours.mode.tours'), Mountain], ] return ( -
-
+
+
{options.map(([value, label, Icon]) => { const selected = active === value return ( @@ -32,14 +32,12 @@ export default function PlacesToursModeSwitch({ active, onChange }: PlacesToursM role="tab" aria-selected={selected} onClick={() => onChange(value)} - style={{ fontSize: 'calc(11.5px * var(--fs-scale-body, 1))' }} - className={`flex h-[30px] flex-1 items-center justify-center gap-1.5 rounded-lg px-2 transition-colors ${ - selected - ? 'bg-surface-card font-semibold text-content shadow-card' - : 'font-medium text-content-muted hover:bg-surface-hover hover:text-content' + style={fs(12.5, 'body')} + className={`flex flex-1 items-center justify-center gap-1.5 whitespace-nowrap rounded-[8px] px-3 py-1.5 font-medium transition-colors ${ + selected ? 'bg-surface-card text-content shadow-sm' : 'text-content-muted hover:text-content' }`} > - + {label} ) diff --git a/client/src/components/Tours/TourDetailDialog.tsx b/client/src/components/Tours/TourDetailDialog.tsx index fd2eef404..dae97809e 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, ArrowDown, ArrowUp, Check, Clock, File, FileImage, FileText, MapPin, Mountain, Navigation, Pencil, Plus, Minus, Ruler, Trash2, Upload } from 'lucide-react' +import { AlertTriangle, Check, ChevronDown, ChevronUp, 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' @@ -8,18 +8,22 @@ import type { Assignment, AssignmentsMap, Day, Place, TripFile } from '../../typ import { useTranslation, translateApiError } from '../../i18n' import { useToast } from '../shared/Toast' import { useSettingsStore } from '../../store/settingsStore' -import { formatDistance, formatElevation } from '../../utils/units' import { resolveTrackColor, inheritedTrackColor } from '../Map/trackColors' import TrackColorPicker from '../shared/TrackColorPicker' import DetailShell from '../shared/DetailShell' import ElevationProfile from '../shared/ElevationProfile' import { NavigationMenu } from '../shared/NavigationMenu' import { markdownLinkComponents } from '../shared/markdownLink' +import { DeleteButton, DialogButton, DialogSection, FooterSpacer, fs } from '../shared/DialogShell' +import { Tooltip } from '../shared/Tooltip' +import { BOX } from '../Planner/bookings/bookingParts' +import { SoftPill, TimePill, tintOf } from '../Planner/planParts' import { analyzeRouteGeometry } from '../../utils/routeGeometry' import { filesForPlace } from '../../utils/placeFiles' import { openFile } from '../../utils/fileDownload' -import { getNavigationTargets, openNavigationTarget } from '../Planner/placeNavigation' +import { getNavigationTargets, navigationTargetLabel, openNavigationTarget } from '../Planner/placeNavigation' import { hikeSourceBadgeLabel, tourSource } from './tourPresentation' +import { TourMetricFields, TourNotice, TourTile } from './tourParts' import { useTourPermissions, type TourPermissionProps } from './useTourPermissions' interface TourDetailDialogProps extends TourPermissionProps { @@ -64,6 +68,12 @@ function assignmentForSelectedDay( ?? null } +/** + * The card a tour opens over the map, in the place inspector's language: a head + * band tinted by the track colour with the tile, the name and the facts as + * pills, then each part of the tour under its own label, and the actions in a + * footer that stays in reach. + */ export default function TourDetailDialog({ tour, place, @@ -94,9 +104,10 @@ export default function TourDetailDialog({ const [nameDraft, setNameDraft] = useState(place.name) const [savingName, setSavingName] = useState(false) const [filesExpanded, setFilesExpanded] = useState(false) + const [colorOpen, setColorOpen] = useState(false) const [uploading, setUploading] = useState(false) const [navigationOpen, setNavigationOpen] = useState(false) - const navigationButtonRef = useRef(null) + const navigationAnchorRef = useRef(null) const fileInputRef = useRef(null) const closeRef = useRef(onClose) closeRef.current = onClose @@ -124,7 +135,9 @@ export default function TourDetailDialog({ const assignment = assignmentForSelectedDay(assignments, selectedDayId, selectedAssignmentId, place.id) const placeFiles = filesForPlace(files, place.id, [], assignment ? [assignment.id] : []) const navigationTargets = getNavigationTargets(place) + const navigationLabel = navigationTargets.length === 1 ? navigationTargetLabel(navigationTargets[0], t) : t('inspector.navigation') const source = tourSource(tour) + const colorLabel = place.route_color ? place.route_color.toUpperCase() : t('inspector.trackColorAuto') const saveName = async () => { if (!canEdit) return @@ -161,128 +174,203 @@ export default function TourDetailDialog({ } } - const header = canEdit && editingName ? ( -
- setNameDraft(event.target.value)} - onKeyDown={event => { - if (event.key === 'Enter') void saveName() - if (event.key === 'Escape') { setNameDraft(place.name); setEditingName(false) } - }} - className="border border-edge bg-surface-secondary text-content" - style={{ flex: 1, minWidth: 0, borderRadius: 8, padding: '6px 10px', fontSize: 'calc(16px * var(--fs-scale-subtitle, 1))', fontWeight: 600 }} - /> - -
- ) : ( -
-
-

{place.name}

- {canEdit && onUpdatePlace && ( - + const header = ( + <> +
+ +
+
+ {canEdit && editingName ? ( +
+ setNameDraft(event.target.value)} + onKeyDown={event => { + if (event.key === 'Enter') void saveName() + if (event.key === 'Escape') { event.preventDefault(); setNameDraft(place.name); setEditingName(false) } + }} + aria-label={t('places.formName')} + className="-mx-1.5 block min-w-0 flex-1 rounded-[8px] border-0 bg-surface-card px-1.5 py-0.5 font-bold tracking-[-0.01em] text-content shadow-sm outline-none" + style={fs(17, 'subtitle')} + /> + + + +
+ ) : ( +
+

{place.name}

+ {canEdit && onUpdatePlace && ( + + + + )} +
)} +
+ }>{t(`tourTypes.${tour.tour_type}`)} + {tour.difficulty && {t('tours.detail.difficulty')}: {tour.difficulty}} + {tour.duration != null && {t('tours.durationMinutes', { count: tour.duration })}} + {hikeSourceBadgeLabel(tour, t)} + {tour.caution && ( + + + }>{t('tours.caution.badge')} + + + )} +
-
- {t(`tourTypes.${tour.tour_type}`)} - {tour.difficulty && {t('tours.detail.difficulty')}: {tour.difficulty}} - {tour.duration != null && {t('tours.durationMinutes', { count: tour.duration })}} - {tour.caution && {t('tours.caution.badge')}} - {hikeSourceBadgeLabel(tour, t)} -
-
+ ) const footer = <> {canAssign && selectedDayId != null && (assignment ? onRemoveAssignment && ( - { if (canAssign) onRemoveAssignment(selectedDayId, assignment.id) }} icon={} label={t('inspector.removeFromDay')} /> + { if (canAssign) onRemoveAssignment(selectedDayId, assignment.id) }} icon={} aria-label={t('inspector.removeFromDay')}> + {t('inspector.removeFromDay')} + ) : ( - onAssignToDay && { if (canAssign) onAssignToDay(place.id) }} icon={} label={t('inspector.addToDay')} primary /> + onAssignToDay && ( + { if (canAssign) onAssignToDay(place.id) }} icon={}> + {t('inspector.addToDay')} + + ) ))} {navigationTargets.length > 0 && ( <> - { - if (navigationTargets.length === 1) openNavigationTarget(navigationTargets[0]) - else setNavigationOpen(open => !open) - }} - icon={} - label={navigationTargets.length === 1 ? navigationTargets[0].label : t('inspector.navigation')} - /> - {navigationOpen && setNavigationOpen(false)} />} + + { + if (navigationTargets.length === 1) openNavigationTarget(navigationTargets[0]) + else setNavigationOpen(open => !open) + }} + aria-label={navigationLabel} + aria-haspopup={navigationTargets.length > 1 ? 'menu' : undefined} + aria-expanded={navigationTargets.length > 1 ? navigationOpen : undefined} + icon={} + > + {navigationLabel} + + + {navigationOpen && setNavigationOpen(false)} />} )} -
- {canEdit && onDelete && { if (canEdit) onDelete() }} icon={} label={t('common.delete')} danger />} + + {canEdit && onDelete && { if (canEdit) onDelete() }} />} return ( {source === 'gpx' && ( -
+ }> {t('tours.detail.gpxReadOnly')} {/* TODO(tours-roadmap): add the planned left-rail “Convert to TREK Tour” action here. */} -
+ )} -
-
{t('inspector.trackStats')}
- {analysis && ( - <> -
- } /> - {analysis.minEle != null && } />} - {analysis.maxEle != null && } />} - {analysis.gain != null && } />} - {analysis.loss != null && } />} -
- - + + {analysis && ( + +
+ + {analysis.distanceIndexedProfileSamples.length >= 2 && ( +
+ +
+ )} +
+
+ )} + + + {canEdit && onUpdatePlace ? ( +
+ + {colorOpen && ( +
+ { if (canEdit) void onUpdatePlace(place.id, { route_color: color }) }} /> +
+ )} +
+ ) : ( +
+ + {colorLabel} +
)} -
+ -
-

{t('tours.detail.trackColor')}

- {canEdit && onUpdatePlace ? { if (canEdit) void onUpdatePlace(place.id, { route_color: color }) }} /> : } -
- - {(place.description || place.notes || assignment?.notes) && ( -
- {place.description &&
{place.description}
} - {place.notes &&
{place.notes}
} - {assignment?.notes &&
{t('places.assignmentNotes')}
{assignment.notes}
} -
- )} + {place.description && {place.description}} + {place.notes && {place.notes}} + {assignment?.notes && {assignment.notes}} {(placeFiles.length > 0 || (canEdit && onFileUpload)) && ( -
-
- - {canEdit && onFileUpload && } + {filesExpanded && placeFiles.length > 0 && ( +
+ {placeFiles.map(file => ( + + ))} +
+ )}
- {filesExpanded && placeFiles.length > 0 &&
{placeFiles.map(file => )}
} -
+ )} - {navigationTargets.length > 0 &&
{t('tours.detail.navigationHint')}
} + {navigationTargets.length > 0 && ( +
+ + {t('tours.detail.navigationHint')} +
+ )}
) } -function TourMetric({ label, value, icon }: { label: string; value: string; icon: React.ReactNode }) { - return
{icon}{label}
{value}
-} - -function TourActionButton({ buttonRef, onClick, icon, label, primary = false, danger = false }: { buttonRef?: React.Ref; onClick: () => void; icon: React.ReactNode; label: string; primary?: boolean; danger?: boolean }) { - const className = primary ? 'bg-accent text-accent-text' : danger ? 'text-danger hover:bg-danger-subtle' : 'text-content-secondary hover:bg-surface-hover' - return +/** Markdown in a framed box, like the inspector's; long words wrap instead of widening the card. */ +function MarkdownBox({ children }: { children: string }) { + return ( +
+ {children} +
+ ) } diff --git a/client/src/components/Tours/TourListRow.tsx b/client/src/components/Tours/TourListRow.tsx index b07ceee79..d6390a1ea 100644 --- a/client/src/components/Tours/TourListRow.tsx +++ b/client/src/components/Tours/TourListRow.tsx @@ -1,10 +1,7 @@ -import type { ReactNode } from 'react' +import { useId, type ReactNode } from 'react' import type { TourListItem } from '@trek/shared' -import { AlertTriangle, ArrowDown, ArrowUp } from 'lucide-react' -import { useTranslation } from '../../i18n' -import { useSettingsStore } from '../../store/settingsStore' -import { formatDistance, formatElevation } from '../../utils/units' -import { hikeSourceBadgeLabel } from './tourPresentation' +import { fs } from '../shared/DialogShell' +import { TourFactPills, TourTile } from './tourParts' interface TourListRowProps { tour: TourListItem @@ -15,65 +12,38 @@ interface TourListRowProps { children?: ReactNode } -/** Shared Tours list row used by both the ADD flow and the planner workspace. */ +/** 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 distanceUnit = useSettingsStore(state => state.settings.distance_unit) - + const nameId = useId() + const tone = selected ? 'bg-surface-selected' : disabled ? '' : 'hover:bg-surface-hover' return (
  • { if (!disabled) onSelect(tour, event.currentTarget) }} onKeyDown={event => { - if (disabled) return + if (disabled || event.target !== event.currentTarget) return if (event.key === 'Enter' || event.key === ' ') { event.preventDefault() onSelect(tour, event.currentTarget) } }} - onMouseEnter={event => { if (!selected) event.currentTarget.style.background = 'var(--bg-hover)' }} - onMouseLeave={event => { if (!selected) event.currentTarget.style.background = 'transparent' }} > -
    - {tour.name} -
    - - {hikeSourceBadgeLabel(tour, t)} - - T{tour.max_hiking_difficulty} - {tour.caution && ( - - {t('tours.caution.badge')} - - )} +
    + +
    + {tour.name} +
    -
    - - {formatDistance(tour.distance ?? 0, distanceUnit)} - - {tour.elevation_gain != null && ( - - - )} - {tour.elevation_loss != null && ( - - - )} -
    - {action &&
    {action}
    } + {action && ( +
    event.stopPropagation()}> + {action} +
    + )}
    {children}
  • diff --git a/client/src/components/Tours/ToursSidebar.test.tsx b/client/src/components/Tours/ToursSidebar.test.tsx index 667750d2c..0879cbf7e 100644 --- a/client/src/components/Tours/ToursSidebar.test.tsx +++ b/client/src/components/Tours/ToursSidebar.test.tsx @@ -55,7 +55,7 @@ describe('ToursSidebar', () => { render() const row = screen.getByRole('option', { name: /Selected ridge walk/i }) expect(row).toHaveAttribute('aria-selected', 'true') - expect(row).toHaveStyle({ background: 'var(--border-faint)' }) + expect(row).toHaveClass('bg-surface-selected') }) it('selects the tour from keyboard activation', () => { @@ -69,7 +69,7 @@ describe('ToursSidebar', () => { it.each([ ['en', 'A deliberately long English hiking tour name that should wrap without pushing its metrics aside'], ['de', 'Eine außergewöhnlich lange deutsche Wanderung mit ausführlicher Bezeichnung für schmale Seitenleisten'], - ])('keeps %s title, badges, atomic metrics and full-width action in separate rows', (language, name) => { + ])('keeps %s title, fact pills and the day action in one place-style row', (language, name) => { seedStore(useSettingsStore, { settings: { language, distance_unit: 'metric' } }) const onAssignToDay = vi.fn() const { container } = render( { const row = screen.getByRole('option', { name: new RegExp(name) }) expect(row).toHaveAttribute('aria-selected', 'true') - expect(row.querySelector('span[title]')).toHaveAttribute('title', name) + expect(row).toHaveTextContent(name) const metrics = row.querySelector('[data-testid="tour-metrics"]')! expect(metrics).toHaveTextContent('1.9 km') expect(metrics).toHaveTextContent('13 m') expect(metrics).toHaveTextContent('11 m') - for (const metric of metrics.children) expect(metric).toHaveClass('whitespace-nowrap') - expect(metrics.children[0].getAttribute('aria-label')).toContain('1.9 km') - expect(metrics.children[1].getAttribute('aria-label')).toContain('13 m') - expect(metrics.children[2].getAttribute('aria-label')).toContain('11 m') + const labels = [...metrics.querySelectorAll('[aria-label]')].map(element => element.getAttribute('aria-label')) + expect(labels[0]).toContain('1.9 km') + expect(labels[1]).toContain('13 m') + expect(labels[2]).toContain('11 m') expect(screen.getByText('TREK')).toBeInTheDocument() expect(screen.getByLabelText(/Maximum trail difficulty|Maximale Wanderschwierigkeit|T2/)).toBeInTheDocument() const actionRow = row.querySelector('[data-testid="tour-action-row"]')! - expect(actionRow).toHaveClass('w-full') - expect(actionRow.querySelector('button')).toHaveClass('w-full') + expect(actionRow.querySelector('button')).toHaveAttribute('aria-haspopup', 'menu') expect(container.querySelector('[data-testid="tour-metrics"]')).toBeTruthy() fireEvent.click(screen.getByRole('button', { name: /Add to day|Zum Tag hinzufügen/ })) @@ -130,7 +129,8 @@ describe('ToursSidebar', () => { expect(onToursChanged).not.toHaveBeenCalled() }) - it('keeps the day picker open and does not refresh when assignment fails', async () => { + it('neither refreshes nor reports success when assignment fails', async () => { + toast.success.mockClear() const onToursChanged = vi.fn() render( { fireEvent.click(screen.getByRole('button', { name: /Add to day|Zum Tag hinzufügen/ })) fireEvent.click(screen.getByRole('button', { name: /Day one/ })) - await waitFor(() => expect(screen.getByRole('button', { name: /Day one/ })).toBeInTheDocument()) + await act(async () => {}) + expect(toast.success).not.toHaveBeenCalled() expect(onToursChanged).not.toHaveBeenCalled() }) diff --git a/client/src/components/Tours/ToursSidebar.tsx b/client/src/components/Tours/ToursSidebar.tsx index 776da3a14..834af50ea 100644 --- a/client/src/components/Tours/ToursSidebar.tsx +++ b/client/src/components/Tours/ToursSidebar.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useRef, useState } from 'react' -import { Check, ChevronDown, FileDown, Mountain } from 'lucide-react' +import { Check, ChevronDown, FileDown } from 'lucide-react' import type { TourListItem } from '@trek/shared' import type { Day } from '../../types' import { toursApi } from '../../api/client' @@ -10,6 +10,8 @@ import TourListRow from './TourListRow' import { useTourPermissions, type TourPermissionProps } from './useTourPermissions' import { NEUTRAL_TINT, fs } from '../shared/DialogShell' import { Tooltip } from '../shared/Tooltip' +import EmptyState from '../shared/EmptyState' +import { TourDayMenu } from './tourParts' const MAX_FILE_BYTES = 10 * 1024 * 1024 @@ -27,10 +29,6 @@ interface ToursSidebarProps extends TourPermissionProps { onSelectTour?: (tour: TourListItem | null, opener?: HTMLElement) => void } -function dayLabel(day: Day, index: number, t: (key: string, params?: Record) => string): string { - if (day.title) return day.title - return t('dayplan.dayN', { n: index + 1 }) -} /** * Tours mode of the right add-panel: a selection list of existing tours @@ -48,7 +46,6 @@ export default function ToursSidebar({ tripId, days, tours, loading = false, sel const loadTrip = useTripStore(s => s.loadTrip) const [filter, setFilter] = useState<'all' | 'unplanned' | 'planned'>('all') const [importing, setImporting] = useState(false) - const [pickerOpenFor, setPickerOpenFor] = useState(null) const fileInputRef = useRef(null) const filterRef = useRef(null) const filterButtonRef = useRef(null) @@ -90,7 +87,6 @@ export default function ToursSidebar({ tripId, days, tours, loading = false, sel if (!canAssign) return const assigned = await onAssignToDay(tour.place_id, day.id) if (assigned === false) return - setPickerOpenFor(null) toast.success(t('tours.addedToDay', { n: index + 1 })) } @@ -170,53 +166,21 @@ export default function ToursSidebar({ tripId, days, tours, loading = false, sel
    -
    +
    {!loading && filtered.length === 0 ? ( -
    - -

    {t('tours.empty.title')}

    -

    {t('tours.empty.body')}

    -
    + {t('tours.empty.body')}

    } /> ) : ( -
      - {filtered.map((tour) => { - const isSelected = tour.place_id === selectedPlaceId - return ( +
        + {filtered.map(tour => ( onSelectTour?.(selectedTour, opener)} - action={( - - )} - > - {canAssign && pickerOpenFor === tour.place_id && ( -
        e.stopPropagation()}> - {days.map((day, i) => ( - - ))} -
        - )} -
        - ) - })} + action={ { void handleAssign(tour, day, index) }} />} + /> + ))}
      )}
    diff --git a/client/src/components/Tours/planner/TourPlannerPanels.test.tsx b/client/src/components/Tours/planner/TourPlannerPanels.test.tsx index daad7d8cf..8333669c6 100644 --- a/client/src/components/Tours/planner/TourPlannerPanels.test.tsx +++ b/client/src/components/Tours/planner/TourPlannerPanels.test.tsx @@ -146,23 +146,24 @@ describe('TourPlannerToursRail', () => { expect(controller.startNewTour).toHaveBeenCalledOnce() expect(controller.discard).not.toHaveBeenCalled() fireEvent.click(screen.getByRole('button', { name: 'Assign to a day' })) - const dayOption = screen.getByLabelText('Pick a day').querySelector('option[value="7"]') + const dayOption = screen.getByRole('button', { name: /Summit day/ }) expect(dayOption).toHaveTextContent(/Day 1/i) expect(dayOption).toHaveTextContent(/May 15/i) expect(dayOption).toHaveTextContent(/Fri/i) - fireEvent.change(screen.getByLabelText('Pick a day'), { target: { value: '7' } }) + fireEvent.click(dayOption) await waitFor(() => expect(onAssignToDay).toHaveBeenCalledWith(42, 7)) }) - it('keeps the saved Tour day picker open when assignment fails', async () => { + it('keeps the saved outcome and its day action when assignment fails', async () => { const controller = planner({ saveOutcome: tour, editingPlaceId: tour.place_id }) const onAssignToDay = vi.fn().mockResolvedValue(false) render() fireEvent.click(screen.getByRole('button', { name: 'Assign to a day' })) - fireEvent.change(screen.getByLabelText('Pick a day'), { target: { value: '7' } }) + fireEvent.click(screen.getByRole('button', { name: /Summit day/ })) await waitFor(() => expect(onAssignToDay).toHaveBeenCalledWith(42, 7)) - expect(screen.getByLabelText('Pick a day')).toBeInTheDocument() + expect(screen.getByRole('status')).toHaveTextContent('Tour saved') + expect(screen.getByRole('button', { name: 'Assign to a day' })).toBeEnabled() }) it('TOUR-PLANNER-RAIL-004: protects a dirty draft before selecting a GPX Tour', () => { @@ -345,9 +346,8 @@ describe('TourPlannerRail', () => { expect(secondNumber).toHaveTextContent('2') expect(firstNumber).toHaveAttribute('data-waypoint-number', '1') expect(secondNumber).toHaveAttribute('data-waypoint-number', '2') - expect(firstNumber).toHaveClass('bg-surface-tertiary', 'text-content-secondary', 'font-geist', 'tabular-nums') - expect(firstNumber).not.toHaveClass('text-white') - expect(firstNumber).toHaveStyle({ display: 'flex', width: '28px', minWidth: '28px', height: '28px', lineHeight: '1' }) + expect(firstNumber).toHaveClass('bg-accent', 'text-accent-text', 'font-geist', 'tabular-nums', 'h-7', 'w-7') + expect(secondNumber).toHaveClass('bg-surface-card', 'text-content', 'font-geist', 'tabular-nums', 'h-7', 'w-7') }) it('TOUR-PLANNER-RAIL-007: acknowledges alpine routing once per planner session', () => { @@ -409,7 +409,7 @@ describe('TourPlannerRail', () => { const { rerender } = render() expect(screen.getByText('Plan a tour')).toBeInTheDocument() - expect(screen.getByText('Create a route with waypoints and save it to this trip.')).toBeInTheDocument() + expect(screen.getByText('Start a new tour and click the map to place its waypoints.')).toBeInTheDocument() fireEvent.click(screen.getByRole('button', { name: 'Plan new tour' })) expect(controller.startNewTour).toHaveBeenCalledOnce() expect(screen.queryByLabelText('Tour name')).not.toBeInTheDocument() diff --git a/client/src/components/Tours/planner/TourPlannerPanels.tsx b/client/src/components/Tours/planner/TourPlannerPanels.tsx index a23dc75ad..84b2f48d6 100644 --- a/client/src/components/Tours/planner/TourPlannerPanels.tsx +++ b/client/src/components/Tours/planner/TourPlannerPanels.tsx @@ -1,21 +1,24 @@ -import { useRef, useState } from 'react' +import { useRef, useState, type ReactNode } from 'react' import type { TourListItem } from '@trek/shared' -import { ArrowDown, ArrowLeft, ArrowUp, CheckCircle2, ChevronDown, Clock3, Mountain, Plus, Redo2, RotateCcw, Save, ShieldAlert, Trash2, Undo2 } from 'lucide-react' +import { AlertTriangle, ArrowDown, ArrowLeft, ArrowUp, CheckCircle2, 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' -import { formatDate } from '../../../utils/formatters' import { formatDistance, formatElevation } from '../../../utils/units' import ConfirmDialog from '../../shared/ConfirmDialog' import CustomSelect from '../../shared/CustomSelect' -import { DialogButton } from '../../shared/DialogShell' +import { DialogButton, NEUTRAL_TINT, fs } from '../../shared/DialogShell' +import { INPUT, LABEL } from '../../shared/dialogParts' import EmptyState from '../../shared/EmptyState' import { useToast } from '../../shared/Toast' -import { BarButton } from '../../Planner/planParts' +import { Tooltip } from '../../shared/Tooltip' +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 type { RouteProfileFocus } from '../../../utils/routeGeometry' +import { FoldButton, TourDayMenu, TourMetricFields, TourNotice, TourSection } from '../tourParts' +import type { DistanceIndexedProfileSample, RouteProfileFocus } from '../../../utils/routeGeometry' import type { TourPlannerController, TourPlannerStatus } from './useTourPlanner' import { useTourPermissions, type TourPermissionProps } from '../useTourPermissions' @@ -30,17 +33,35 @@ const STATUS_KEY: Partial> = { saved: 'tours.planner.saved', } +/** The tinted head band both rails open with, like every panel head in the planner. */ +const RAIL_HEAD = 'flex flex-none items-start gap-2 border-b border-edge-faint px-3 py-2.5' + +type Translate = (key: string, params?: Record) => string + function roleLabel(role: 'start' | 'via' | 'end', t: (key: string) => string) { return t(`tours.planner.${role}`) } -function profileFocusLabel(focus: RouteProfileFocus, t: (key: string, params?: Record) => string, unit: 'metric' | 'imperial'): string { +function profileFocusLabel(focus: RouteProfileFocus, t: Translate, unit: 'metric' | 'imperial'): string { return t('tours.planner.inspector.profileFocus', { distance: formatDistance(focus.distanceMeters / 1000, unit), - elevation: focus.elevationMeters == null ? '—' : formatElevation(focus.elevationMeters, unit), + elevation: focus.elevationMeters == null ? '-' : formatElevation(focus.elevationMeters, unit), }) } +/** The title of a rail's head band, with an optional line under it. */ +function RailTitle({ title, sub, aside }: { title: string; sub?: ReactNode; aside?: ReactNode }) { + return ( +
    +
    +

    {title}

    + {aside} +
    + {sub &&

    {sub}

    } +
    + ) +} + 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() @@ -59,25 +80,25 @@ export function TourPlannerRail({ planner, canEdit: editPermission, canAssign: a if (planner.mode.type === 'neutral') { return ( -
    - -

    {t('tours.planner.neutralBody')}

    -
    - } disabled={!canEdit} onClick={() => { if (canEdit) planner.startNewTour() }}> - {t('tours.planner.newTour')} - -
    -

    {t('tours.planner.neutralHint')}

    -
    - )} - /> +
    +
    + +
    +
    + {t('tours.planner.neutralBody')}

    } + /> +
    ) } @@ -85,61 +106,34 @@ export function TourPlannerRail({ planner, canEdit: editPermission, canAssign: a if (planner.mode.type === 'view-gpx') { const { tour } = planner.readOnlyGpxTour const analysis = planner.readOnlyGpxAnalysis + const extra = tour.duration != null + ? [{ label: t('tours.planner.inspector.duration'), value: t('tours.durationMinutes', { count: Math.max(1, Math.round(tour.duration)) }) }] + : [] return ( <> -
    -
    - planner.returnToNeutral()} className="mt-0.5 focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"> - +
    +
    + planner.returnToNeutral()} className="focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"> + -
    -

    {t('tours.planner.gpxTitle')}

    -

    {tour.name}

    -

    {hikeSourceBadgeLabel(tour, t)}

    -
    + {hikeSourceBadgeLabel(tour, t)}} />
    -
    -

    {t('tours.detail.gpxReadOnly')}

    +
    + }>{t('tours.detail.gpxReadOnly')} {analysis ? ( -
    -

    {t('tours.planner.inspector.title')}

    -
    - - {tour.duration != null && } - {analysis.minEle != null && } - {analysis.maxEle != null && } - {analysis.gain != null && } - {analysis.loss != null && } -
    - {analysis.distanceIndexedProfileSamples.length >= 2 ? ( -
    -
    -

    {t('tours.planner.inspector.elevation')}

    - -
    - -
    - ) : ( -

    {t('tours.planner.inspector.elevationPlaceholder')}

    - )} -
    + <> + + + + + ) : ( -

    {t('tours.planner.gpxNoGeometry')}

    + {t('tours.planner.gpxNoGeometry')} )}
    @@ -149,125 +143,142 @@ export function TourPlannerRail({ planner, canEdit: editPermission, canAssign: a } const isSavedEdit = planner.mode.type === 'edit-saved' + const lastIndex = planner.waypoints.length - 1 return ( <> -
    -
    -
    -
    - requestClose()} disabled={isSaving} className="shrink-0 focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"> - - -
    -

    {t(isSavedEdit ? 'tours.planner.editTitle' : 'tours.planner.newTitle')}

    -

    {t('tours.planner.mapHint')}

    -
    -
    -
    - { if (canEdit && !isSaving) planner.undo() }} disabled={!canEdit || isSaving || !planner.canUndo}> - - - { if (canEdit && !isSaving) planner.redo() }} disabled={!canEdit || isSaving || !planner.canRedo}> - - -
    -
    - {planner.draftRestored && planner.hasUnsavedChanges &&

    {t('tours.planner.restored')}

    } - - { if (canEdit && !isSaving) planner.setName(event.target.value) }} - maxLength={255} - placeholder={t('tours.planner.namePlaceholder')} - className="mt-1 w-full rounded-lg border border-edge bg-surface px-3 py-2 text-sm text-content outline-none focus:border-accent" - /> - - { - if (!canEdit || isSaving) return - const value = Number(nextValue) as 1 | 2 | 3 | 4 | 5 | 6 - if (value >= 4 && !alpineAcknowledged.current) setPendingDifficulty(value) - else planner.setMaxHikingDifficulty(value) - }} - options={[1, 2, 3, 4, 5, 6].map(value => ({ value, label: t(`tours.planner.difficulty.t${value}`) }))} - size="sm" - style={{ marginTop: 4 }} - /> - {planner.maxHikingDifficulty === 3 &&

    {t('tours.planner.difficulty.t3Warning')}

    } +
    +
    + requestClose()} disabled={isSaving} className="focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"> + + + + { if (canEdit && !isSaving) planner.undo() }} disabled={!canEdit || isSaving || !planner.canUndo}> + + + { if (canEdit && !isSaving) planner.redo() }} disabled={!canEdit || isSaving || !planner.canRedo}> + +
    -
    -

    {t('tours.planner.waypoints')}

    - {planner.waypoints.length === 0 ? ( -
    - -

    {t('tours.planner.firstUseDetails')}

    -
    - ) : ( -
      - {planner.waypoints.map((point, index) => { - const selected = planner.selectedWaypointId === point.id - return ( -
    1. - -
    2. - ) - })} -
    +
    + {planner.draftRestored && planner.hasUnsavedChanges && ( + }>{t('tours.planner.restored')} )} +
    +
    + + { if (canEdit && !isSaving) planner.setName(event.target.value) }} + maxLength={255} + placeholder={t('tours.planner.namePlaceholder')} + className={INPUT} + /> +
    +
    + + { + if (!canEdit || isSaving) return + const value = Number(nextValue) as 1 | 2 | 3 | 4 | 5 | 6 + if (value >= 4 && !alpineAcknowledged.current) setPendingDifficulty(value) + else planner.setMaxHikingDifficulty(value) + }} + options={[1, 2, 3, 4, 5, 6].map(value => ({ value, label: t(`tours.planner.difficulty.t${value}`) }))} + /> +
    + {planner.maxHikingDifficulty === 3 && ( + }>{t('tours.planner.difficulty.t3Warning')} + )} +
    + + + {planner.waypoints.length === 0 ? ( +
    + +

    {t('tours.planner.startingPoint')} {t('tours.planner.firstUseDetails')}

    +
    + ) : ( +
      + {planner.waypoints.map((point, index) => { + const selected = planner.selectedWaypointId === point.id + return ( +
    1. + +
    2. + ) + })} +
    + )} +
    + {(statusKey || planner.error) && ( -
    + : } + > {planner.error === 'save' ? t('tours.planner.status.saveFailed') : statusKey ? t(statusKey) : null} - {canRetry && } -
    + {canRetry && ( + + )} + )}
    -
    -

    - +

    +

    + {t('tours.planner.safetyNote')} {t('tours.planner.safetyNoteDetails')}

    -
    - } onClick={requestClose} disabled={isSaving || (!planner.hasUnsavedChanges && !isSavedEdit)}> +
    + } onClick={requestClose} disabled={isSaving || (!planner.hasUnsavedChanges && !isSavedEdit)}> {t(isSavedEdit ? 'tours.planner.discardChanges' : 'tours.planner.discard')} - } onClick={() => { if (canEdit && !isSaving) void planner.save() }} disabled={!canEdit || isSaving || !planner.canSave}> + } onClick={() => { if (canEdit && !isSaving) void planner.save() }} disabled={!canEdit || isSaving || !planner.canSave}> {t(isSavedEdit ? 'tours.planner.saveChanges' : 'tours.planner.save')}
    @@ -301,22 +312,59 @@ export function TourPlannerRail({ planner, canEdit: editPermission, canAssign: a ) } -function TourPlannerMetric({ label, value }: { label: string; value: string }) { - return
    {label}
    {value}
    +/** A quiet icon control on a waypoint row; a span, because the row itself is the button. */ +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} + + + ) } -function ElevationProfileToggle({ id, expanded, onToggle, t }: { id: string; expanded: boolean; onToggle: () => void; t: (key: string) => string }) { +/** The elevation profile under its label, folded away with the chevron beside it. */ +function ElevationSection({ id, samples, gradientId, planner }: { + id: string + samples: DistanceIndexedProfileSample[] + gradientId: string + planner: TourPlannerController +}) { + const { t } = useTranslation() + const distanceUnit = useSettingsStore(state => state.settings.distance_unit) + if (samples.length < 2) { + return ( + + {t('tours.planner.inspector.elevationPlaceholder')} + + ) + } + const expanded = planner.elevationProfileExpanded return ( - + + ) } @@ -343,19 +391,12 @@ interface TourPlannerToursRailProps extends TourPermissionProps { onViewGpxTour: (tour: TourListItem) => void } -function plannerDayLabel(day: Day, index: number, locale: string, t: (key: string, params?: Record) => string): string { - const dayNumber = t('dayplan.dayN', { n: index + 1 }) - const date = formatDate(day.date, locale) - return [dayNumber, date, day.title].filter(Boolean).join(' · ') -} - export function TourPlannerToursRail({ planner, tours, days, loading, onAssignToDay, onViewGpxTour, canEdit: editPermission, canAssign: assignPermission }: TourPlannerToursRailProps) { const { canEdit, canAssign } = useTourPermissions({ canEdit: editPermission ?? planner.canEdit, canAssign: assignPermission ?? planner.canAssign }) - const { t, locale } = useTranslation() + const { t } = useTranslation() const toast = useToast() const distanceUnit = useSettingsStore(state => state.settings.distance_unit) const [pendingTour, setPendingTour] = useState(null) - const [dayPickerOpen, setDayPickerOpen] = useState(false) const [assigning, setAssigning] = useState(false) const isSaving = planner.isSaving const hasRoute = (planner.mode.type === 'new-draft' || planner.mode.type === 'edit-saved') && planner.route !== null @@ -385,135 +426,87 @@ export function TourPlannerToursRail({ planner, tours, days, loading, onAssignTo if (!canAssign || !savedTour) return setAssigning(true) try { - const assigned = await onAssignToDay(savedTour.place_id, dayId) - if (assigned === false) return - setDayPickerOpen(false) + await onAssignToDay(savedTour.place_id, dayId) } finally { setAssigning(false) } } + const walkingTime = { label: t('tours.planner.inspector.duration'), value: t('tours.durationMinutes', { count: Math.max(1, Math.round((planner.durationSeconds ?? 0) / 60)) }) } + return ( -