fix(tours): hold the client warning ratchet and the server format baseline flat after the rebase onto dev

This commit is contained in:
Maurice
2026-10-14 09:11:47 +02:00
parent 4c487ceefa
commit dbee1664af
14 changed files with 588 additions and 394 deletions
@@ -94,6 +94,16 @@ function clickOn(el: Element) {
})
}
/** The Leaflet marker that renders `el`; throws when the map has none. */
function markerOf(map: L.Map, el: Element): L.Marker {
let found: L.Marker | null = null
map.eachLayer(layer => {
if (layer instanceof L.Marker && layer.getElement() === el) found = layer
})
if (!found) throw new Error('no marker renders this element')
return found
}
function pointer(type: string, x: number, y: number): Event {
const event = new Event(type, { bubbles: true, cancelable: true })
Object.defineProperties(event, {
@@ -149,11 +159,7 @@ describe('MapView on a real Leaflet map', () => {
const map = maps[maps.length - 1]
await waitFor(() => expect(view.container.querySelector('.tour-planner-waypoint-marker')).not.toBeNull())
const pin = view.container.querySelector('.tour-planner-waypoint-marker') as HTMLElement
let marker: L.Marker | null = null
map.eachLayer(layer => {
if (layer instanceof L.Marker && layer.getElement() === pin) marker = layer
})
expect(marker).not.toBeNull()
const marker = markerOf(map, pin)
const start = map.latLngToContainerPoint([waypoint.lat, waypoint.lng])
const end = start.add([30, 20])
const expected = map.containerPointToLatLng(end)
@@ -164,8 +170,8 @@ describe('MapView on a real Leaflet map', () => {
pin.dispatchEvent(mouse('mousedown', start.x, start.y))
pin.dispatchEvent(pointer('pointermove', end.x, end.y))
})
expect(marker!.getLatLng().lat).toBeCloseTo(expected.lat, 6)
expect(marker!.getLatLng().lng).toBeCloseTo(expected.lng, 6)
expect(marker.getLatLng().lat).toBeCloseTo(expected.lat, 6)
expect(marker.getLatLng().lng).toBeCloseTo(expected.lng, 6)
expect(onMove).not.toHaveBeenCalled()
expect(waypoints[0]).toEqual(waypoint)
@@ -184,8 +190,8 @@ describe('MapView on a real Leaflet map', () => {
routeProfileFocus={{ distanceMeters: 100, elevationMeters: 0, lat: 34.69, lng: 135.84, sampleIndex: 0 }}
/>
)
expect(marker!.getLatLng().lat).toBeCloseTo(expected.lat, 6)
expect(marker!.getLatLng().lng).toBeCloseTo(expected.lng, 6)
expect(marker.getLatLng().lat).toBeCloseTo(expected.lat, 6)
expect(marker.getLatLng().lng).toBeCloseTo(expected.lng, 6)
act(() => {
pin.dispatchEvent(pointer('pointerup', end.x, end.y))
@@ -238,10 +244,7 @@ describe('MapView on a real Leaflet map', () => {
const map = maps[maps.length - 1]
await waitFor(() => expect(view.container.querySelector('.tour-planner-waypoint-marker')).not.toBeNull())
const pin = view.container.querySelector('.tour-planner-waypoint-marker') as HTMLElement
let marker: L.Marker | null = null
map.eachLayer(layer => {
if (layer instanceof L.Marker && layer.getElement() === pin) marker = layer
})
const marker = markerOf(map, pin)
const start = map.latLngToContainerPoint([waypoint.lat, waypoint.lng])
const end = start.add([30, 20])
@@ -250,15 +253,15 @@ describe('MapView on a real Leaflet map', () => {
pin.dispatchEvent(pointer('pointermove', end.x, end.y))
pin.dispatchEvent(pointer('pointercancel', end.x, end.y))
})
expect(marker!.getLatLng().lat).toBeCloseTo(waypoint.lat, 6)
expect(marker!.getLatLng().lng).toBeCloseTo(waypoint.lng, 6)
expect(marker.getLatLng().lat).toBeCloseTo(waypoint.lat, 6)
expect(marker.getLatLng().lng).toBeCloseTo(waypoint.lng, 6)
expect(onMove).not.toHaveBeenCalled()
clickOn(pin)
expect(onSelect).toHaveBeenCalledOnce()
expect(onSelect).toHaveBeenCalledWith(waypoint.id)
expect(onMapClick).not.toHaveBeenCalled()
act(() => map.fire('click', { latlng: L.latLng(34.7, 135.85) }))
act(() => { map.fire('click', { latlng: L.latLng(34.7, 135.85) }) })
expect(onMapClick).toHaveBeenCalledOnce()
})
+2 -2
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState, useMemo, useCallback, createElement, memo, type ComponentType } from 'react'
import { useEffect, useLayoutEffect, useRef, useState, useMemo, useCallback, createElement, memo, type ComponentType } from 'react'
import DOM from 'react-dom'
import { renderIconMarkup } from '../../utils/iconMarkup'
import { MapContainer, TileLayer, Marker, Polyline, CircleMarker, Circle, useMap, Tooltip } from 'react-leaflet'
@@ -784,7 +784,7 @@ function PlannerWaypointMarker({
const map = useMap()
const [marker, setMarker] = useState<L.Marker | null>(null)
const handlersRef = useRef({ onClick, onMove })
handlersRef.current = { onClick, onMove }
useLayoutEffect(() => { handlersRef.current = { onClick, onMove } }, [onClick, onMove])
const position = useMemo(() => [point.lat, point.lng] as [number, number], [point.lat, point.lng])
const icon = useMemo(() => plannerWaypointIcon(index, selected, !!onMove), [index, onMove, selected])
const eventHandlers = useMemo(() => ({
+4 -2
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useMemo, useState, createElement, useCallback } from 'react'
import { useEffect, useLayoutEffect, useRef, useMemo, useState, createElement, useCallback } from 'react'
import { makeMarkerDraggable, makePoiDraggable, draggedPoiId } from './markerDrag'
import { useStableVias } from './viaMarkerState'
import { wirePlannerWaypointDrag } from './plannerWaypointDrag'
@@ -711,7 +711,9 @@ export function MapViewGL({
const viaPinsRef = useRef<PlacePin[]>([])
const plannerWaypointPinsRef = useRef<PlacePin[]>([])
const plannerWaypointHandlersRef = useRef({ onPlannerWaypointClick, onPlannerWaypointMove })
plannerWaypointHandlersRef.current = { onPlannerWaypointClick, onPlannerWaypointMove }
useLayoutEffect(() => {
plannerWaypointHandlersRef.current = { onPlannerWaypointClick, onPlannerWaypointMove }
}, [onPlannerWaypointClick, onPlannerWaypointMove])
const plannerWaypointsDraggable = !!onPlannerWaypointMove
const profileFocusPinRef = useRef<PlacePin | null>(null)
/** The drive-time pills on the offered routes; same treatment. */
@@ -117,24 +117,12 @@ export default function LocationSelect({ value, onChange, placeholder, ariaLabel
// than written into `results`, so a search answer that lands after the field was
// shortened cannot replace them, and a place already picked does not reopen them.
const typed = query.trim()
const showPicks = !value && typed.length < 3 && !!places?.length
const rows = showPicks
? (places ?? []).filter(p => !typed || p.name.toLowerCase().includes(typed.toLowerCase()))
: results
const showNoResults =
showSearchStatus && !showPicks && searchedQuery === typed && rows.length === 0 && !loading && !searchError
const showError = showSearchStatus && searchError && searchedQuery === typed
const listOpen = open && ((loading && !showPicks) || rows.length > 0 || showNoResults || showError)
const { showPicks, rows } = pickRows(value, typed, places, results)
const { showNoResults, showError, listOpen } = searchStatus({
showSearchStatus, showPicks, searchedQuery, typed, rowCount: rows.length, loading, searchError, open,
})
const accessibleLabel = ariaLabel ?? placeholder ?? t('reservations.searchLocation')
const statusLine = showError ? (
<div role="alert" className="text-content-faint" style={{ padding: 10, fontSize: 'calc(12px * var(--fs-scale-body, 1))' }}>
{t('common.error')}
</div>
) : showNoResults ? (
<div role="status" className="text-content-faint" style={{ padding: 10, fontSize: 'calc(12px * var(--fs-scale-body, 1))' }}>
{t('help.center.searchEmpty', { query: typed })}
</div>
) : null
const statusLine = renderStatusLine(showError, showNoResults, typed, t)
const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
// Escape closes the list even while it only shows a status line.
@@ -156,14 +144,7 @@ export default function LocationSelect({ value, onChange, placeholder, ariaLabel
onOpen={() => setOpen(true)}
onKeyDown={onKeyDown}
onClear={value ? clear : undefined}
inputAria={showSearchStatus ? {
role: 'combobox',
'aria-label': accessibleLabel,
'aria-autocomplete': 'list',
'aria-expanded': listOpen,
'aria-controls': listOpen ? listId : undefined,
'aria-activedescendant': listOpen && highlight >= 0 ? `${listId}-option-${highlight}` : undefined,
} : undefined}
inputAria={showSearchStatus ? comboboxAria(accessibleLabel, listOpen, listId, highlight) : undefined}
/>
{open && (
@@ -190,3 +171,63 @@ export default function LocationSelect({ value, onChange, placeholder, ariaLabel
</div>
)
}
function pickRows<T>(value: LocationPoint | null, typed: string, places: LocationPoint[] | undefined, results: T[]) {
const showPicks = !value && typed.length < 3 && !!places?.length
const rows: (T | LocationPoint)[] = showPicks
? (places ?? []).filter(p => !typed || p.name.toLowerCase().includes(typed.toLowerCase()))
: results
return { showPicks, rows }
}
interface SearchStatusInput {
showSearchStatus: boolean
showPicks: boolean
searchedQuery: string | null
typed: string
rowCount: number
loading: boolean
searchError: boolean
open: boolean
}
function searchStatus({ showSearchStatus, showPicks, searchedQuery, typed, rowCount, loading, searchError, open }: SearchStatusInput) {
const showNoResults =
showSearchStatus && !showPicks && searchedQuery === typed && rowCount === 0 && !loading && !searchError
const showError = showSearchStatus && searchError && searchedQuery === typed
const listOpen = open && ((loading && !showPicks) || rowCount > 0 || showNoResults || showError)
return { showNoResults, showError, listOpen }
}
function renderStatusLine(
showError: boolean,
showNoResults: boolean,
typed: string,
t: ReturnType<typeof useTranslation>['t'],
) {
return showError ? (
<div role="alert" className="text-content-faint" style={{ padding: 10, fontSize: 'calc(12px * var(--fs-scale-body, 1))' }}>
{t('common.error')}
</div>
) : showNoResults ? (
<div role="status" className="text-content-faint" style={{ padding: 10, fontSize: 'calc(12px * var(--fs-scale-body, 1))' }}>
{t('help.center.searchEmpty', { query: typed })}
</div>
) : null
}
function comboboxAria(
accessibleLabel: string,
listOpen: boolean,
listId: string,
highlight: number,
): React.AriaAttributes & { role?: React.AriaRole } {
return {
role: 'combobox',
'aria-label': accessibleLabel,
'aria-autocomplete': 'list',
'aria-expanded': listOpen,
'aria-controls': listOpen ? listId : undefined,
'aria-activedescendant': listOpen && highlight >= 0 ? `${listId}-option-${highlight}` : undefined,
}
}
@@ -1,6 +1,6 @@
import type { TourListItem } from '@trek/shared';
import { ArrowUp, Clock, Footprints, Pause, Ruler, type LucideIcon } from 'lucide-react';
import { useId, useState } from 'react';
import { createElement, useId, useState } from 'react';
import { useTranslation } from '../../i18n';
import { useSettingsStore } from '../../store/settingsStore';
import { formatDistance, formatElevation } from '../../utils/units';
@@ -10,7 +10,6 @@ import { formatPlannedTourDuration, tourPlannedTimes } from './tourPresentation'
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 (
<span
@@ -19,7 +18,7 @@ export function TourDayRowIcon({ type, size = 27 }: { type: string; size?: numbe
className="grid flex-none place-items-center rounded-full"
style={{ width: size, height: size }}
>
<Icon size={Math.round(size * 0.6)} aria-hidden="true" />
{createElement(tourDayRowIcon(type), { size: Math.round(size * 0.6), 'aria-hidden': 'true' })}
</span>
);
}
@@ -137,7 +137,6 @@ 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')
@@ -220,27 +219,7 @@ export default function TourDetailDialog({
<div className="mt-2 flex flex-wrap items-center gap-1.5">
<SoftPill icon={<Mountain size={11} strokeWidth={2} className="flex-none" style={{ color: trackColor }} />}>{t(`tourTypes.${tour.tour_type}`)}</SoftPill>
{tour.difficulty && <SoftPill>{t('tours.detail.difficulty')}: {tour.difficulty}</SoftPill>}
{tourTimes.walkingMinutes != null && (
<TimePill>
<span aria-label={`${t('tours.planner.inspector.duration')}: ${formatPlannedTourDuration(tourTimes.walkingMinutes)}`}>
{t('tours.planner.inspector.duration')}: {formatPlannedTourDuration(tourTimes.walkingMinutes)}
</span>
</TimePill>
)}
{tourTimes.breakMinutes != null && (
<TimePill>
<span aria-label={`${t('tours.planner.breaksAdditional')}: ${formatPlannedTourDuration(tourTimes.breakMinutes)}`}>
{t('tours.planner.breaksAdditional')}: {formatPlannedTourDuration(tourTimes.breakMinutes)}
</span>
</TimePill>
)}
{tourTimes.plannedTotalMinutes != null && (
<TimePill>
<span aria-label={`${t('tours.planner.plannedTotalDuration')}: ${formatPlannedTourDuration(tourTimes.plannedTotalMinutes)}${tourTimes.manuallyOverridden ? `, ${t('tours.planner.plannedTotalManual')}` : ''}`}>
{t('tours.planner.plannedTotalDuration')}: {formatPlannedTourDuration(tourTimes.plannedTotalMinutes)}{tourTimes.manuallyOverridden ? ` · ${t('tours.planner.plannedTotalManual')}` : ''}
</span>
</TimePill>
)}
<TourTimePills tour={tour} />
<SoftPill>{hikeSourceBadgeLabel(tour, t)}</SoftPill>
{tour.caution && (
<Tooltip label={t('tours.caution.tooltip')}>
@@ -396,6 +375,37 @@ export default function TourDetailDialog({
)
}
/** Walking time, breaks and the planned total, each as its own pill; missing values are skipped. */
function TourTimePills({ tour }: { tour: TourListItem }) {
const { t } = useTranslation()
const tourTimes = tourPlannedTimes(tour)
return (
<>
{tourTimes.walkingMinutes != null && (
<TimePill>
<span aria-label={`${t('tours.planner.inspector.duration')}: ${formatPlannedTourDuration(tourTimes.walkingMinutes)}`}>
{t('tours.planner.inspector.duration')}: {formatPlannedTourDuration(tourTimes.walkingMinutes)}
</span>
</TimePill>
)}
{tourTimes.breakMinutes != null && (
<TimePill>
<span aria-label={`${t('tours.planner.breaksAdditional')}: ${formatPlannedTourDuration(tourTimes.breakMinutes)}`}>
{t('tours.planner.breaksAdditional')}: {formatPlannedTourDuration(tourTimes.breakMinutes)}
</span>
</TimePill>
)}
{tourTimes.plannedTotalMinutes != null && (
<TimePill>
<span aria-label={`${t('tours.planner.plannedTotalDuration')}: ${formatPlannedTourDuration(tourTimes.plannedTotalMinutes)}${tourTimes.manuallyOverridden ? `, ${t('tours.planner.plannedTotalManual')}` : ''}`}>
{t('tours.planner.plannedTotalDuration')}: {formatPlannedTourDuration(tourTimes.plannedTotalMinutes)}{tourTimes.manuallyOverridden ? ` · ${t('tours.planner.plannedTotalManual')}` : ''}
</span>
</TimePill>
)}
</>
)
}
/** Markdown in a framed box, like the inspector's; long words wrap instead of widening the card. */
function MarkdownBox({ children }: { children: string }) {
return (
@@ -92,8 +92,6 @@ export function TourPlannerRail({ planner, canEdit: editPermission, canAssign: a
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<string | null>(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]
@@ -103,6 +101,10 @@ export function TourPlannerRail({ planner, canEdit: editPermission, canAssign: a
if (planner.hasUnsavedChanges) setConfirmDiscard(true)
else planner.returnToNeutral()
}
const changeDifficulty = (value: 1 | 2 | 3 | 4 | 5 | 6) => {
if (value >= 4 && !alpineAcknowledged.current) setPendingDifficulty(value)
else planner.setMaxHikingDifficulty(value)
}
if (planner.mode.type === 'neutral') {
return (
@@ -169,7 +171,6 @@ export function TourPlannerRail({ planner, canEdit: editPermission, canAssign: a
}
const isSavedEdit = planner.mode.type === 'edit-saved'
const lastIndex = planner.waypoints.length - 1
return (
<>
@@ -192,279 +193,9 @@ export function TourPlannerRail({ planner, canEdit: editPermission, canAssign: a
<TourNotice role="status" icon={<RotateCcw size={13} strokeWidth={2} className="text-content-faint" />}>{t('tours.planner.restored')}</TourNotice>
)}
<div className="flex flex-col gap-3">
<div className="min-w-0">
<label className={LABEL} htmlFor="tour-planner-name">{t('tours.planner.name')}</label>
<input
id="tour-planner-name"
disabled={!canEdit || isSaving}
value={planner.name}
onChange={event => { if (canEdit && !isSaving) planner.setName(event.target.value) }}
maxLength={255}
placeholder={t('tours.planner.namePlaceholder')}
className={INPUT}
/>
</div>
<div className="min-w-0">
<label className={LABEL} htmlFor="tour-planner-description">{t('places.formDescription')}</label>
<textarea
id="tour-planner-description"
disabled={!canEdit || isSaving}
value={planner.description}
onChange={event => { if (canEdit && !isSaving) planner.setDescription(event.target.value) }}
maxLength={2000}
rows={3}
className={`${INPUT} min-h-[72px] resize-y`}
/>
</div>
<TourSection label={t('tours.planner.plannedTotalDuration')}>
<div data-testid="tour-planner-time-grid" className="grid grid-cols-[repeat(auto-fit,minmax(min(100%,12rem),1fr))] gap-3">
<div className="min-w-0">
<div className="mb-[5px] flex min-w-0 items-center gap-1">
<label className={`${LABEL} mb-0`} htmlFor="tour-planner-breaks">{t('tours.planner.breaksAdditional')}</label>
<DurationInfo
label={t('tours.planner.breaksAdditionalInfoLabel')}
description={t('tours.planner.breaksAdditionalInfo')}
/>
</div>
<input
id="tour-planner-breaks"
aria-label={t('tours.planner.breaksAdditional')}
type="number"
inputMode="numeric"
min={0}
max={1440}
step={1}
disabled={!canEdit || isSaving}
value={planner.breakAdditionalMinutes ?? ''}
onChange={event => { 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}
/>
<span id="tour-planner-breaks-hint" className="mt-1 block text-content-faint" style={fs(10.5)}>
{t('tours.planner.breaksAdditionalHint')}
</span>
{planner.breakAdditionalInvalid && (
<span id="tour-planner-breaks-error" role="alert" className="mt-1 block text-danger" style={fs(11)}>
{t('tours.planner.timeValueInvalid')}
</span>
)}
</div>
<div className="min-w-0">
<div className="mb-[5px] flex min-w-0 items-center gap-1">
<label className={`${LABEL} mb-0`} htmlFor="tour-planner-planned-duration">
{t('tours.planner.plannedTotalDuration')}
{planner.plannedDurationMinutes != null && <span className="ms-1 normal-case text-info">· {t('tours.planner.plannedTotalManual')}</span>}
</label>
<DurationInfo
label={t('tours.planner.plannedTotalInfoLabel')}
description={t('tours.planner.plannedTotalInfo')}
/>
</div>
<div className="flex min-w-0 items-center gap-2">
<input
id="tour-planner-planned-duration"
aria-label={t('tours.planner.plannedTotalDuration')}
type="number"
inputMode="numeric"
min={0}
max={1440}
step={1}
disabled={!canEdit || isSaving}
value={planner.plannedDurationMinutes ?? planner.plannedTotalMinutes ?? ''}
onChange={event => { 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 && <span className="text-content-faint" style={fs(10.5)}>{t('tours.planner.plannedTotalAutomatic')}</span>}
</div>
{planner.plannedDurationInvalid && (
<span id="tour-planner-planned-duration-error" role="alert" className="mt-1 block text-danger" style={fs(11)}>
{t('tours.planner.timeValueInvalid')}
</span>
)}
</div>
</div>
</TourSection>
<div className="min-w-0">
<label className={LABEL} htmlFor="tour-planner-website">{t('places.formWebsite')}</label>
<input
id="tour-planner-website"
type="url"
inputMode="url"
disabled={!canEdit || isSaving}
value={planner.website}
onChange={event => { 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 && (
<span id="tour-planner-website-error" role="alert" className="mt-1 block text-danger" style={fs(11)}>
{t('dawarich.error.invalid_url')}
</span>
)}
</div>
<div className="min-w-0">
<label className={LABEL} htmlFor="tour-planner-difficulty">{t('tours.planner.maxDifficulty')}</label>
<CustomSelect
id="tour-planner-difficulty"
disabled={!canEdit || isSaving}
ariaLabel={t('tours.planner.maxDifficulty')}
value={planner.maxHikingDifficulty}
onChange={nextValue => {
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}`) }))}
/>
</div>
{planner.maxHikingDifficulty === 3 && (
<TourNotice tone="warning" role="status" icon={<AlertTriangle size={13} strokeWidth={2} />}>{t('tours.planner.difficulty.t3Warning')}</TourNotice>
)}
</div>
<TourDetailsFields planner={planner} locked={!canEdit || isSaving} onDifficultyChange={changeDifficulty} />
<TourSection label={t('tours.planner.waypoints')}>
{planner.waypoints.length === 0 ? (
<div data-testid="tour-planner-empty-state" className="flex items-start gap-2 rounded-[12px] border border-dashed border-edge px-3 py-3 leading-snug text-content-muted" style={fs(12, 'body')}>
<MapPin size={14} strokeWidth={2} className="mt-px flex-none text-content-faint" />
<p className="m-0">{t('tours.planner.startingPoint')} {t('tours.planner.firstUseDetails')}</p>
</div>
) : (
<ol data-touch-drag className="m-0 flex list-none flex-col gap-px p-0">
{planner.waypoints.map((point, index) => {
const selected = planner.selectedWaypointId === point.id;
const isDropTarget = dropTarget?.id === point.id && draggedWaypointId !== point.id;
return (
<li
key={point.id}
data-testid="tour-planner-waypoint-row"
onDragStart={(event) => {
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 && (
<span
aria-hidden="true"
className={`pointer-events-none absolute inset-x-2 z-10 h-0.5 rounded bg-accent ${dropTarget.placement === 'before' ? 'top-0' : 'bottom-0'}`}
/>
)}
<div className="group flex w-full items-center gap-2.5 rounded-[12px] px-2 py-1.5">
<span
draggable={canEdit && !isSaving}
aria-hidden="true"
data-testid="tour-planner-waypoint-drag-handle"
className={`grid h-6 w-5 flex-none place-items-center text-content-faint ${canEdit && !isSaving ? 'cursor-grab active:cursor-grabbing' : 'opacity-35'}`}
>
<GripVertical size={14} strokeWidth={1.8} />
</span>
<span
aria-label={t('tours.planner.waypointLabel', { n: index + 1 })}
data-waypoint-number={index + 1}
className={`grid h-7 w-7 flex-none place-items-center rounded-[9px] font-geist font-semibold tabular-nums shadow-sm ${selected ? 'bg-accent text-accent-text' : 'bg-surface-card text-content'}`}
style={fs(11.5)}
>
{index + 1}
</span>
<button
type="button"
onClick={() => 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}
>
<span className="block font-semibold leading-tight text-content" style={fs(12.5, 'body')}>
{roleLabel(point.role, t)}
</span>
<span
className="mt-0.5 block truncate font-geist tabular-nums text-content-faint"
style={fs(10.5)}
>
{point.lat.toFixed(5)}, {point.lng.toFixed(5)}
</span>
</button>
{canEdit && (
<span
className={`flex flex-none items-center gap-0.5 transition-opacity ${selected ? '' : 'opacity-50 group-focus-within:opacity-100 group-hover:opacity-100'}`}
>
<WaypointAction
label={t('tours.planner.moveUp')}
disabled={isSaving || index === 0}
onAct={() => planner.moveWaypoint(point.id, -1)}
>
<ArrowUp size={13} strokeWidth={2.2} />
</WaypointAction>
<WaypointAction
label={t('tours.planner.moveDown')}
disabled={isSaving || index === lastIndex}
onAct={() => planner.moveWaypoint(point.id, 1)}
>
<ArrowDown size={13} strokeWidth={2.2} />
</WaypointAction>
<WaypointAction
label={t('tours.planner.remove')}
disabled={isSaving}
danger
onAct={() => planner.removeWaypoint(point.id)}
>
<Trash2 size={13} strokeWidth={2} />
</WaypointAction>
</span>
)}
</div>
</li>
);
})}
</ol>
)}
</TourSection>
<WaypointList planner={planner} canEdit={canEdit} isSaving={isSaving} />
{(statusKey || planner.error) && (
<TourNotice
@@ -527,6 +258,307 @@ export function TourPlannerRail({ planner, canEdit: editPermission, canAssign: a
)
}
interface TourFieldProps {
planner: TourPlannerController
/** True while the viewer may not edit or a save is in flight. */
locked: boolean
}
/** Breaks on top of the moving time, and the planned total that defaults to their sum. */
function TourTimeGrid({ planner, locked }: TourFieldProps) {
const { t } = useTranslation()
return (
<div data-testid="tour-planner-time-grid" className="grid grid-cols-[repeat(auto-fit,minmax(min(100%,12rem),1fr))] gap-3">
<div className="min-w-0">
<div className="mb-[5px] flex min-w-0 items-center gap-1">
<label className={`${LABEL} mb-0`} htmlFor="tour-planner-breaks">{t('tours.planner.breaksAdditional')}</label>
<DurationInfo
label={t('tours.planner.breaksAdditionalInfoLabel')}
description={t('tours.planner.breaksAdditionalInfo')}
/>
</div>
<input
id="tour-planner-breaks"
aria-label={t('tours.planner.breaksAdditional')}
type="number"
inputMode="numeric"
min={0}
max={1440}
step={1}
disabled={locked}
value={planner.breakAdditionalMinutes ?? ''}
onChange={event => { if (!locked) 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}
/>
<span id="tour-planner-breaks-hint" className="mt-1 block text-content-faint" style={fs(10.5)}>
{t('tours.planner.breaksAdditionalHint')}
</span>
{planner.breakAdditionalInvalid && (
<span id="tour-planner-breaks-error" role="alert" className="mt-1 block text-danger" style={fs(11)}>
{t('tours.planner.timeValueInvalid')}
</span>
)}
</div>
<div className="min-w-0">
<div className="mb-[5px] flex min-w-0 items-center gap-1">
<label className={`${LABEL} mb-0`} htmlFor="tour-planner-planned-duration">
{t('tours.planner.plannedTotalDuration')}
{planner.plannedDurationMinutes != null && <span className="ms-1 normal-case text-info">· {t('tours.planner.plannedTotalManual')}</span>}
</label>
<DurationInfo
label={t('tours.planner.plannedTotalInfoLabel')}
description={t('tours.planner.plannedTotalInfo')}
/>
</div>
<div className="flex min-w-0 items-center gap-2">
<input
id="tour-planner-planned-duration"
aria-label={t('tours.planner.plannedTotalDuration')}
type="number"
inputMode="numeric"
min={0}
max={1440}
step={1}
disabled={locked}
value={planner.plannedDurationMinutes ?? planner.plannedTotalMinutes ?? ''}
onChange={event => { if (!locked) 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 && <span className="text-content-faint" style={fs(10.5)}>{t('tours.planner.plannedTotalAutomatic')}</span>}
</div>
{planner.plannedDurationInvalid && (
<span id="tour-planner-planned-duration-error" role="alert" className="mt-1 block text-danger" style={fs(11)}>
{t('tours.planner.timeValueInvalid')}
</span>
)}
</div>
</div>
)
}
/** Name, description, timings, website and the difficulty cap of the tour being planned. */
function TourDetailsFields({ planner, locked, onDifficultyChange }: TourFieldProps & { onDifficultyChange: (value: 1 | 2 | 3 | 4 | 5 | 6) => void }) {
const { t } = useTranslation()
return (
<div className="flex flex-col gap-3">
<div className="min-w-0">
<label className={LABEL} htmlFor="tour-planner-name">{t('tours.planner.name')}</label>
<input
id="tour-planner-name"
disabled={locked}
value={planner.name}
onChange={event => { if (!locked) planner.setName(event.target.value) }}
maxLength={255}
placeholder={t('tours.planner.namePlaceholder')}
className={INPUT}
/>
</div>
<div className="min-w-0">
<label className={LABEL} htmlFor="tour-planner-description">{t('places.formDescription')}</label>
<textarea
id="tour-planner-description"
disabled={locked}
value={planner.description}
onChange={event => { if (!locked) planner.setDescription(event.target.value) }}
maxLength={2000}
rows={3}
className={`${INPUT} min-h-[72px] resize-y`}
/>
</div>
<TourSection label={t('tours.planner.plannedTotalDuration')}>
<TourTimeGrid planner={planner} locked={locked} />
</TourSection>
<div className="min-w-0">
<label className={LABEL} htmlFor="tour-planner-website">{t('places.formWebsite')}</label>
<input
id="tour-planner-website"
type="url"
inputMode="url"
disabled={locked}
value={planner.website}
onChange={event => { if (!locked) 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 && (
<span id="tour-planner-website-error" role="alert" className="mt-1 block text-danger" style={fs(11)}>
{t('dawarich.error.invalid_url')}
</span>
)}
</div>
<div className="min-w-0">
<label className={LABEL} htmlFor="tour-planner-difficulty">{t('tours.planner.maxDifficulty')}</label>
<CustomSelect
id="tour-planner-difficulty"
disabled={locked}
ariaLabel={t('tours.planner.maxDifficulty')}
value={planner.maxHikingDifficulty}
onChange={nextValue => {
if (!locked) onDifficultyChange(Number(nextValue) as 1 | 2 | 3 | 4 | 5 | 6)
}}
options={[1, 2, 3, 4, 5, 6].map(value => ({ value, label: t(`tours.planner.difficulty.t${value}`) }))}
/>
</div>
{planner.maxHikingDifficulty === 3 && (
<TourNotice tone="warning" role="status" icon={<AlertTriangle size={13} strokeWidth={2} />}>{t('tours.planner.difficulty.t3Warning')}</TourNotice>
)}
</div>
)
}
/** The ordered waypoints, reorderable by drag or by the arrow controls on each row. */
function WaypointList({ planner, canEdit, isSaving }: { planner: TourPlannerController; canEdit: boolean; isSaving: boolean }) {
const { t } = useTranslation()
const [draggedWaypointId, setDraggedWaypointId] = useState<string | null>(null);
const [dropTarget, setDropTarget] = useState<{ id: string; placement: 'before' | 'after' } | null>(null);
const lastIndex = planner.waypoints.length - 1
return (
<TourSection label={t('tours.planner.waypoints')}>
{planner.waypoints.length === 0 ? (
<div data-testid="tour-planner-empty-state" className="flex items-start gap-2 rounded-[12px] border border-dashed border-edge px-3 py-3 leading-snug text-content-muted" style={fs(12, 'body')}>
<MapPin size={14} strokeWidth={2} className="mt-px flex-none text-content-faint" />
<p className="m-0">{t('tours.planner.startingPoint')} {t('tours.planner.firstUseDetails')}</p>
</div>
) : (
<ol data-touch-drag className="m-0 flex list-none flex-col gap-px p-0">
{planner.waypoints.map((point, index) => {
const selected = planner.selectedWaypointId === point.id;
const isDropTarget = dropTarget?.id === point.id && draggedWaypointId !== point.id;
return (
<li
key={point.id}
data-testid="tour-planner-waypoint-row"
onDragStart={(event) => {
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 && (
<span
aria-hidden="true"
className={`pointer-events-none absolute inset-x-2 z-10 h-0.5 rounded bg-accent ${dropTarget.placement === 'before' ? 'top-0' : 'bottom-0'}`}
/>
)}
<div className="group flex w-full items-center gap-2.5 rounded-[12px] px-2 py-1.5">
<span
draggable={canEdit && !isSaving}
aria-hidden="true"
data-testid="tour-planner-waypoint-drag-handle"
className={`grid h-6 w-5 flex-none place-items-center text-content-faint ${canEdit && !isSaving ? 'cursor-grab active:cursor-grabbing' : 'opacity-35'}`}
>
<GripVertical size={14} strokeWidth={1.8} />
</span>
<span
aria-label={t('tours.planner.waypointLabel', { n: index + 1 })}
data-waypoint-number={index + 1}
className={`grid h-7 w-7 flex-none place-items-center rounded-[9px] font-geist font-semibold tabular-nums shadow-sm ${selected ? 'bg-accent text-accent-text' : 'bg-surface-card text-content'}`}
style={fs(11.5)}
>
{index + 1}
</span>
<button
type="button"
onClick={() => 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}
>
<span className="block font-semibold leading-tight text-content" style={fs(12.5, 'body')}>
{roleLabel(point.role, t)}
</span>
<span
className="mt-0.5 block truncate font-geist tabular-nums text-content-faint"
style={fs(10.5)}
>
{point.lat.toFixed(5)}, {point.lng.toFixed(5)}
</span>
</button>
{canEdit && (
<span
className={`flex flex-none items-center gap-0.5 transition-opacity ${selected ? '' : 'opacity-50 group-focus-within:opacity-100 group-hover:opacity-100'}`}
>
<WaypointAction
label={t('tours.planner.moveUp')}
disabled={isSaving || index === 0}
onAct={() => planner.moveWaypoint(point.id, -1)}
>
<ArrowUp size={13} strokeWidth={2.2} />
</WaypointAction>
<WaypointAction
label={t('tours.planner.moveDown')}
disabled={isSaving || index === lastIndex}
onAct={() => planner.moveWaypoint(point.id, 1)}
>
<ArrowDown size={13} strokeWidth={2.2} />
</WaypointAction>
<WaypointAction
label={t('tours.planner.remove')}
disabled={isSaving}
danger
onAct={() => planner.removeWaypoint(point.id)}
>
<Trash2 size={13} strokeWidth={2} />
</WaypointAction>
</span>
)}
</div>
</li>
);
})}
</ol>
)}
</TourSection>
)
}
/** 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 (
@@ -333,13 +333,13 @@ describe('useTourPlanner', () => {
expect(result.current.plannedTotalMinutes).toBe(90)
const waypointId = result.current.waypoints[1].id
act(() => result.current.setWaypointPosition(waypointId, 48.2, 11.2))
act(() => { result.current.setWaypointPosition(waypointId, 48.2, 11.2) })
await act(async () => { await vi.advanceTimersByTimeAsync(450) })
expect(result.current.walkingDurationMinutes).toBe(90)
expect(result.current.plannedTotalMinutes).toBe(120)
act(() => result.current.setPlannedDurationMinutes(110))
act(() => result.current.setWaypointPosition(waypointId, 48.3, 11.3))
act(() => { result.current.setWaypointPosition(waypointId, 48.3, 11.3) })
await act(async () => { await vi.advanceTimersByTimeAsync(450) })
expect(result.current.walkingDurationMinutes).toBe(120)
expect(result.current.plannedTotalMinutes).toBe(110)
@@ -437,7 +437,9 @@ describe('useTourPlanner', () => {
act(() => first.result.current.reorderWaypoint(ids[2], ids[0], 'before'));
expect(first.result.current.waypoints.map((point) => point.id)).toEqual([ids[2], ids[0], ids[1]]);
expect(first.result.current.waypoints.map((point) => point.role)).toEqual(['start', 'via', 'end']);
const stored = JSON.parse(localStorage.getItem('tour-draft-78')!) as { waypoints: { id: string }[] };
const storedRaw = localStorage.getItem('tour-draft-78');
if (storedRaw === null) throw new Error('expected a stored tour draft');
const stored = JSON.parse(storedRaw) as { waypoints: { id: string }[] };
expect(stored.waypoints.map((point) => point.id)).toEqual([ids[2], ids[0], ids[1]]);
first.unmount();
@@ -64,22 +64,51 @@ function sameWaypointSnapshot(a: TourPlannerWaypoint[], b: TourPlannerWaypoint[]
return tourWaypointKey(a) === tourWaypointKey(b)
}
const DRAFT_VERSIONS: ReadonlyArray<DraftPayload['version']> = [1, 2, 3, 4, 5, 6]
function optionalString(value: unknown): boolean {
return value === undefined || typeof value === 'string'
}
function optionalMinutes(value: number | null | undefined): boolean {
return value === undefined || value === null
|| (Number.isInteger(value) && value >= 0 && value <= MAX_PLANNED_TOUR_DURATION_MINUTES)
}
function optionalPlaceId(value: number | null | undefined): boolean {
return value == null || (Number.isInteger(value) && value > 0)
}
function optionalDifficulty(value: number | null | undefined): boolean {
return value == null || (Number.isInteger(value) && value >= 1 && value <= 6)
}
function validDraftWaypoint(point: TourPlannerWaypoint): boolean {
return !!point && typeof point.id === 'string'
&& Number.isFinite(point.lat) && Math.abs(point.lat) <= 90
&& Number.isFinite(point.lng) && Math.abs(point.lng) <= 180
}
function trimmedOrNull(value: string): string | null {
return value.trim() || null
}
function parsedWebsiteOrNull(value: string) {
return value.trim() ? tourWebsiteSchema.parse(value) : null
}
function validDraft(value: unknown): value is DraftPayload {
if (!value || typeof value !== 'object') return false
const draft = value as Partial<DraftPayload>
return (draft.version === 1 || draft.version === 2 || draft.version === 3 || draft.version === 4 || draft.version === 5 || draft.version === 6) && typeof draft.name === 'string'
&& (draft.description === undefined || typeof draft.description === 'string')
&& (draft.website === undefined || typeof draft.website === 'string')
&& (draft.plannedDurationMinutes === undefined || draft.plannedDurationMinutes === null
|| (Number.isInteger(draft.plannedDurationMinutes) && draft.plannedDurationMinutes >= 0 && draft.plannedDurationMinutes <= MAX_PLANNED_TOUR_DURATION_MINUTES))
&& (draft.breakAdditionalMinutes === undefined || draft.breakAdditionalMinutes === null
|| (Number.isInteger(draft.breakAdditionalMinutes) && draft.breakAdditionalMinutes >= 0 && draft.breakAdditionalMinutes <= MAX_PLANNED_TOUR_DURATION_MINUTES))
&& (draft.editingPlaceId == null || (Number.isInteger(draft.editingPlaceId) && draft.editingPlaceId > 0))
&& (draft.maxHikingDifficulty == null || (Number.isInteger(draft.maxHikingDifficulty) && draft.maxHikingDifficulty >= 1 && draft.maxHikingDifficulty <= 6))
return draft.version !== undefined && DRAFT_VERSIONS.includes(draft.version) && typeof draft.name === 'string'
&& optionalString(draft.description)
&& optionalString(draft.website)
&& optionalMinutes(draft.plannedDurationMinutes)
&& optionalMinutes(draft.breakAdditionalMinutes)
&& optionalPlaceId(draft.editingPlaceId)
&& optionalDifficulty(draft.maxHikingDifficulty)
&& Array.isArray(draft.waypoints)
&& draft.waypoints.every(point => point && typeof point.id === 'string'
&& Number.isFinite(point.lat) && Math.abs(point.lat) <= 90
&& Number.isFinite(point.lng) && Math.abs(point.lng) <= 180)
&& draft.waypoints.every(validDraftWaypoint)
}
export interface UseTourPlannerOptions extends TourPermissionProps {
@@ -673,8 +702,8 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
const submittedPlaceId = editingPlaceId
const payload = {
name: name.trim(),
description: description.trim() || null,
website: website.trim() ? tourWebsiteSchema.parse(website) : null,
description: trimmedOrNull(description),
website: parsedWebsiteOrNull(website),
tour_type: 'hike',
route_geometry: enrichedGeometry.map(point => [point[0], point[1], point[2]]),
waypoints: waypoints.map((point, sequence) => ({
@@ -622,10 +622,14 @@ describe('TripPlannerPage — shell', () => {
expect(screen.getByTestId('map-view')).toBe(mapElement);
expect(plannerActions.addWaypoint).not.toHaveBeenCalled();
act(() => props('map').onMapClick({ latlng: { lat: 35.02, lng: 135.77 } }));
act(() => {
props('map').onMapClick({ latlng: { lat: 35.02, lng: 135.77 } });
});
expect(plannerActions.addWaypoint).toHaveBeenCalledOnce();
expect(plannerActions.addWaypoint).toHaveBeenCalledWith(35.02, 135.77);
act(() => props('map').onPlannerWaypointMove('via-1', 35.03, 135.78));
act(() => {
props('map').onPlannerWaypointMove('via-1', 35.03, 135.78);
});
expect(plannerActions.setWaypointPosition).toHaveBeenCalledOnce();
expect(plannerActions.setWaypointPosition).toHaveBeenCalledWith('via-1', 35.03, 135.78);
expect(createAssignment).not.toHaveBeenCalled();
@@ -0,0 +1,56 @@
// FE-TP-LOCSEARCH-001 to FE-TP-LOCSEARCH-004
//
// The tour planner's map location search: a picked result only moves the camera,
// and the planner's own focus takes over again once it bumps its focus key.
import { act, renderHook } from '@testing-library/react';
import { useMapLocationSearch } from './useMapLocationSearch';
const PLANNER_POINTS: [number, number][] = [
[48, 11],
[48.1, 11.1],
];
const PICK = { name: 'Zugspitze', lat: 47.42, lng: 10.98 };
describe('useMapLocationSearch', () => {
it('FE-TP-LOCSEARCH-001: without a pick the planner frames its own points', () => {
const { result } = renderHook(() => useMapLocationSearch(3, PLANNER_POINTS));
expect(result.current.mapFocusPoints).toBe(PLANNER_POINTS);
expect(result.current.mapFocusKey).toBe('tour:3');
expect(result.current.locationSearch.value).toBeNull();
});
it('FE-TP-LOCSEARCH-002: a pick frames the picked point under a fresh key each time', () => {
const { result } = renderHook(() => useMapLocationSearch(3, PLANNER_POINTS));
act(() => result.current.locationSearch.onChange(PICK));
expect(result.current.locationSearch.value).toEqual(PICK);
expect(result.current.mapFocusPoints).toEqual([[47.42, 10.98]]);
expect(result.current.mapFocusKey).toBe('tour:3:search:1');
act(() => result.current.locationSearch.onChange(PICK));
expect(result.current.mapFocusKey).toBe('tour:3:search:2');
});
it('FE-TP-LOCSEARCH-003: clearing the field keeps the camera where the pick put it', () => {
const { result } = renderHook(() => useMapLocationSearch(3, PLANNER_POINTS));
act(() => result.current.locationSearch.onChange(PICK));
act(() => result.current.locationSearch.onChange(null));
expect(result.current.locationSearch.value).toBeNull();
expect(result.current.mapFocusKey).toBe('tour:3:search:1');
});
it('FE-TP-LOCSEARCH-004: a new planner focus key wins over an older pick', () => {
const { result, rerender } = renderHook(({ key }) => useMapLocationSearch(key, PLANNER_POINTS), {
initialProps: { key: 3 },
});
act(() => result.current.locationSearch.onChange(PICK));
rerender({ key: 4 });
expect(result.current.mapFocusPoints).toBe(PLANNER_POINTS);
expect(result.current.mapFocusKey).toBe('tour:4');
});
});
@@ -0,0 +1,43 @@
import { useCallback, useRef, useState } from 'react';
import type { LocationPoint } from '../../components/Planner/LocationSelect';
interface SearchCameraIntent {
plannerFocusKey: number;
sequence: number;
point: [number, number];
}
/**
* The TOUR-PLANNER location search: a picked result only moves the camera.
* The search focus wins until the planner bumps its own focus key.
*/
export function useMapLocationSearch(plannerFocusKey: number, focusPoints: [number, number][]) {
const [locationSearchValue, setLocationSearchValue] = useState<LocationPoint | null>(null);
const [searchCameraIntent, setSearchCameraIntent] = useState<SearchCameraIntent | null>(null);
const searchCameraSequenceRef = useRef(0);
const onLocationSearchChange = useCallback(
(location: LocationPoint | null) => {
setLocationSearchValue(location);
if (!location) return;
searchCameraSequenceRef.current += 1;
setSearchCameraIntent({
plannerFocusKey,
sequence: searchCameraSequenceRef.current,
point: [location.lat, location.lng],
});
},
[plannerFocusKey]
);
const searchFocusIsCurrent = searchCameraIntent?.plannerFocusKey === plannerFocusKey;
const mapFocusPoints = searchFocusIsCurrent ? [searchCameraIntent.point] : focusPoints;
const mapFocusKey = searchFocusIsCurrent
? `tour:${plannerFocusKey}:search:${searchCameraIntent.sequence}`
: `tour:${plannerFocusKey}`;
return {
mapFocusPoints,
mapFocusKey,
locationSearch: { value: locationSearchValue, onChange: onLocationSearchChange },
};
}
@@ -1,10 +1,10 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { TourListItem } from '@trek/shared'
import type { LocationPoint } from '../../components/Planner/LocationSelect';
import { usePoiExplore, type Bbox } from '../../components/Map/usePoiExplore'
import { placeActions } from '../../components/Planner/usePlaceActions'
import { useTourPlanner } from '../../components/Tours/planner/useTourPlanner'
import { usePlanMapViewport } from '../../hooks/usePlanMapViewport'
import { useMapLocationSearch } from './useMapLocationSearch'
import { useTripPlanner } from './useTripPlanner'
export function useTripPlannerPage() {
@@ -40,27 +40,6 @@ export function useTripPlannerPage() {
},
})
const [locationSearchValue, setLocationSearchValue] = useState<LocationPoint | null>(null);
const [searchCameraIntent, setSearchCameraIntent] = useState<{
plannerFocusKey: number;
sequence: number;
point: [number, number];
} | null>(null);
const searchCameraSequenceRef = useRef(0);
const onLocationSearchChange = useCallback(
(location: LocationPoint | null) => {
setLocationSearchValue(location);
if (!location) return;
searchCameraSequenceRef.current += 1;
setSearchCameraIntent({
plannerFocusKey: tourPlanner.mapFocusKey,
sequence: searchCameraSequenceRef.current,
point: [location.lat, location.lng],
});
},
[tourPlanner.mapFocusKey]
);
const tourDetailOpenerRef = useRef<{ placeId: number; element: HTMLElement } | null>(null)
const lastPlanMapViewportRef = useRef<Bbox | null>(null)
const [plannerViewportSnapshot, setPlannerViewportSnapshot] = useState<Bbox | null>(lastPlanMapViewportRef.current)
@@ -93,11 +72,7 @@ export function useTripPlannerPage() {
return plannerFallbackFocusPoints
}, [tourPlanner.mode.type, tourPlanner.readOnlyGpxAnalysis?.routeCoordinates, tourPlanner.waypoints, plannerFallbackFocusPoints])
const searchFocusIsCurrent = searchCameraIntent?.plannerFocusKey === tourPlanner.mapFocusKey;
const mapFocusPoints = searchFocusIsCurrent ? [searchCameraIntent.point] : focusPoints;
const mapFocusKey = searchFocusIsCurrent
? `tour:${tourPlanner.mapFocusKey}:search:${searchCameraIntent.sequence}`
: `tour:${tourPlanner.mapFocusKey}`;
const { mapFocusPoints, mapFocusKey, locationSearch } = useMapLocationSearch(tourPlanner.mapFocusKey, focusPoints)
const route = useMemo<[number, number][][] | null>(() => {
if (tourPlanner.mode.type === 'view-gpx') {
@@ -140,6 +115,6 @@ export function useTripPlannerPage() {
tourPlanner,
tourDetails: { openerRef: tourDetailOpenerRef, onSelectTour },
tourMap: { route, focusPoints: mapFocusPoints, focusKey: mapFocusKey, captureViewport },
locationSearch: { value: locationSearchValue, onChange: onLocationSearchChange },
locationSearch,
}
}
-2
View File
@@ -1,6 +1,5 @@
[
"src/db/repositories/DayAssignments.repository.ts",
"src/db/repositories/Places.repository.ts",
"src/db/repositories/Reservations.repository.ts",
"src/db/repositories/Trips.repository.ts",
"src/db/repositories/Users.repository.ts",
@@ -22,7 +21,6 @@
"src/nest/reservations/reservations.service.ts",
"src/nest/trips/trips.service.ts",
"src/nest/vacay/vacay.service.ts",
"tests/unit/db/repositories/Places.repository.test.ts",
"tests/unit/nest/atlas.service.test.ts",
"tests/unit/nest/budget.service.db.test.ts",
"tests/unit/nest/calendar.service.test.ts",