import { useRoadtripSettings } from '../../hooks/useRoadtripSettings' import { isServiceStopType } from '../Roadtrip/roadtripModel' /* eslint-disable @typescript-eslint/no-unnecessary-type-assertion */ interface DragDataPayload { placeId?: string; assignmentId?: string; noteId?: string; reservationId?: string; fromDayId?: string; phase?: 'single' | 'start' | 'middle' | 'end'; /** A corridor hit on its way onto the drive (#1797) — not a place yet. */ poiOsmId?: string } declare global { interface Window { __dragData: DragDataPayload | null } } import React, { useState, useEffect, useLayoutEffect, useRef, useMemo, useCallback } from 'react' import { avatarSrc } from '../../utils/avatarSrc' import { safeHttpUrl } from '../../utils/safeUrl' import { ChevronDown, ChevronRight, ChevronUp, Compass, Navigation, RotateCcw, ExternalLink, Clock, Pencil, GripVertical, Ticket, Plus, FileText, Trash2, Car, Lock, Hotel, Footprints, Route as RouteIcon, Bookmark, StickyNote, TramFront, Zap } from 'lucide-react' import { type PickedPlace } from './TransitSearchPanel' import { assignmentsApi, reservationsApi, daysApi } from '../../api/client' import { calculateRouteWithLegs, optimizeRoute, generateGoogleMapsUrl, generateCoMapsUrl, type NamedWaypoint } from '../Map/RouteCalculator' import GoogleMapsIcon from '../shared/GoogleMapsIcon' import PlaceAvatar from '../shared/PlaceAvatar' import ConfirmDialog from '../shared/ConfirmDialog' import { useContextMenu, ContextMenu } from '../shared/ContextMenu' import Markdown from 'react-markdown' import remarkGfm from 'remark-gfm' import remarkBreaks from 'remark-breaks' import WeatherWidget from '../Weather/WeatherWidget' import { useToast } from '../shared/Toast' import { getCategoryIcon } from '../shared/categoryIcons' import { useTripStore } from '../../store/tripStore' import { useCanDo } from '../../store/permissionsStore' import { useSettingsStore } from '../../store/settingsStore' import { useAddonStore } from '../../store/addonStore' import { usePluginStore } from '../../store/pluginStore' import { useSaveToCollectionStore } from '../../store/saveToCollectionStore' import { placeToSaveTarget } from '../Collections/saveTarget' import { useTranslation } from '../../i18n' import { Tooltip } from '../shared/Tooltip' import { isDayInAccommodationRange, getAccommodationAnchors, getDayBookendHotels, shouldDrawMorningLeg, shouldDrawEveningLeg, type CarrierEdge } from '../../utils/dayOrder' import { TRANSPORT_TYPES, parseTimeToMinutes, getSpanPhase, hidesOnMiddleDay, getDisplayTimeForDay, getTransportRouteEndpoints, getTransportForDay as _getTransportForDay, getMergedItems as _getMergedItems, isCarrierTransport, hasCarrierEndpointOnDay, getAssignmentReservations, type MergedItem, } from '../../utils/dayMerge' import { withinDriveRange } from '../../utils/geo' import { formatDate, formatTime, dayTotalCost, formatMoneySum, splitReservationDateTime } from '../../utils/formatters' import { useDayNotes } from '../../hooks/useDayNotes' import { useExchangeRates } from '../../hooks/useExchangeRates' import { RES_ICONS, getNoteIcon } from './DayPlanSidebar.constants' import { noteSurface } from './noteSurface' import { findTodayDayId } from './today' import { markdownLinkComponents } from '../shared/markdownLink' import { RouteConnector, HotelRouteConnector } from './DayPlanSidebarRouteConnector' import { resolveLegMode } from './legMode' import { usePluginDaySchedule, usePluginDayTints, dayTintBackground, dayTinted, PluginDayScheduleRow, formatScheduleMinutes } from '../Plugins/PluginDaySchedule' import { MobileAddPlaceButton } from './DayPlanSidebarMobileAddPlaceButton' import { DayPlanSidebarToolbar } from './DayPlanSidebarToolbar' import { DayPlanSidebarNoteModal } from './DayPlanSidebarNoteModal' import { DayPlanSidebarTimeConfirmModal } from './DayPlanSidebarTimeConfirmModal' import { DayPlanSidebarTransportDetailModal } from './DayPlanSidebarTransportDetailModal' import { TransitTitle, TransitLegChips, TransitItineraryInline } from './transitDisplay' import { DayPlanSidebarFooter } from './DayPlanSidebarFooter' import type { Trip, Day, Place, Category, Assignment, Accommodation, Reservation, AssignmentsMap, RouteResult, RouteSegment, DayNote } from '../../types' import { getNavigationTargets, openNavigationTarget } from './placeNavigation' interface DayPlanSidebarProps { tripId: number trip: Trip days: Day[] places: Place[] categories: Category[] assignments: AssignmentsMap selectedDayId: number | null selectedPlaceId: number | null selectedAssignmentId: number | null onSelectDay: (dayId: number | null, skipFit?: boolean) => void onPlaceClick: (placeId: number | null, assignmentId?: number | null) => void onDayDetail: (day: Day | null) => void accommodations?: Accommodation[] onReorder: (dayId: number, orderedIds: number[]) => void onReorderDays?: (orderedIds: number[]) => void onAddDay?: (position?: number) => void /** Renaming lives in the day-detail panel (#1065); the sidebar only forwards the prop. */ onUpdateDayTitle: (dayId: number, title: string) => void /** The day route is computed by the planner page itself; kept for the existing call sites. */ onRouteCalculated: (route: RouteResult | null) => void onAssignToDay: (placeId: number, dayId: number, position?: number) => void onRemoveAssignment: (dayId: number, assignmentId: number) => void onEditPlace: (place: Place, assignmentId?: number) => void onDeletePlace: (placeId: number) => void reservations?: Reservation[] visibleConnectionIds?: number[] onToggleConnection?: (reservationId: number) => void allConnectionsShown?: boolean onToggleAllConnections?: () => void externalTransportDetail?: Reservation | null onExternalTransportDetailHandled?: () => void onAddReservation: (dayId: number) => void onNavigateToFiles?: () => void routeShown?: boolean routeProfile?: string onToggleRoute?: () => void onSetRouteProfile?: (profile: string) => void onAddPlace?: () => void onAddPlaceToDay?: (placeId: number, dayId: number) => void /** Open the place form already pointed at this day, to create a new place there. */ onCreatePlaceForDay?: (dayId: number) => void onExpandedDaysChange?: (expandedDayIds: Set) => void pushUndo?: (label: string, undoFn: () => Promise | void) => void canUndo?: boolean lastActionLabel?: string | null onUndo?: () => void onRouteRefresh?: () => void onAddTransport?: (dayId: number) => void /** Opens the public-transit route search for a day (#1065). */ onPlanTransit?: (dayId: number) => void /** * Opens the public-transit search pre-filled for a single leg (#1281 follow-up): * the leg's origin and destination endpoints plus the origin's departure time, * so "public transport" becomes an option on any connector, not just the day header. */ onPlanTransitLeg?: (leg: { dayId: number; from: PickedPlace; to: PickedPlace; time: string | null }) => void /** Opens the journey view for a saved transit entry (#1065). */ onOpenTransit?: (reservation: Reservation) => void onEditTransport?: (reservation: Reservation) => void onEditReservation?: (reservation: Reservation) => void onAddBookingToAssignment?: (dayId: number, assignmentId: number) => void initialScrollTop?: number onScrollTopChange?: (top: number) => void /** Mobile: show the route tools footer (Route toggle / Optimize / travel profile) on expanded days, since selecting a day closes the sheet */ showRouteToolsWhenExpanded?: boolean isMobile?: boolean } /** * Day-plan state + behaviour: expand/collapse, inline title edit, route legs + * optimisation, day notes, and the drag-and-drop reorder/move machinery across * days (places, transports, notes). Returns everything the timeline view renders * from, keeping DayPlanSidebar a thin shell over one large day list. */ function useDayPlanSidebar(props: DayPlanSidebarProps) { const { tripId, trip, days, places, categories, assignments, selectedDayId, selectedPlaceId, selectedAssignmentId, onSelectDay, onPlaceClick, onDayDetail, accommodations = [], onReorder, onReorderDays, onAddDay, onAssignToDay, onRemoveAssignment, onEditPlace, onDeletePlace, reservations = [], visibleConnectionIds = [], onToggleConnection, allConnectionsShown = false, onToggleAllConnections, externalTransportDetail, onExternalTransportDetailHandled, onAddReservation, onAddPlace, onAddPlaceToDay, onCreatePlaceForDay, onNavigateToFiles, routeShown = false, routeProfile = 'driving', onToggleRoute, onSetRouteProfile, onExpandedDaysChange, pushUndo, canUndo = false, lastActionLabel = null, onUndo, onRouteRefresh, onAddTransport, onPlanTransit, onPlanTransitLeg, onOpenTransit, onEditTransport, onEditReservation, onAddBookingToAssignment, initialScrollTop, onScrollTopChange, showRouteToolsWhenExpanded = false, isMobile = false, } = props // Below lg the plan and the places sit in separate tabs, so there is nothing // to drag between. A coarse pointer is no longer a reason of its own: tablets // reach the same drag through a long press (#1616). const dragDisabled = isMobile const toast = useToast() const { t, language, locale } = useTranslation() const ctxMenu = useContextMenu() const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h' const mirrorServiceStops = useRoadtripSettings(s => s.roadtrip_service_stops_in_days !== false) const tripActions = useRef(useTripStore.getState()).current const can = useCanDo() const canEditDays = can('day_edit', trip) // The calendar subscription hands out a link that reads the trip without an // account, so it sits behind the same permission as the public share link. const canManageShare = can('share_manage', trip) const { noteUi, setNoteUi, noteInputRef, dayNotes, openAddNote: _openAddNote, openEditNote: _openEditNote, cancelNote, saveNote, deleteNote: _deleteNote, moveNote: _moveNote } = useDayNotes(tripId) const [expandedDays, setExpandedDays] = useState(() => { try { const saved = localStorage.getItem(`day-expanded-${tripId}`) if (saved) return new Set(JSON.parse(saved) as number[]) } catch {} return new Set(days.map(d => d.id)) }) useEffect(() => { onExpandedDaysChange?.(expandedDays) }, [expandedDays]) // Per-segment legs keyed by day id, then by the start place's assignment id (or the // transport's reservation id). Nested per day so several Route-toggled mobile days // can't collide in one flat map — assignment ids and reservation ids come from // independent sequences and would overwrite each other across days (#1374). const [routeLegs, setRouteLegs] = useState>>({}) // Hotel bookend legs keyed by day id. Desktop keys only the selected day; mobile // keys every day whose Route toggle is on, so each shows its own bookends (#1374). const [hotelLegs, setHotelLegs] = useState>({}) // Mobile only: days the user tapped "Route" on. Their leg distances show inline in // the expanded day, so seeing distances doesn't require selecting the day (which // closes the mobile sheet) — #1374. const [expandedRouteDayIds, setExpandedRouteDayIds] = useState>(new Set()) const optimizeFromAccommodation = useSettingsStore(s => s.settings.optimize_from_accommodation) // Recompute the hotel/route legs when the user flips km↔mi so the connector // distances refresh instead of showing stale cached text (#1300). const distanceUnit = useSettingsStore(s => s.settings.distance_unit) const legsAbortRef = useRef(null) const [draggingId, setDraggingId] = useState(null) const [lockedIds, setLockedIds] = useState(new Set()) const [lockHoverId, setLockHoverId] = useState(null) const [undoHover, setUndoHover] = useState(false) const [hoveredAssignmentId, setHoveredAssignmentId] = useState(null) // Transit rows fold their itinerary out inline (#1065). const [expandedTransitIds, setExpandedTransitIds] = useState>(new Set()) const [dropTargetKey, _setDropTargetKey] = useState(null) const dropTargetRef = useRef(null) const setDropTargetKey = (key) => { dropTargetRef.current = key; _setDropTargetKey(key) } const [dragOverDayId, setDragOverDayId] = useState(null) const [transportDetail, setTransportDetail] = useState(null) const [transportPosVersion, setTransportPosVersion] = useState(0) useEffect(() => { if (externalTransportDetail) { setTransportDetail(externalTransportDetail) onExternalTransportDetailHandled?.() } }, [externalTransportDetail, onExternalTransportDetailHandled]) const [timeConfirm, setTimeConfirm] = useState<{ dayId: number; fromId: number; time: string; // Target slot of the pending move — drag & drop and the arrow buttons both // describe it as "put fromId before/after this merged row". fromType?: string; toType?: string; toId?: number; insertAfter?: boolean; toLegIndex?: number | null; } | null>(null) const dragDataRef = useRef(null) const scrollContainerRef = useRef(null) const dayRefs = useRef>(new Map()) /** The day that is today, or null when the trip is not running right now. */ const todayDayId = useMemo(() => findTodayDayId(days), [days]) const scrollToDay = useCallback((dayId: number) => { const el = dayRefs.current.get(dayId) const container = scrollContainerRef.current if (!el || !container) return // Positioned against the container, not scrollIntoView: the sidebar sits in // a page that also scrolls, and scrollIntoView would drag the whole layout // around to satisfy a scroll inside one column. container.scrollTo({ top: el.offsetTop - container.offsetTop - 8, behavior: 'smooth' }) }, []) const jumpToToday = useCallback(() => { if (todayDayId == null) return onSelectDay(todayDayId, true) setExpandedDays(prev => new Set(prev).add(todayDayId)) // After the expand has rendered, or the target is measured at its old height. requestAnimationFrame(() => scrollToDay(todayDayId)) }, [todayDayId, onSelectDay, scrollToDay]) // On opening a trip that is running, land on today rather than on day 1 (#1567). // Once per mount, and only while nothing is selected yet: a deep link into a // specific day, or a day the user picked before switching tabs, must win. const autoJumpedRef = useRef(false) useEffect(() => { if (autoJumpedRef.current || todayDayId == null || selectedDayId != null || days.length === 0) return autoJumpedRef.current = true jumpToToday() // eslint-disable-next-line react-hooks/exhaustive-deps }, [todayDayId, days.length]) useLayoutEffect(() => { if (scrollContainerRef.current && initialScrollTop) { scrollContainerRef.current.scrollTop = initialScrollTop } }, []) const initedTransportIds = useRef(new Set()) // Speichert Drag-Daten als Backup (dataTransfer geht bei Re-Render verloren) // Remember which assignment we last auto-scrolled into view so we don't // keep yanking the user back whenever they scroll away while the same // place stays selected. const lastAutoScrolledIdRef = useRef(null) useEffect(() => { // Reset the scroll-lock whenever selection moves, so the next selected // row triggers a fresh scroll-into-view on its ref. if (!selectedAssignmentId && !selectedPlaceId) { lastAutoScrolledIdRef.current = null } }, [selectedAssignmentId, selectedPlaceId]) const currency = trip?.currency || 'EUR' // Cost totals render in the user's display currency (falling back to the // trip's), converting foreign-currency place prices via live FX rates — // same base resolution as BookingCostsSection / the Costs panel (#1561). const displayCurrency = useSettingsStore(s => s.settings.default_currency) const costBase = (displayCurrency || currency).toUpperCase() const { rates: fxRates } = useExchangeRates(costBase) // Drag-Daten aus dataTransfer, Ref oder window lesen (dataTransfer geht bei Re-Render verloren) const getDragData = (e) => { const dt = e?.dataTransfer // Interner Drag hat Vorrang (Ref wird nur bei assignmentId/noteId/reservationId gesetzt) if (dragDataRef.current) { return { placeId: '', assignmentId: dragDataRef.current.assignmentId || '', noteId: dragDataRef.current.noteId || '', reservationId: dragDataRef.current.reservationId || '', fromDayId: Number.parseInt(dragDataRef.current.fromDayId) || 0, phase: (dragDataRef.current.phase || 'single') as 'single' | 'start' | 'middle' | 'end', } } // Externer Drag (aus PlacesSidebar) const ext = window.__dragData || {} const placeId = dt?.getData('placeId') || ext.placeId || '' return { placeId, assignmentId: '', noteId: '', reservationId: '', fromDayId: 0, phase: 'single' as const } } // Only auto-expand genuinely new days (not on initial load from storage) const prevDayCount = React.useRef(days.length) useEffect(() => { if (days.length > prevDayCount.current) { // New days added — expand only those setExpandedDays(prev => { const n = new Set(prev) days.forEach(d => { if (!prev.has(d.id)) n.add(d.id) }) try { localStorage.setItem(`day-expanded-${tripId}`, JSON.stringify([...n])) } catch {} return n }) } prevDayCount.current = days.length }, [days.length, tripId]) // Globaler Aufräum-Listener: wenn ein Drag endet ohne Drop, alles zurücksetzen useEffect(() => { const cleanup = () => { setDraggingId(null) setDropTargetKey(null) setDragOverDayId(null) dragDataRef.current = null window.__dragData = null } document.addEventListener('dragend', cleanup) return () => document.removeEventListener('dragend', cleanup) }, []) // Initialize missing transport positions outside of render to avoid setState-during-render // eslint-disable-next-line react-hooks/exhaustive-deps useEffect(() => { days.forEach(day => initTransportPositions(day.id)) }, [days, reservations]) const toggleDay = (dayId, e) => { e.stopPropagation() setExpandedDays(prev => { const n = new Set(prev) n.has(dayId) ? n.delete(dayId) : n.add(dayId) try { localStorage.setItem(`day-expanded-${tripId}`, JSON.stringify([...n])) } catch {} return n }) } // Get phase label for multi-day badge const getSpanLabel = (r: Reservation, phase: string): string | null => { if (phase === 'single') return null if (r.type === 'flight') return t(`reservations.span.${phase === 'start' ? 'departure' : phase === 'end' ? 'arrival' : 'inTransit'}`) if (r.type === 'car') return t(`reservations.span.${phase === 'start' ? 'pickup' : phase === 'end' ? 'return' : 'active'}`) // Parking reuses span.pickup for its end day on purpose: you drop the car off and // later pick it up again, so the rental car's wording fits without a second key. if (r.type === 'parking') return t(`reservations.span.${phase === 'start' ? 'dropOff' : phase === 'end' ? 'pickup' : 'ongoing'}`) return t(`reservations.span.${phase === 'start' ? 'start' : phase === 'end' ? 'end' : 'ongoing'}`) } const getDayOrder = (day: (typeof days)[number]) => (day as any).day_number ?? days.indexOf(day) const computeMultiDayMove = (r: Reservation, targetDayId: number, phase: 'single' | 'start' | 'middle' | 'end') => { const startId = r.day_id ?? targetDayId const endId = r.end_day_id ?? startId const order = (id: number) => { const d = days.find(x => x.id === id); return d ? getDayOrder(d) : 0 } if (phase === 'single' || startId === endId) return { day_id: targetDayId, end_day_id: targetDayId } if (phase === 'start') { if (order(targetDayId) > order(endId)) return { day_id: targetDayId, end_day_id: targetDayId } return { day_id: targetDayId, end_day_id: endId } } // phase === 'end' if (order(targetDayId) < order(startId)) return { day_id: targetDayId, end_day_id: targetDayId } return { day_id: startId, end_day_id: targetDayId } } const getTransportForDay = (dayId: number) => _getTransportForDay({ reservations, dayId, dayAssignmentIds: (assignments[String(dayId)] || []).map(a => a.id), days }) // Get car rentals that are in "active" (middle) phase for a day — shown in day header, not timeline const getActiveRentalsForDay = (dayId: number) => { return reservations.filter(r => { if (r.type !== 'car') return false const startDayId = r.day_id const endDayId = r.end_day_id if (!startDayId || !endDayId || endDayId === startDayId) return false const startDay = days.find(d => d.id === startDayId) const endDay = days.find(d => d.id === endDayId) const thisDay = days.find(d => d.id === dayId) if (!startDay || !endDay || !thisDay) return false return getDayOrder(thisDay) > getDayOrder(startDay) && getDayOrder(thisDay) < getDayOrder(endDay) }) } const getDayAssignments = (dayId) => (assignments[String(dayId)] || []).filter(a => mirrorServiceStops || !isServiceStopType(a.place?.stop_type)).slice().sort((a, b) => a.order_index - b.order_index) // Compute initial day_plan_position for a transport based on time const computeTransportPosition = (r, da) => { const minutes = parseTimeToMinutes(r.reservation_time) ?? 0 // Find the last place with time <= transport time let afterIdx = -1 for (const a of da) { const pm = parseTimeToMinutes(a.place?.place_time) if (pm !== null && pm <= minutes) afterIdx = a.order_index } // Position: midpoint between afterIdx and afterIdx+1 (leaves room for other items) return afterIdx >= 0 ? afterIdx + 0.5 : da.length + 0.5 } // Auto-initialize transport positions on first render if not set const initTransportPositions = (dayId) => { const da = getDayAssignments(dayId) const transport = getTransportForDay(dayId) const needsInit = transport.filter(r => r.day_plan_position == null && !initedTransportIds.current.has(r.id)) if (needsInit.length === 0) return const sorted = [...needsInit].sort((a, b) => (parseTimeToMinutes(a.reservation_time) ?? 0) - (parseTimeToMinutes(b.reservation_time) ?? 0) ) const positions = sorted.map((r, idx) => ({ id: r.id, day_plan_position: computeTransportPosition(r, da) + idx * 0.01, })) // Mark as initialized immediately to prevent re-entry for (const p of positions) initedTransportIds.current.add(p.id) const before = positions.map(p => ({ id: p.id, day_plan_position: useTripStore.getState().reservations.find(r => r.id === p.id)?.day_plan_position ?? null, })) // Update store so subscribers see the new positions useTripStore.setState(state => ({ reservations: state.reservations.map(r => { const p = positions.find(x => x.id === r.id) if (!p) return r return { ...r, day_plan_position: p.day_plan_position } }) })) // Persist to server. No toast — this is a bootstrap write nobody asked for — // but the store has to go back to what the server still holds, otherwise the // day shows an order that only exists in this tab. // The ids stay marked as initialised: the effect reruns on every reservations // change, so clearing them here would retry the same failing write in a loop. reservationsApi.updatePositions(tripId, positions).catch(() => { useTripStore.setState(state => ({ reservations: state.reservations.map(r => { const p = before.find(x => x.id === r.id) if (!p) return r return { ...r, day_plan_position: p.day_plan_position } }) })) }) } const getMergedItems = (dayId: number): MergedItem[] => _getMergedItems({ dayAssignments: getDayAssignments(dayId), dayNotes: (dayNotes[String(dayId)] || []).slice().sort((a, b) => a.sort_order - b.sort_order), dayTransports: getTransportForDay(dayId), dayId, getDisplayTime: getDisplayTimeForDay, }) // Pre-compute merged items for all days so the render loop doesn't recompute on unrelated state changes (e.g. hover) // eslint-disable-next-line react-hooks/exhaustive-deps const mergedItemsMap = useMemo(() => { const map: Record> = {} days.forEach(day => { map[day.id] = getMergedItems(day.id) }) return map // getMergedItems is redefined each render but captures assignments/dayNotes/reservations/days via closure // eslint-disable-next-line react-hooks/exhaustive-deps }, [days, assignments, dayNotes, reservations, transportPosVersion, mirrorServiceStops]) // Days whose inline route legs should be computed & shown. Desktop: the selected // day while the Route toggle is on. Mobile: each expanded day the user tapped // "Route" on — shown inline so seeing distances between places doesn't require // selecting the day, which would close the mobile sheet (#1374). const routeDayIds = useMemo(() => ( showRouteToolsWhenExpanded ? days.filter(d => expandedRouteDayIds.has(d.id) && expandedDays.has(d.id)).map(d => d.id) : (routeShown && selectedDayId ? [selectedDayId] : []) ), [showRouteToolsWhenExpanded, expandedRouteDayIds, expandedDays, days, routeShown, selectedDayId]) const routeDayKey = routeDayIds.join(',') // Per-segment travel times shown as connectors between a day's located stops. // Groups located places into runs (split at transports), one cached OSRM call per // run keyed by the start place's assignment id, plus the hotel bookend legs. Shares // RouteCalculator's cache with the map. Runs for every day in routeDayIds — one // selected day on desktop, each Route-toggled day on mobile (#1374). useEffect(() => { if (legsAbortRef.current) legsAbortRef.current.abort() if (routeDayIds.length === 0) { setRouteLegs({}); setHotelLegs({}); return } const hotelName = (a: Accommodation) => (a as any).place_name || (a as any).reservation_title || '' // Pure per-day plan: the drive runs (each ≥2 waypoints) and which hotel bookend // legs to draw. Side-effect free, so the async loop below only does OSRM I/O. const planDay = (dayId: number) => { const merged = mergedItemsMap[dayId] || [] // Each run point carries the ORIGIN assignment's per-segment travel mode // (#1281): the leg from this point to the next is routed with `mode` (null = // inherit the day default). Transport endpoints carry no mode → day default. const runs: { id: number; lat: number; lng: number; isPlace: boolean; leg_transport_mode?: string | null; incoming_leg_transport_mode?: string | null }[][] = [] let cur: { id: number; lat: number; lng: number; isPlace: boolean; leg_transport_mode?: string | null; incoming_leg_transport_mode?: string | null }[] = [] // A run is only a real drive when it holds an actual place. Two back-to-back // transports (e.g. two flights on one day) would otherwise pair the first's // arrival with the second's departure into a phantom airport→airport leg — the // flight, not a drive — and surface it as a bogus connector distance (#1394). let curHasPlace = false for (const it of merged) { if (it.type === 'place' && it.data.place?.lat && it.data.place?.lng) { // Mirror of the guard below: the open run may hold nothing but a far-away // arrival endpoint, which is no more drivable in this direction (#2133). const prev = cur[cur.length - 1] if (prev && !prev.isPlace && !withinDriveRange(prev, { lat: it.data.place.lat, lng: it.data.place.lng })) { if (cur.length >= 2 && curHasPlace) runs.push(cur) cur = [] curHasPlace = false } cur.push({ id: it.data.id, lat: it.data.place.lat, lng: it.data.place.lng, isPlace: true, leg_transport_mode: it.data.leg_transport_mode ?? null, incoming_leg_transport_mode: it.data.incoming_leg_transport_mode ?? null }) curHasPlace = true } else if (it.type === 'transport') { const r = it.data const { from, to } = getTransportRouteEndpoints(r, dayId) if (from || to) { // Located transport: route to its departure point, break the run (the // flight/train itself isn't driven), and let its arrival start the next. // ...but only when you could have driven there from the stop before it. A // long-haul departure airport that sorted in after the day's local stops is // not the end of a drive, and pairing them produces a phantom connector with // an ocean-crossing distance (#2133). const prev = cur[cur.length - 1] if (from && (!prev || withinDriveRange(prev, from))) cur.push({ id: r.id, lat: from.lat, lng: from.lng, isPlace: false }) if (cur.length >= 2 && curHasPlace) runs.push(cur) cur = [] curHasPlace = false if (to) cur.push({ id: r.id, lat: to.lat, lng: to.lng, isPlace: false }) } else if (cur.length > 0 && !(r.type === 'car' && getSpanPhase(r, dayId) === 'middle') && !hidesOnMiddleDay(r, dayId)) { // No location: ignore for routing, but attribute the through-leg to the // booking so its distance/duration shows under it (purely cosmetic). // Not for a car rental's middle days or a multi-day parking's though: // those rows aren't rendered in the timeline, so re-keying would drop // the leg entirely (#1504). cur[cur.length - 1] = { ...cur[cur.length - 1], id: r.id } } } } if (cur.length >= 2 && curHasPlace) runs.push(cur) // Hotel bookend legs: the drive from the day's accommodation to the first located // waypoint of the day (morning) and from the last one back to it (evening). Only when // the "optimize from accommodation" setting is on and the day has a hotel. const day = days.find(d => d.id === dayId) const bookends = day && optimizeFromAccommodation !== false ? getDayBookendHotels(day, days, accommodations) : null const startHotel = bookends?.morning const endHotel = bookends?.evening // Waypoints include transport endpoints (a car return, a taxi/train arrival), so the hotel // legs connect even when the day starts or ends with a booking rather than a place. Track // whether each is a place and its time so the bookend decision can drop a leg that isn't // real: a check-in hotel never drove to a departure airport (#1321), and a place timed before // check-in / after check-out means you weren't at the hotel then (#1465). // A carrier endpoint (flight/train/ferry/cruise/coach — not a hire car you keep // driving) also records WHICH end it is, so the hotel leg can be dropped when it // is one you flew out of or landed at rather than drove between (#2133). const wayPts: { lat: number; lng: number; isPlace: boolean; time: string | null; carrierEdge?: CarrierEdge; leg_transport_mode?: string | null; incoming_leg_transport_mode?: string | null; id?: number }[] = [] for (const it of merged) { if (it.type === 'place' && it.data.place?.lat && it.data.place?.lng) { wayPts.push({ lat: it.data.place.lat, lng: it.data.place.lng, isPlace: true, time: it.data.place?.place_time ?? null, leg_transport_mode: it.data.leg_transport_mode ?? null, incoming_leg_transport_mode: it.data.incoming_leg_transport_mode ?? null, id: it.data.id }) } else if (it.type === 'transport') { const { from, to } = getTransportRouteEndpoints(it.data, dayId) const carrier = isCarrierTransport(it.data) if (from) wayPts.push({ lat: from.lat, lng: from.lng, isPlace: false, time: null, carrierEdge: carrier ? 'departure' : null }) if (to) wayPts.push({ lat: to.lat, lng: to.lng, isPlace: false, time: null, carrierEdge: carrier ? 'arrival' : null }) } } const firstWay = wayPts[0] const lastWay = wayPts[wayPts.length - 1] const reachable = (h: { place_lat?: number | null; place_lng?: number | null } | undefined, w: typeof firstWay) => !h || !w || w.isPlace || h.place_lat == null || h.place_lng == null || withinDriveRange({ lat: h.place_lat, lng: h.place_lng }, w) // Same carrier evidence the map route uses (#2157): with a located carrier // endpoint on the day, the no-time default must not open a hotel leg. const dayHasCarrier = wayPts.some(w => w.carrierEdge != null) const wantTop = !!(startHotel && firstWay && bookends && day && shouldDrawMorningLeg(bookends, day, firstWay, dayHasCarrier)) && reachable(startHotel, firstWay) const wantBottom = !!(endHotel && lastWay && bookends && day && shouldDrawEveningLeg(bookends, day, lastWay, dayHasCarrier)) && reachable(endHotel, lastWay) return { runs, startHotel, endHotel, firstWay, lastWay, wantTop, wantBottom } } const controller = new AbortController() legsAbortRef.current = controller ;(async () => { const legsByDay: Record> = {} const hotelByDay: Record = {} // One cached OSRM/plugin call per waypoint pair; shares RouteCalculator's cache. // tripId/dayId ride along for plugin route profiles (the server access-checks them). // Resolve a leg's mode (#1281): an explicit per-segment override wins, then // the day's saved default, then the live picker value. Sticky by design — the // day default never touches a leg that carries its own mode. const dayDefaultMode = (dayId: number): string => days.find(d => d.id === dayId)?.default_transport_mode || routeProfile const legBetween = async (a: { lat: number; lng: number }, b: { lat: number; lng: number }, dayId: number, mode: string): Promise => { try { const r = await calculateRouteWithLegs([a, b], { signal: controller.signal, profile: mode, tripId, dayId }) return r.legs[0] ? { ...r.legs[0], mode } : undefined } catch { return undefined } } // Collect every leg of every day first, then fetch them a few at a time. // Each result is stored under its own key, so nothing here depends on the // order they come back in — which is what lets them overlap at all. const tasks: (() => Promise)[] = [] for (const dayId of routeDayIds) { const { runs, startHotel, endHotel, firstWay, lastWay, wantTop, wantBottom } = planDay(dayId) const dfMode = dayDefaultMode(dayId) const dayLegs: Record = {} legsByDay[dayId] = dayLegs for (const run of runs) { // One routing call per LEG, each with its own resolved mode, so a day can // mix walking/driving/plugin legs. RouteCalculator's cache is keyed per // profile+coords, so per-leg calls stay cheap (a single-mode day reuses // the same entries) and each leg is tagged with the mode it was drawn in. for (let i = 0; i < run.length - 1; i++) { const from = run[i] const to = run[i + 1] const mode = resolveLegMode(from, to, dfMode) tasks.push(async () => { const seg = await legBetween({ lat: from.lat, lng: from.lng }, { lat: to.lat, lng: to.lng }, dayId, mode) if (seg) dayLegs[from.id] = seg }) } } const hotel: { top?: { seg: RouteSegment; name: string; targetId?: number }; bottom?: { seg: RouteSegment; name: string; targetId?: number } } = {} hotelByDay[dayId] = hotel if (wantTop) { const mode = resolveLegMode({ isPlace: false }, firstWay!, dfMode) tasks.push(async () => { const seg = await legBetween({ lat: startHotel!.place_lat as number, lng: startHotel!.place_lng as number }, { lat: firstWay!.lat, lng: firstWay!.lng }, dayId, mode) if (seg) hotel.top = { seg, name: hotelName(startHotel!), targetId: firstWay!.isPlace ? firstWay!.id : undefined } }) } if (wantBottom) { const mode = resolveLegMode(lastWay!, { isPlace: false }, dfMode) tasks.push(async () => { const seg = await legBetween({ lat: lastWay!.lat, lng: lastWay!.lng }, { lat: endHotel!.place_lat as number, lng: endHotel!.place_lng as number }, dayId, mode) if (seg) hotel.bottom = { seg, name: hotelName(endHotel!), targetId: lastWay!.isPlace ? lastWay!.id : undefined } }) } } // Small pool on purpose: a week of days is dozens of legs and the routing // host is often a shared OSRM. let nextTask = 0 const runTasks = async () => { while (nextTask < tasks.length && !controller.signal.aborted) await tasks[nextTask++]() } await Promise.all(Array.from({ length: Math.min(6, tasks.length) }, runTasks)) if (controller.signal.aborted) return // Days that ended up without a single leg were never in the map before. for (const dayId of Object.keys(legsByDay).map(Number)) { if (!Object.keys(legsByDay[dayId]).length) delete legsByDay[dayId] } for (const dayId of Object.keys(hotelByDay).map(Number)) { if (!hotelByDay[dayId].top && !hotelByDay[dayId].bottom) delete hotelByDay[dayId] } setRouteLegs(legsByDay) setHotelLegs(hotelByDay) })() // routeDayIds is memoized from the same inputs as routeDayKey below, so keying the // effect on the string is equivalent while staying stable across unrelated renders. // eslint-disable-next-line react-hooks/exhaustive-deps }, [routeDayKey, routeProfile, mergedItemsMap, accommodations, days, optimizeFromAccommodation, distanceUnit]) const openAddNote = (dayId, e) => { e?.stopPropagation() _openAddNote(dayId, getMergedItems, (id) => { if (!expandedDays.has(id)) setExpandedDays(prev => new Set([...prev, id])) }) } const openEditNote = (dayId: number, note: DayNote) => _openEditNote(dayId, note) // Deleting a note asks for confirmation first — the edit/delete icons sit close together and are // easy to mis-tap on touch devices, where an accidental delete was previously unrecoverable. const [pendingDeleteNote, setPendingDeleteNote] = useState<{ dayId: number; noteId: number } | null>(null) const deleteNote = (dayId: number, noteId: number) => _deleteNote(dayId, noteId) // Unified reorder: assigns positions to ALL item types based on new visual order const applyMergedOrder = async (dayId: number, newOrder: { type: string; data: any }[]) => { // Capture previous place order for undo const prevAssignmentIds = (assignments[String(dayId)] || []).slice().sort((a, b) => a.order_index - b.order_index).map(a => a.id) // …and, per booking, the fields this call is about to overwrite, so a failed write // can put the visible order back instead of leaving a phantom one behind the error // toast. Restoring the whole array instead would also drop what a collaborator's // socket event applied while the requests were in flight. const pendingRollback: (Partial & { id: number })[] = [] const dropRollback = (id: number) => { const i = pendingRollback.findIndex(p => p.id === id) if (i >= 0) pendingRollback.splice(i, 1) } const rollBackReservations = () => { if (!pendingRollback.length) return useTripStore.setState(state => ({ reservations: state.reservations.map(r => { const p = pendingRollback.find(x => x.id === r.id) return p ? { ...r, ...p } : r }) })) setTransportPosVersion(v => v + 1) } // Places get sequential integer positions (0, 1, 2, ...) // Non-place items between place N-1 and place N get fractional positions const assignmentIds: number[] = [] const noteUpdates: { id: number; sort_order: number }[] = [] const transportUpdates: { id: number; day_plan_position: number }[] = [] // Multi-leg flight legs share a reservation id, so their positions can't live in // the single per-booking slot — collect them per leg, keyed reservationId → legIndex → pos. const legPosUpdates: Record> = {} let placeCount = 0 let i = 0 while (i < newOrder.length) { if (newOrder[i].type === 'place') { assignmentIds.push(newOrder[i].data.id) placeCount++ i++ } else { // Collect consecutive non-place items const group: { type: string; data: any }[] = [] while (i < newOrder.length && newOrder[i].type !== 'place') { group.push(newOrder[i]) i++ } // Fractional positions between (placeCount-1) and placeCount const base = placeCount > 0 ? placeCount - 1 : -1 group.forEach((g, idx) => { const pos = base + (idx + 1) / (group.length + 1) if (g.type === 'note') noteUpdates.push({ id: g.data.id, sort_order: pos }) else if (g.type === 'transport') { if (g.data.__leg) ((legPosUpdates[g.data.id] ??= {})[g.data.__leg.index] = pos) else transportUpdates.push({ id: g.data.id, day_plan_position: pos }) } }) } } try { // Update transport positions in store FIRST so the useEffect triggered by // onReorder's optimistic assignment update reads the correct positions. if (transportUpdates.length) { for (const tu of transportUpdates) { const r = useTripStore.getState().reservations.find(x => x.id === tu.id) if (r) pendingRollback.push({ id: r.id, day_plan_position: r.day_plan_position, day_positions: r.day_positions }) } useTripStore.setState(state => ({ reservations: state.reservations.map(r => { const tu = transportUpdates.find(u => u.id === r.id) if (!tu) return r const day_positions = { ...(r.day_positions || {}), [dayId]: tu.day_plan_position } return { ...r, day_plan_position: tu.day_plan_position, day_positions } }) })) setTransportPosVersion(v => v + 1) } // Per-leg positions of multi-leg flights live in metadata.legs[i].day_positions // (the single per-booking slot can't hold one position per leg). const legResIds = Object.keys(legPosUpdates) if (legResIds.length) { for (const ridStr of legResIds) { const rid = Number(ridStr) const r = useTripStore.getState().reservations.find(x => x.id === rid) if (!r) continue let parsed: any = {} try { parsed = typeof r.metadata === 'string' ? JSON.parse(r.metadata || '{}') : (r.metadata || {}) } catch { parsed = {} } if (!Array.isArray(parsed.legs)) continue const legs = parsed.legs.map((leg: any, i: number) => { const pos = legPosUpdates[rid][i] return pos == null ? leg : { ...leg, day_positions: { ...(leg.day_positions || {}), [dayId]: pos } } }) // Send metadata as an OBJECT (like the form does) — passing a JSON string // here double-encodes it on the server, which wipes metadata.legs on read // and collapses the flight back to a single span. const newMeta = { ...parsed, legs } pendingRollback.push({ id: rid, metadata: r.metadata }) useTripStore.setState(state => ({ reservations: state.reservations.map(x => (x.id === rid ? { ...x, metadata: newMeta } : x)) })) await tripActions.updateReservation(tripId, rid, { metadata: newMeta }) // Stored, so a later step failing must not put the old legs back. dropRollback(rid) } setTransportPosVersion(v => v + 1) } if (assignmentIds.length) await onReorder(dayId, assignmentIds) if (transportUpdates.length) { onRouteRefresh?.() await reservationsApi.updatePositions(tripId, transportUpdates, dayId) for (const tu of transportUpdates) dropRollback(tu.id) } for (const n of noteUpdates) { await tripActions.updateDayNote(tripId, dayId, n.id, { sort_order: n.sort_order }) } if (prevAssignmentIds.length) { const capturedDayId = dayId const capturedPrevIds = prevAssignmentIds pushUndo?.(t('undo.reorder'), async () => { await tripActions.reorderAssignments(tripId, capturedDayId, capturedPrevIds) }) } } catch (err: unknown) { rollBackReservations() toast.error(err instanceof Error ? err.message : t('common.unknownError')) } } const handleMergedDrop = async (dayId, fromType, fromId, toType, toId, insertAfter = false, toLegIndex = null) => { const m = getMergedItems(dayId) // Multi-leg flights expose one item per leg sharing the same reservation id; // disambiguate the drop target by leg index so you can drop BETWEEN legs. const matchTo = (i: any) => i.type === toType && i.data.id === toId && (toLegIndex == null || i.data?.__leg?.index === toLegIndex) // Check if a timed place is being moved → would it break chronological order? if (fromType === 'place') { const fromItem = m.find(i => i.type === 'place' && i.data.id === fromId) const fromMinutes = parseTimeToMinutes(fromItem?.data?.place?.place_time) if (fromItem && fromMinutes !== null) { const fromIdx = m.findIndex(i => i.type === fromType && i.data.id === fromId) const toIdx = m.findIndex(matchTo) if (fromIdx !== -1 && toIdx !== -1) { const simulated = [...m] const [moved] = simulated.splice(fromIdx, 1) let insertIdx = simulated.findIndex(matchTo) if (insertIdx === -1) insertIdx = simulated.length if (insertAfter) insertIdx += 1 simulated.splice(insertIdx, 0, moved) const timedInOrder = simulated .map(i => { if (i.type === 'transport') return parseTimeToMinutes(i.data?.reservation_time) if (i.type === 'place') return parseTimeToMinutes(i.data?.place?.place_time) return null }) .filter(t => t !== null) const isChronological = timedInOrder.every((t, i) => i === 0 || t >= timedInOrder[i - 1]) if (!isChronological) { const placeTime = fromItem.data.place.place_time const timeStr = placeTime.includes(':') ? placeTime.substring(0, 5) : placeTime setTimeConfirm({ dayId, fromType, fromId, toType, toId, insertAfter, toLegIndex, time: timeStr }) setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null return } } } } // Build new order: remove the dragged item, insert at target position const fromIdx = m.findIndex(i => i.type === fromType && i.data.id === fromId) const toIdx = m.findIndex(matchTo) if (fromIdx === -1 || toIdx === -1 || fromIdx === toIdx) { setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null return } const newOrder = [...m] const [moved] = newOrder.splice(fromIdx, 1) let adjustedTo = newOrder.findIndex(matchTo) if (adjustedTo === -1) adjustedTo = newOrder.length if (insertAfter) adjustedTo += 1 newOrder.splice(adjustedTo, 0, moved) await applyMergedOrder(dayId, newOrder) setDraggingId(null) setDropTargetKey(null) dragDataRef.current = null } const confirmTimeRemoval = async () => { if (!timeConfirm) return const saved = { ...timeConfirm } const { dayId, fromId, fromType, toType, toId, insertAfter, toLegIndex } = saved setTimeConfirm(null) // Remove time from assignment try { await assignmentsApi.updateTime(tripId, fromId, { place_time: null, end_time: null }) const key = String(dayId) const currentAssignments = { ...assignments } if (currentAssignments[key]) { currentAssignments[key] = currentAssignments[key].map(a => a.id === fromId ? { ...a, place: { ...a.place, place_time: null, end_time: null } } : a ) tripActions.setAssignments(currentAssignments) } } catch (err) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) return } // Re-apply the pending move against the current merged list const m = getMergedItems(dayId) if (fromType && toType) { const matchTo = (i: any) => i.type === toType && i.data.id === toId && (toLegIndex == null || i.data?.__leg?.index === toLegIndex) const fromIdx = m.findIndex(i => i.type === fromType && i.data.id === fromId) const toIdx = m.findIndex(matchTo) if (fromIdx === -1 || toIdx === -1 || fromIdx === toIdx) return const newOrder = [...m] const [moved] = newOrder.splice(fromIdx, 1) let adjustedTo = newOrder.findIndex(matchTo) if (adjustedTo === -1) adjustedTo = newOrder.length if (insertAfter) adjustedTo += 1 newOrder.splice(adjustedTo, 0, moved) await applyMergedOrder(dayId, newOrder) } } const moveNote = async (dayId, noteId, direction) => { await _moveNote(dayId, noteId, direction, getMergedItems) } const toggleLock = (assignmentId) => { const prevLocked = new Set(lockedIds) setLockedIds(prev => { const next = new Set(prev) if (next.has(assignmentId)) next.delete(assignmentId) else next.add(assignmentId) return next }) pushUndo?.(t('undo.lock'), () => { setLockedIds(prevLocked) }) } const handleOptimize = async (dayId: number) => { const da = getDayAssignments(dayId) if (da.length < 3) return const prevIds = (assignments[String(dayId)] || []).slice().sort((a, b) => a.order_index - b.order_index).map(a => a.id) // Separate fixed (stay at their index) and movable assignments. A place is // fixed if it's locked OR has a set time — timed places are anchored by their // time, so the optimizer must not reshuffle them. const locked = new Map() // index -> assignment const unlocked = [] da.forEach((a, i) => { if (lockedIds.has(a.id) || a.place?.place_time) locked.set(i, a) else unlocked.push(a) }) // Optimize only unlocked assignments (work on assignments, not places) const unlockedWithCoords = unlocked.filter(a => a.place?.lat && a.place?.lng) const unlockedNoCoords = unlocked.filter(a => !a.place?.lat || !a.place?.lng) // Anchor the route on the day's accommodation (when enabled): a loop out from and back to the // hotel, or — on a transfer day — a run from the hotel you leave to the one you arrive at. const day = days.find(d => d.id === dayId) const anchors = day && useSettingsStore.getState().settings.optimize_from_accommodation !== false ? getAccommodationAnchors( day, days, accommodations, unlockedWithCoords.map(a => ({ lat: a.place!.lat!, lng: a.place!.lng! })), (mergedItemsMap[dayId] || []).some(i => i.type === 'transport' && hasCarrierEndpointOnDay(i.data, dayId)), ) : {} const optimizedAssignments = unlockedWithCoords.length >= 2 ? optimizeRoute(unlockedWithCoords.map(a => ({ ...a.place, _assignmentId: a.id })), anchors).map(p => unlockedWithCoords.find(a => a.id === p._assignmentId)).filter(Boolean) : unlockedWithCoords const optimizedQueue = [...optimizedAssignments, ...unlockedNoCoords] // Merge: locked stay at their index, fill gaps with optimized const result = new Array(da.length) locked.forEach((a, i) => { result[i] = a }) let qi = 0 for (let i = 0; i < result.length; i++) { if (!result[i]) result[i] = optimizedQueue[qi++] } await onReorder(dayId, result.map(a => a.id)) const usedHotel = !!(anchors.start || anchors.end) toast.success(usedHotel ? t('dayplan.toast.routeOptimizedFromHotel') : t('dayplan.toast.routeOptimized')) const capturedDayId = dayId pushUndo?.(t('undo.optimize'), async () => { await tripActions.reorderAssignments(tripId, capturedDayId, prevIds) }) } const handleDropOnDay = (e, dayId) => { e.preventDefault() e.stopPropagation() setDragOverDayId(null) const { placeId, assignmentId, noteId, reservationId: fromReservationId, fromDayId, phase } = getDragData(e) if (fromReservationId && fromDayId !== dayId) { const r = reservations.find(x => x.id === Number(fromReservationId)) if (r) { const update = computeMultiDayMove(r, dayId, phase); tripActions.updateReservation(tripId, r.id, update).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) } setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; window.__dragData = null; return } if (placeId) { onAssignToDay?.(Number.parseInt(placeId), dayId) } else if (assignmentId && fromDayId !== dayId) { const srcAssignment = (useTripStore.getState().assignments[String(fromDayId)] || []).find(a => a.id === Number(assignmentId)) const capturedFromDayId = fromDayId const capturedOrderIndex = srcAssignment?.order_index ?? 0 tripActions.moveAssignment(tripId, Number(assignmentId), fromDayId, dayId) .then(() => { pushUndo?.(t('undo.moveDay'), async () => { await tripActions.moveAssignment(tripId, Number(assignmentId), dayId, capturedFromDayId, capturedOrderIndex) }) }) .catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) } else if (noteId && fromDayId !== dayId) { tripActions.moveDayNote(tripId, fromDayId, dayId, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) } setDraggingId(null) setDropTargetKey(null) dragDataRef.current = null window.__dragData = null } const totalCostLabel = useMemo(() => { const entries = days.flatMap(d => (assignments[String(d.id)] || []).map(a => ({ amount: Number(a.place?.price) || 0, currency: a.place?.currency || currency, }))) return formatMoneySum(entries, costBase, locale, fxRates) }, [days, assignments, currency, costBase, locale, fxRates]) return { tripId, trip, days, places, categories, assignments, selectedDayId, selectedPlaceId, selectedAssignmentId, onSelectDay, onPlaceClick, onDayDetail, accommodations, onReorder, onReorderDays, onAddDay, onAssignToDay, onRemoveAssignment, onEditPlace, onDeletePlace, reservations, visibleConnectionIds, onToggleConnection, allConnectionsShown, onToggleAllConnections, externalTransportDetail, onExternalTransportDetailHandled, onAddReservation, onAddPlace, onAddPlaceToDay, onCreatePlaceForDay, onNavigateToFiles, routeShown, routeProfile, onToggleRoute, onSetRouteProfile, onExpandedDaysChange, pushUndo, canUndo, lastActionLabel, onUndo, onRouteRefresh, onAddTransport, onPlanTransit, onPlanTransitLeg, onOpenTransit, onEditTransport, expandedTransitIds, setExpandedTransitIds, onEditReservation, onAddBookingToAssignment, initialScrollTop, onScrollTopChange, showRouteToolsWhenExpanded, isMobile, dragDisabled, toast, t, language, locale, ctxMenu, timeFormat, tripActions, can, canEditDays, canManageShare, noteUi, setNoteUi, noteInputRef, dayNotes, openAddNote, openEditNote, cancelNote, saveNote, deleteNote, pendingDeleteNote, setPendingDeleteNote, moveNote, expandedDays, setExpandedDays, routeLegs, setRouteLegs, hotelLegs, setHotelLegs, legsAbortRef, draggingId, setDraggingId, lockedIds, setLockedIds, lockHoverId, setLockHoverId, undoHover, setUndoHover, hoveredAssignmentId, setHoveredAssignmentId, dropTargetKey, _setDropTargetKey, dropTargetRef, setDropTargetKey, dragOverDayId, setDragOverDayId, transportDetail, setTransportDetail, transportPosVersion, setTransportPosVersion, timeConfirm, setTimeConfirm, dragDataRef, scrollContainerRef, dayRefs, initedTransportIds, lastAutoScrolledIdRef, currency, costBase, fxRates, getDragData, prevDayCount, toggleDay, getSpanLabel, getDayOrder, computeMultiDayMove, getTransportForDay, getActiveRentalsForDay, getDayAssignments, computeTransportPosition, initTransportPositions, getMergedItems, mergedItemsMap, applyMergedOrder, handleMergedDrop, confirmTimeRemoval, toggleLock, handleOptimize, handleDropOnDay, totalCostLabel, expandedRouteDayIds, setExpandedRouteDayIds, } } /** * Below this the plan sidebar has no room for a word on the route button. * * The word goes early on purpose: it shares its row with two export buttons and * an optimise button, and a "Route" squeezed to four letters and an ellipsis is * worse than the icon that means the same thing. The icon keeps the accessible * name, so nothing is lost but the letters. */ const NARROW_PLAN_PX = 320 const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarProps) { const S = useDayPlanSidebar(props) // The plan sidebar is resizable. Below this the route button's own word is the // first thing that stops fitting beside the two export buttons — the icon says // the same thing, and the accessible name still spells it out. // // A callback ref rather than `useRef`, so the measurement happens when the // element exists rather than on a mount that may render nothing yet. const [panel, setPanel] = useState(null) const [narrowPanel, setNarrowPanel] = useState(false) useEffect(() => { if (!panel || typeof ResizeObserver === 'undefined') return const measure = (): void => setNarrowPanel(panel.clientWidth < NARROW_PLAN_PX) measure() const ro = new ResizeObserver(measure) ro.observe(panel) return () => ro.disconnect() }, [panel]) // A stable key for the current selection. A multi-day place renders one row per // day (same place_id, different assignment ids); selecting it by place_id alone // (e.g. clicking an accommodation) marks every one of those rows selected, so a // per-assignment scroll-lock would let each day's row scroll the list in turn. // Keying the lock on the selection identity makes only the first row scroll (#1375). const selectionScrollKey = S.selectedAssignmentId != null ? `a${S.selectedAssignmentId}` : S.selectedPlaceId != null ? `p${S.selectedPlaceId}` : null // Needed by the route-tools visibility gate in the render below (#1330); the hook // keeps its own copy, so read it reactively here in the component scope too. const optimizeFromAccommodation = useSettingsStore(s => s.settings.optimize_from_accommodation) const collectionsEnabled = useAddonStore(s => s.isEnabled('collections')) // Route-profile picker entries: the two built-ins plus every profile an active // routeProvider plugin declared (keyed 'plugin:/', labeled by the // plugin's manifest). The feed only lists granted providers. const activePlugins = usePluginStore(s => s.plugins) // Plugin time contributions in the day plan (dayScheduleProvider hook). const daySchedule = usePluginDaySchedule(S.tripId) // Per-day colours from the dayTintProvider hook — e.g. which leg of the trip a // day belongs to. Empty unless a granted plugin provides them. const dayTints = usePluginDayTints(S.tripId) const routeProfileOptions = useMemo(() => { const opts: Array<{ key: string; label: string }> = [ { key: 'driving', label: 'Driving' }, { key: 'walking', label: 'Walking' }, ] for (const p of activePlugins) { for (const prof of p.routeProfiles ?? []) opts.push({ key: `plugin:${p.id}/${prof.id}`, label: prof.label }) } return opts }, [activePlugins]) const { tripId, trip, days, places, categories, assignments, selectedDayId, selectedPlaceId, selectedAssignmentId, onSelectDay, onPlaceClick, onDayDetail, accommodations, onReorder, onReorderDays, onAddDay, onAssignToDay, onRemoveAssignment, onEditPlace, onDeletePlace, reservations, visibleConnectionIds, onToggleConnection, allConnectionsShown, onToggleAllConnections, externalTransportDetail, onExternalTransportDetailHandled, onAddReservation, onAddPlace, onAddPlaceToDay, onCreatePlaceForDay, onNavigateToFiles, routeShown, routeProfile, onToggleRoute, onSetRouteProfile, onExpandedDaysChange, pushUndo, canUndo, lastActionLabel, onUndo, onRouteRefresh, onAddTransport, onPlanTransit, onPlanTransitLeg, onOpenTransit, onEditTransport, expandedTransitIds, setExpandedTransitIds, onEditReservation, onAddBookingToAssignment, initialScrollTop, onScrollTopChange, showRouteToolsWhenExpanded, isMobile, dragDisabled, toast, t, language, locale, ctxMenu, timeFormat, tripActions, can, canEditDays, canManageShare, noteUi, setNoteUi, noteInputRef, dayNotes, openAddNote, openEditNote, cancelNote, saveNote, deleteNote, pendingDeleteNote, setPendingDeleteNote, moveNote, expandedDays, setExpandedDays, routeLegs, setRouteLegs, hotelLegs, setHotelLegs, legsAbortRef, draggingId, setDraggingId, lockedIds, setLockedIds, lockHoverId, setLockHoverId, undoHover, setUndoHover, hoveredAssignmentId, setHoveredAssignmentId, dropTargetKey, _setDropTargetKey, dropTargetRef, setDropTargetKey, dragOverDayId, setDragOverDayId, transportDetail, setTransportDetail, transportPosVersion, setTransportPosVersion, timeConfirm, setTimeConfirm, dragDataRef, scrollContainerRef, dayRefs, initedTransportIds, lastAutoScrolledIdRef, currency, costBase, fxRates, getDragData, prevDayCount, toggleDay, getSpanLabel, getDayOrder, computeMultiDayMove, getTransportForDay, getActiveRentalsForDay, getDayAssignments, computeTransportPosition, initTransportPositions, getMergedItems, mergedItemsMap, applyMergedOrder, handleMergedDrop, confirmTimeRemoval, toggleLock, handleOptimize, handleDropOnDay, totalCostLabel, expandedRouteDayIds, setExpandedRouteDayIds, } = S // ── Per-segment / per-day travel mode (#1281) ────────────────────────────── // Icon per mode, matching the route picker (driving→Car, walking→Footprints, // any plugin profile→Zap). const modeIcon = (key: string) => (key === 'walking' ? Footprints : key.startsWith('plugin:') ? Zap : Car) // Set the mode of the leg LEAVING this stop. Optimistic (the connector + map // recompute from the store), then persisted; null clears the override so the leg // falls back to the day default. The whole-day picker never writes here — that is // what keeps a chosen segment sticky against the Foot/Car button (#1281). const setLegMode = (assignmentId: number, dayId: number, mode: string | null) => { const key = String(dayId) if (assignments[key]) { tripActions.setAssignments({ ...assignments, [key]: assignments[key].map(a => (a.id === assignmentId ? { ...a, leg_transport_mode: mode } : a)), }) } assignmentsApi.updateTransport(tripId, assignmentId, mode).catch((err: unknown) => { toast.error(err instanceof Error ? err.message : t('common.unknownError')) tripActions.refreshDays(tripId) }) } // The whole-day default (the Car/Foot picker). Persisted so it survives a reload, // and mirrored into routeProfile so the live map redraws at once. Legs that carry // their own mode are left untouched. const setDayDefaultMode = (dayId: number, mode: string) => { useTripStore.setState(state => ({ days: state.days.map(d => (d.id === dayId ? { ...d, default_transport_mode: mode } : d)) })) onSetRouteProfile?.(mode) daysApi.updateTransport(tripId, dayId, mode).catch((err: unknown) => { toast.error(err instanceof Error ? err.message : t('common.unknownError')) tripActions.refreshDays(tripId) }) } // A connector's endpoints carry only coordinates (RouteSegment.from/to, both // [lat, lng]); resolve them back to the NAMES the transit search shows by indexing // every located place, hotel and booking endpoint on the trip. Name is a property // of a coordinate, so a trip-wide index is fine; a connector only renders when both // ends are located, so a coordinate that misses the index is rare (nameless pick). const transitNameIndex = useMemo(() => { const m = new Map() const put = (lat?: number | null, lng?: number | null, name?: string | null) => { if (lat == null || lng == null || !name) return const k = `${lat},${lng}` if (!m.has(k)) m.set(k, name) } for (const list of Object.values(assignments)) { for (const a of list) put(a.place?.lat, a.place?.lng, a.place?.name) } for (const acc of accommodations) put(acc.place_lat, acc.place_lng, acc.place_name) for (const r of reservations) { for (const ep of (r.endpoints || [])) put(ep.lat, ep.lng, ep.name) } return m }, [assignments, accommodations, reservations]) // The leg's departure time must be resolved WITHIN its day: the same located POI can // be revisited on another day at a different time (a supported pattern), so a // trip-wide coordinate index would return the wrong day's time. Scope to this day's // assignments (and the reservations that touch it) so a revisit keeps its own time. const originDepartureTime = (originKey: string, dayId: number): string | null => { for (const a of assignments[String(dayId)] ?? []) { if (a.place?.lat != null && a.place?.lng != null && `${a.place.lat},${a.place.lng}` === originKey) return a.place.place_time ?? null } for (const r of reservations) { if (r.day_id !== dayId && r.end_day_id !== dayId) continue for (const ep of (r.endpoints || [])) { if (ep.lat != null && ep.lng != null && `${ep.lat},${ep.lng}` === originKey) return ep.local_time ?? null } } return null } // Build the "public transport" prefill for a leg from its RouteSegment: MOTIS is // driven off the coordinates, the names come from the index and the departure time // from the origin's own day. Accepts 'H:mm' or 'HH:mm[:ss]', normalised to 'HH:mm'. const buildTransitLeg = (seg: RouteSegment | undefined, dayId: number): { from: PickedPlace; to: PickedPlace; time: string | null } | null => { if (!seg?.from || !seg?.to) return null const pick = (c: [number, number]): PickedPlace => ({ name: transitNameIndex.get(`${c[0]},${c[1]}`) || '', lat: c[0], lng: c[1] }) const rawTime = originDepartureTime(`${seg.from[0]},${seg.from[1]}`, dayId) const m = rawTime ? /^(\d{1,2}):(\d{2})/.exec(rawTime) : null return { from: pick(seg.from), to: pick(seg.to), time: m ? `${m[1].padStart(2, '0')}:${m[2]}` : null } } // The extra connector-menu entry (#1281 follow-up): search public transit for this // leg instead of drawing a road route. Only when a handler is wired (day has dates). const transitLegMenuItem = (dayId: number, seg?: RouteSegment) => { if (!onPlanTransitLeg) return [] const leg = buildTransitLeg(seg, dayId) if (!leg) return [] return [{ label: t('transit.title'), icon: TramFront, onClick: () => onPlanTransitLeg({ dayId, from: leg.from, to: leg.to, time: leg.time }) }] } // Open the mode menu at the clicked connector: every route profile, the optional // "public transport" entry, plus a "use day default" entry that clears the override. const openLegModeMenu = (e: React.MouseEvent, assignmentId: number, dayId: number, seg?: RouteSegment) => { ctxMenu.open(e, [ ...routeProfileOptions.map(o => ({ label: o.label, icon: modeIcon(o.key), onClick: () => setLegMode(assignmentId, dayId, o.key) })), ...transitLegMenuItem(dayId, seg), { divider: true }, { label: t('dayplan.transportMode.useDefault'), icon: RotateCcw, onClick: () => setLegMode(assignmentId, dayId, null) }, ]) } // Set the mode of the leg ENTERING this stop (a booking arrival or the morning // hotel bookend). Optimistic, then persisted with direction:'incoming'. const setIncomingLegMode = (assignmentId: number, dayId: number, mode: string | null) => { const key = String(dayId) if (assignments[key]) { tripActions.setAssignments({ ...assignments, [key]: assignments[key].map(a => (a.id === assignmentId ? { ...a, incoming_leg_transport_mode: mode } : a)), }) } assignmentsApi.updateTransport(tripId, assignmentId, mode, 'incoming').catch((err: unknown) => { toast.error(err instanceof Error ? err.message : t('common.unknownError')) tripActions.refreshDays(tripId) }) } const openIncomingLegModeMenu = (e: React.MouseEvent, assignmentId: number, dayId: number, seg?: RouteSegment) => { ctxMenu.open(e, [ ...routeProfileOptions.map(o => ({ label: o.label, icon: modeIcon(o.key), onClick: () => setIncomingLegMode(assignmentId, dayId, o.key) })), ...transitLegMenuItem(dayId, seg), { divider: true }, { label: t('dayplan.transportMode.useDefault'), icon: RotateCcw, onClick: () => setIncomingLegMode(assignmentId, dayId, null) }, ]) } // Enter/Space on a route connector opens the same mode menu a click opens. A key // event carries no pointer position, so the menu is anchored to the connector. const openLegMenuByKey = (e: React.KeyboardEvent, open: (m: React.MouseEvent) => void) => { if (e.key !== 'Enter' && e.key !== ' ') return const rect = e.currentTarget.getBoundingClientRect() open({ clientX: rect.left, clientY: rect.bottom, preventDefault: () => e.preventDefault(), stopPropagation: () => e.stopPropagation(), } as unknown as React.MouseEvent) } return (
{/* Toolbar */} {/* Tagesliste */}
onScrollTopChange?.((e.currentTarget as HTMLElement).scrollTop)}> {days.map((day, index) => { const isSelected = selectedDayId === day.id // Shared by the click and the key handler so the two can never drift // apart — pressing Enter on the row has to toggle, not just select. // Named for the selection, not the expand/collapse `toggleDay` above. const toggleDaySelection = () => { if (isSelected) { onSelectDay(null); if (onDayDetail) onDayDetail(null) } else { onSelectDay(day.id); if (onDayDetail) onDayDetail(day) } } const isExpanded = expandedDays.has(day.id) const da = getDayAssignments(day.id) const cost = dayTotalCost(day.id, assignments, costBase, currency, locale, fxRates) const formattedDate = formatDate(day.date, locale) const loc = da.find(a => a.place?.lat && a.place?.lng) // Route tools normally need 2+ stops, but a single located place is still // routable when accommodation optimization can bookend it with a hotel // (hotel → place → hotel, the same line the map draws) — otherwise the tools // vanish on such a day (#1330). Purely additive to the 2+ case. // The weather anchor below must not depend on the optimize-from-accommodation // setting — where you wake up is a fact about the day, not a routing choice — // so the bookend lookup runs unconditionally (mirrors useMPlanTimeline) while // the route tools keep honoring the setting via routeBookends. const dayBookends = getDayBookendHotels(day, days, accommodations) const routeBookends = optimizeFromAccommodation !== false ? dayBookends : null const hasRouteBookend = !!( (routeBookends?.morning?.place_lat != null && routeBookends?.morning?.place_lng != null) || (routeBookends?.evening?.place_lat != null && routeBookends?.evening?.place_lng != null) ) // Transfer day with no stops at all: you check out of one hotel and into another, so // the map already draws a hotel → hotel leg (see the transfer branch in // useRouteCalculation) even with zero places. Mirror that exact gate here — two // distinct bookend hotels you actually slept in / sleep in tonight — so the route // tools appear when you click the day (#1297). A same-hotel rest day or a plain // arrival/departure day has morning === evening and stays excluded. const transferMorning = routeBookends?.morning const transferEvening = routeBookends?.evening const hasHotelTransfer = !!( routeBookends?.morningIsSleptHere && routeBookends?.eveningIsOvernight && transferMorning?.place_lat != null && transferMorning?.place_lng != null && transferEvening?.place_lat != null && transferEvening?.place_lng != null && (transferMorning.place_lat !== transferEvening.place_lat || transferMorning.place_lng !== transferEvening.place_lng) ) const routeToolsRoutable = da.length >= 2 || (loc != null && hasRouteBookend) || hasHotelTransfer /** * The day's located stops in planned order, bookended by the accommodation * the same way the drawn map route is (routeBookends is null when "optimize * from accommodation" is off), so hotels aren't dropped from an exported * route (#1372) — but only when the leg is real: no hotel prepended before an * early check-in-day stop, none appended after a post-check-out stop (#1465). * Names ride along for the deep links that can label a pin with one. */ const dayExportStops = (): NamedWaypoint[] => { const dayStops = getDayAssignments(day.id).filter(a => a.place?.lat != null && a.place?.lng != null) const stops = dayStops.map(a => ({ lat: a.place!.lat!, lng: a.place!.lng!, name: a.place!.name })) const first = dayStops[0] ? { isPlace: true, time: dayStops[0].place?.place_time ?? null, lat: dayStops[0].place!.lat!, lng: dayStops[0].place!.lng! } : undefined const lastAssignment = dayStops[dayStops.length - 1] const last = lastAssignment ? { isPlace: true, time: lastAssignment.place?.place_time ?? null, lat: lastAssignment.place!.lat!, lng: lastAssignment.place!.lng! } : undefined // Same carrier gate as the drawn route (#2157): the exported link must not // start at a hotel you only reach tonight or lead back to one you left. const dayHasCarrier = (mergedItemsMap[day.id] || []).some(i => i.type === 'transport' && hasCarrierEndpointOnDay(i.data, day.id)) const drawMorning = !!routeBookends && shouldDrawMorningLeg(routeBookends, day, first, dayHasCarrier) const drawEvening = !!routeBookends && shouldDrawEveningLeg(routeBookends, day, last, dayHasCarrier) const morning = drawMorning && routeBookends?.morning?.place_lat != null && routeBookends?.morning?.place_lng != null ? { lat: routeBookends.morning.place_lat, lng: routeBookends.morning.place_lng, name: routeBookends.morning.place_name } : null const evening = drawEvening && routeBookends?.evening?.place_lat != null && routeBookends?.evening?.place_lng != null ? { lat: routeBookends.evening.place_lat, lng: routeBookends.evening.place_lng, name: routeBookends.evening.place_name } : null return [...(morning ? [morning] : []), ...stops, ...(evening ? [evening] : [])] } // Is this day's inline route currently on? Mobile toggles it per day (its // own expandedRouteDayIds entry); desktop uses the global Route toggle on // the selected day (#1374). const routeActive = showRouteToolsWhenExpanded ? expandedRouteDayIds.has(day.id) : (routeShown && isSelected) const isDragTarget = dragOverDayId === day.id const merged = mergedItemsMap[day.id] || [] const dayNoteUi = noteUi[day.id] const placeItems = merged.filter(i => i.type === 'place') // A row hidden on a middle day still sits in `merged` (the route builder above // has to see it), so the empty-day hint counts what actually renders. Without // that, a day whose only entry is a spanning parking would show a blank gap. const visibleCount = merged.filter(i => !(i.type === 'transport' && hidesOnMiddleDay(i.data, day.id))).length const dayTint = dayTints[day.id] // Resolved once per day: the header owns a background that its hover // handlers reassign imperatively, so both the base and the hover value have // to be tint-aware or the first hover-out would wipe the colour. const headerTintBg = dayTintBackground(dayTint, 'header', '--day-tint-header') ?? 'transparent' const headerTintHoverBg = dayTintBackground(dayTint, 'header', '--day-tint-header-hover') ?? 'var(--bg-tertiary)' return ( // The card wrapper stays untinted — its three regions (badge, header, // activity list) paint themselves, so a plugin controls them separately.
{ if (el) dayRefs.current.set(day.id, el); else dayRefs.current.delete(day.id) }} title={dayTint?.label || undefined} style={{ borderBottom: '1px solid var(--border-faint)' }}> {/* Tages-Header — akzeptiert Drops aus der PlacesSidebar */}
toggleDaySelection()} onKeyDown={e => { if (e.target !== e.currentTarget) return if (e.key !== 'Enter' && e.key !== ' ') return e.preventDefault() toggleDaySelection() }} onDragOver={e => { e.preventDefault(); if (dragOverDayId !== day.id) setDragOverDayId(day.id) }} onDragLeave={e => { if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setDragOverDayId(null) }} onDrop={e => handleDropOnDay(e, day.id)} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '11px 14px 11px 16px', cursor: 'pointer', background: isDragTarget ? 'rgba(17,24,39,0.07)' : (isSelected ? 'var(--bg-selected)' : headerTintBg), transition: 'background 0.12s', userSelect: 'none', outline: isDragTarget ? '2px dashed rgba(17,24,39,0.25)' : 'none', outlineOffset: -2, borderRadius: isDragTarget ? 8 : 0, touchAction: 'manipulation', }} // A tinted header hovers to a deeper mix of its own tone rather than to // the neutral --bg-tertiary, so hovering reads as a state change on // this day instead of momentarily losing its colour. Selection keeps // winning outright, so a selected day is never ambiguous. onMouseEnter={e => { if (!isSelected && !isDragTarget) e.currentTarget.style.background = headerTintHoverBg }} onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = isDragTarget ? 'rgba(17,24,39,0.07)' : headerTintBg }} > {/* Tages-Badge: Nummer oben, darunter (falls vorhanden) das Wetter des Tages */} {(() => { // Day-local anchor only (#2167): the day's first located stop, else the // hotel you wake up in. No trip-wide fallback — on a roadtrip that // silently showed another city's weather with nothing naming the place. const weatherHotel = loc == null ? dayBookends.morning : undefined const wLat = loc?.place?.lat ?? weatherHotel?.place_lat const wLng = loc?.place?.lng ?? weatherHotel?.place_lng const weatherName = loc?.place?.name ?? weatherHotel?.place_name ?? null const hasWeather = !!(day.date && wLat != null && wLng != null) return (
{/* lineHeight 1, or the digit rides the line box's leading and sits above centre on the plain badge — visible as soon as there is no weather block under it to distract from it. */}
{index + 1}
{hasWeather && ( <>
)}
) })()}
{day.title || t('dayplan.dayN', { n: index + 1 })} {formattedDate && ( <> {formattedDate} )}
{(() => { const hasAccs = accommodations.some(a => isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days)) const hasRentals = getActiveRentalsForDay(day.id).length > 0 if (!hasAccs && !hasRentals) return null return
})()}
{(() => { const dayAccs = accommodations.filter(a => isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days)) // Sort: check-out first, then ongoing stays, then check-in last .sort((a, b) => { const aIsOut = a.end_day_id === day.id && a.start_day_id !== day.id const bIsOut = b.end_day_id === day.id && b.start_day_id !== day.id const aIsIn = a.start_day_id === day.id const bIsIn = b.start_day_id === day.id if (aIsOut && !bIsOut) return -1 if (!aIsOut && bIsOut) return 1 if (aIsIn && !bIsIn) return 1 if (!aIsIn && bIsIn) return -1 return 0 }) if (dayAccs.length === 0) return null return dayAccs.map(acc => { const isCheckIn = acc.start_day_id === day.id const isCheckOut = acc.end_day_id === day.id const iconColor = isCheckOut && !isCheckIn ? '#ef4444' : isCheckIn ? '#22c55e' : 'var(--text-faint)' return ( ) }) })()} {/* Active rental car badges */} {(() => { const activeRentals = getActiveRentalsForDay(day.id) if (activeRentals.length === 0) return null return activeRentals.map(r => ( )) })()}
{cost && (
{cost}
)}
{canEditDays ? ( (() => { const cell = { padding: 7, cursor: 'pointer', display: 'grid', placeItems: 'center' } as const const div = '1px solid var(--border-faint)' return (
{/* Public transit search (#1065) — replaced the rename pencil, which moved next to the day name in the day detail view. */} {onPlanTransit ? ( ) :
} {onAddTransport ? ( ) :
}
) })() ) : ( )}
{/* Aufgeklappte Orte + Notizen */} {isExpanded && (
{ e.preventDefault(); const cur = dropTargetRef.current; if (draggingId && (!cur || cur.startsWith('end-'))) setDropTargetKey(`end-${day.id}`) }} onDrop={e => { e.preventDefault() e.stopPropagation() const { placeId, assignmentId, noteId, reservationId: fromReservationId, fromDayId, phase } = getDragData(e) // Drop on transport card (detected via dropTargetRef for sync accuracy) if (dropTargetRef.current?.startsWith('transport-')) { const isAfter = dropTargetRef.current.startsWith('transport-after-') const parts = dropTargetRef.current.replace('transport-after-', '').replace('transport-', '').split('-') const transportId = Number(parts[0]) const legPart = parts.find(p => /^leg\d+$/.test(p)) const toLegIndex = legPart ? Number(legPart.slice(3)) : null if (placeId) { onAssignToDay?.(Number.parseInt(placeId), day.id) } else if (fromReservationId && fromDayId !== day.id) { const r = reservations.find(x => x.id === Number(fromReservationId)) if (r) { const update = computeMultiDayMove(r, day.id, phase); tripActions.updateReservation(tripId, r.id, update).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) } } else if (fromReservationId) { handleMergedDrop(day.id, 'transport', Number(fromReservationId), 'transport', transportId, isAfter, toLegIndex) } else if (assignmentId && fromDayId !== day.id) { tripActions.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) } else if (assignmentId) { handleMergedDrop(day.id, 'place', Number(assignmentId), 'transport', transportId, isAfter, toLegIndex) } else if (noteId && fromDayId !== day.id) { tripActions.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) } else if (noteId) { handleMergedDrop(day.id, 'note', Number(noteId), 'transport', transportId, isAfter, toLegIndex) } setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; window.__dragData = null return } if (fromReservationId && fromDayId !== day.id) { const r = reservations.find(x => x.id === Number(fromReservationId)) if (r) { const update = computeMultiDayMove(r, day.id, phase); tripActions.updateReservation(tripId, r.id, update).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) } setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return } if (!assignmentId && !noteId && !placeId) { dragDataRef.current = null; window.__dragData = null; return } if (placeId) { onAssignToDay?.(Number.parseInt(placeId), day.id) setDropTargetKey(null); window.__dragData = null; return } if (assignmentId && fromDayId !== day.id) { tripActions.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return } if (noteId && fromDayId !== day.id) { tripActions.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return } const m = getMergedItems(day.id) if (m.length === 0) return const lastItem = m[m.length - 1] if (assignmentId && String(lastItem?.data?.id) !== assignmentId) handleMergedDrop(day.id, 'place', Number(assignmentId), lastItem.type, lastItem.data.id, true) else if (noteId && String(lastItem?.data?.id) !== noteId) handleMergedDrop(day.id, 'note', Number(noteId), lastItem.type, lastItem.data.id, true) }} > {hotelLegs[day.id]?.top && (() => { const targetId = hotelLegs[day.id]?.top?.targetId const connector = return canEditDays && targetId != null ? (
openIncomingLegModeMenu(e, targetId, day.id, hotelLegs[day.id]!.top!.seg)} onKeyDown={e => openLegMenuByKey(e, m => openIncomingLegModeMenu(m, targetId, day.id, hotelLegs[day.id]!.top!.seg))} style={{ cursor: 'pointer' }} aria-label={t('dayplan.transportMode.change')}> {connector}
) : connector })()} {daySchedule.byPosition[day.id]?.start.map(si => )} {visibleCount === 0 && !dayNoteUi ? (
{ e.preventDefault(); if (dragOverDayId !== day.id) setDragOverDayId(day.id) }} onDrop={e => handleDropOnDay(e, day.id)} className={dragOverDayId === day.id ? 'bg-[rgba(17,24,39,0.05)]' : 'bg-transparent'} style={{ padding: '16px', textAlign: 'center', borderRadius: 8, border: dragOverDayId === day.id ? '2px dashed rgba(17,24,39,0.2)' : '2px dashed transparent', }} > {/* An empty day is where somebody wants to add something, so the slot offers to do it instead of only stating the fact. Without the handler (no edit rights) it stays the sentence. */} {onCreatePlaceForDay ? ( ) : ( {t('dayplan.emptyDay')} )}
) : ( merged.map((item, idx) => { const legSuffix = item.data?.__leg ? `-leg${item.data.__leg.index}` : '' const itemKey = item.type === 'transport' ? `transport-${item.data.id}${legSuffix}-${day.id}` : (item.type === 'place' ? `place-${item.data.id}` : `note-${item.data.id}`) const showDropLine = (!!draggingId || !!dropTargetKey) && dropTargetKey === itemKey const showDropLineAfter = item.type === 'transport' && (!!draggingId || !!dropTargetKey) && dropTargetKey === `transport-after-${item.data.id}${legSuffix}-${day.id}` if (item.type === 'place') { const assignment = item.data const place = assignment.place if (!place) return null const cat = categories.find(c => c.id === place.category_id) const isPlaceSelected = selectedAssignmentId ? assignment.id === selectedAssignmentId : place.id === selectedPlaceId const isDraggingThis = draggingId === assignment.id const placeIdx = placeItems.findIndex(i => i.data.id === assignment.id) const arrowMove = (direction: 'up' | 'down') => { const m = getMergedItems(day.id) const myIdx = m.findIndex(i => i.type === 'place' && i.data.id === assignment.id) if (myIdx === -1) return const targetIdx = direction === 'up' ? myIdx - 1 : myIdx + 1 if (targetIdx < 0 || targetIdx >= m.length) return // Build new order: swap this item with its neighbor in the merged list const newOrder = [...m] ;[newOrder[myIdx], newOrder[targetIdx]] = [newOrder[targetIdx], newOrder[myIdx]] // Check chronological order of all timed items in the new order const placeTime = place.place_time if (parseTimeToMinutes(placeTime) !== null) { const timedInNewOrder = newOrder .map(i => { if (i.type === 'transport') return parseTimeToMinutes(i.data?.reservation_time) if (i.type === 'place') return parseTimeToMinutes(i.data?.place?.place_time) return null }) .filter(t => t !== null) const isChronological = timedInNewOrder.every((t, i) => i === 0 || t >= timedInNewOrder[i - 1]) if (!isChronological) { const timeStr = placeTime.includes(':') ? placeTime.substring(0, 5) : placeTime // Describe the swap by its neighbour so the confirm step can // replay it against the merged list — a place-id projection // would drop moves across a booking or note. const neighbour = m[targetIdx] setTimeConfirm({ dayId: day.id, fromId: assignment.id, time: timeStr, fromType: 'place', toType: neighbour.type, toId: neighbour.data.id, insertAfter: direction === 'down', toLegIndex: neighbour.data?.__leg?.index ?? null, }) return } } applyMergedOrder(day.id, newOrder) } const moveUp = (e) => { e.stopPropagation(); arrowMove('up') } const moveDown = (e) => { e.stopPropagation(); arrowMove('down') } const selectPlace = () => { onPlaceClick(isPlaceSelected ? null : place.id, isPlaceSelected ? null : assignment.id); if (!isPlaceSelected) onSelectDay(day.id, true) } return (
{ if (!canEditDays || dragDisabled) { e.preventDefault(); return } e.dataTransfer.setData('assignmentId', String(assignment.id)) e.dataTransfer.setData('fromDayId', String(day.id)) e.dataTransfer.effectAllowed = 'move' dragDataRef.current = { assignmentId: String(assignment.id), fromDayId: String(day.id) } setDraggingId(assignment.id) }} onDragOver={e => { e.preventDefault(); e.stopPropagation(); setDragOverDayId(null); if (dropTargetKey !== `place-${assignment.id}`) setDropTargetKey(`place-${assignment.id}`) }} onDrop={e => { e.preventDefault(); e.stopPropagation() const { placeId, assignmentId: fromAssignmentId, noteId, reservationId: fromReservationId, fromDayId, phase } = getDragData(e) if (placeId) { const pos = placeItems.findIndex(i => i.data.id === assignment.id) onAssignToDay?.(Number.parseInt(placeId), day.id, pos >= 0 ? pos : undefined) setDropTargetKey(null); window.__dragData = null } else if (fromReservationId && fromDayId !== day.id) { const r = reservations.find(x => x.id === Number(fromReservationId)) if (r) { const update = computeMultiDayMove(r, day.id, phase); tripActions.updateReservation(tripId, r.id, update).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) } setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null } else if (fromReservationId) { handleMergedDrop(day.id, 'transport', Number(fromReservationId), 'place', assignment.id) } else if (fromAssignmentId && fromDayId !== day.id) { const toIdx = getDayAssignments(day.id).findIndex(a => a.id === assignment.id) tripActions.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id, toIdx).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null } else if (fromAssignmentId) { handleMergedDrop(day.id, 'place', Number(fromAssignmentId), 'place', assignment.id) } else if (noteId && fromDayId !== day.id) { const tm = getMergedItems(day.id) const toIdx = tm.findIndex(i => i.type === 'place' && i.data.id === assignment.id) const so = toIdx <= 0 ? (tm[0]?.sortKey ?? 0) - 1 : (tm[toIdx - 1].sortKey + tm[toIdx].sortKey) / 2 tripActions.moveDayNote(tripId, fromDayId, day.id, Number(noteId), so).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null } else if (noteId) { handleMergedDrop(day.id, 'note', Number(noteId), 'place', assignment.id) } }} ref={el => { // Auto-scroll the selected row into view — but only on // the transition "just became selected". Once we've // scrolled for this assignment id, we won't scroll // again until selection actually moves somewhere else. if (el && isPlaceSelected && selectionScrollKey != null && lastAutoScrolledIdRef.current !== selectionScrollKey) { const rect = el.getBoundingClientRect() const nearTop = rect.top < 80 const nearBottom = rect.bottom > window.innerHeight - 80 if (nearTop || nearBottom) { el.scrollIntoView({ behavior: 'smooth', block: 'center' }) } lastAutoScrolledIdRef.current = selectionScrollKey } }} onDragEnd={() => { setDraggingId(null); setDragOverDayId(null); setDropTargetKey(null); dragDataRef.current = null }} onClick={selectPlace} onKeyDown={e => { if (e.target !== e.currentTarget) return if (e.key !== 'Enter' && e.key !== ' ') return e.preventDefault() selectPlace() }} onContextMenu={e => { // Flat entries, one per app: the menu has room to // grow and a submenu would need a component that // does not exist here. const navTargets = getNavigationTargets(place) ctxMenu.open(e, [ canEditDays && onEditPlace && { label: t('common.edit'), icon: Pencil, onClick: () => onEditPlace(place, assignment.id) }, canEditDays && onRemoveAssignment && { label: t('planner.removeFromDay'), icon: Trash2, onClick: () => onRemoveAssignment(day.id, assignment.id) }, safeHttpUrl(place.website) && { label: t('inspector.website'), icon: ExternalLink, onClick: () => window.open(safeHttpUrl(place.website)!, '_blank', 'noopener,noreferrer') }, ...navTargets.map(target => ({ label: target.label, icon: Navigation, onClick: () => openNavigationTarget(target) })), collectionsEnabled && { label: t('inspector.saveToCollection'), icon: Bookmark, onClick: () => useSaveToCollectionStore.getState().open(placeToSaveTarget(place)) }, { divider: true }, canEditDays && onDeletePlace && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => onDeletePlace(place.id) }, ]) }} onMouseEnter={e => { if (!isPlaceSelected && !lockedIds.has(assignment.id)) e.currentTarget.style.background = 'var(--bg-hover)' const grip = e.currentTarget.querySelector('.dp-grip') as HTMLElement | null if (grip) grip.style.opacity = '1' setHoveredAssignmentId(assignment.id) }} onMouseLeave={e => { if (!isPlaceSelected && !lockedIds.has(assignment.id)) e.currentTarget.style.background = 'transparent' const grip = e.currentTarget.querySelector('.dp-grip') as HTMLElement | null if (grip) grip.style.opacity = '0.3' setHoveredAssignmentId(null) }} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '7px 8px 7px 10px', cursor: 'pointer', background: lockedIds.has(assignment.id) ? 'rgba(220,38,38,0.08)' : isPlaceSelected ? 'var(--bg-selected)' : 'transparent', borderLeft: lockedIds.has(assignment.id) ? '3px solid #dc2626' : '3px solid transparent', borderTop: showDropLine ? '2px solid var(--text-primary)' : undefined, transition: 'background 0.15s, border-color 0.15s', opacity: isDraggingThis ? 0.4 : 1, }} > {canEditDays && !dragDisabled &&
}
{cat && (() => { const CatIcon = getCategoryIcon(cat.icon) return })()} {place.name} {place.place_time && ( {formatTime(place.place_time, locale, timeFormat)}{place.end_time ? ` – ${formatTime(place.end_time, locale, timeFormat)}` : ''} )}
{(place.description || place.address || cat?.name) && (
{place.description || place.address || cat?.name || ''}
)} {assignment.notes && ( // Day-specific note on this stop (#2163) — one muted // caption line so the timeline shows the note exists // without swallowing the row.
{assignment.notes}
)} {(() => { const linked = getAssignmentReservations(reservations, assignment.id) if (linked.length === 0) return null // A stop can carry more than one booking (a parking pass and the // tickets for the same zoo), and every one of them is kept out of // the timeline, so this row is where they have to appear (#2201). // Stacked, because the chip lines are inline-flex and would // otherwise run together on one. return (
{linked.map(res => { const confirmed = res.status === 'confirmed' const hasEndpoints = onToggleConnection && (res.endpoints || []).length >= 2 const active = hasEndpoints ? visibleConnectionIds.includes(res.id) : false // The status, the time and the flight/train number used to sit in one // pill strung together on a middle dot, which read as one run-on // sentence. They are separate chips now: same tint so they still // belong together, own outline so the eye can take them one at a time. const RI = RES_ICONS[res.type] || Ticket const tint = confirmed ? 'bg-[rgba(22,163,74,0.1)] text-[#16a34a]' : 'bg-[rgba(217,119,6,0.1)] text-[#d97706]' const chip: React.CSSProperties = { display: 'inline-flex', alignItems: 'center', gap: 3, padding: '1px 6px', borderRadius: 5, fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 600, whiteSpace: 'nowrap', } const { time: st } = splitReservationDateTime(res.reservation_time) const { time: et } = splitReservationDateTime(res.reservation_end_time) const timeLabel = st || et ? `${st ? formatTime(st, locale, timeFormat) : ''}${et ? ` – ${formatTime(et, locale, timeFormat)}` : ''}` : '' let meta: any = {} try { meta = typeof res.metadata === 'string' ? JSON.parse(res.metadata || '{}') : (res.metadata || {}) } catch { meta = {} } const carrierLabel = meta ? (meta.airline && meta.flight_number ? `${meta.airline} ${meta.flight_number}` : meta.flight_number || meta.train_number || '') : '' return ( // No wrapping: the time belongs to the status it qualifies, so the // chips stay on one line even when the row gets narrow.
{confirmed ? t('planner.resConfirmed') : t('planner.resPending')}
{timeLabel && {timeLabel}} {carrierLabel && {carrierLabel}} {hasEndpoints && ( )} {canEditDays && (() => { const isTransport = TRANSPORT_TYPES.has(res.type) const handler = isTransport ? onEditTransport : onEditReservation if (!handler) return null return ( ) })()}
) })}
) })()} {assignment.participants?.length > 0 && (
{assignment.participants.slice(0, 5).map((p, pi) => (
0 ? -4 : 0, flexShrink: 0, overflow: 'hidden', }}> {p.avatar ? {p.username : p.username?.[0]?.toUpperCase()}
))} {assignment.participants.length > 5 && ( +{assignment.participants.length - 5} )}
)}
{canEditDays &&
} {canEditDays && onAddBookingToAssignment && hoveredAssignmentId === assignment.id && ( )}
{daySchedule.byAssignment[day.id]?.[assignment.id]?.map(si => )} {routeLegs[day.id]?.[assignment.id] && (canEditDays ? (
openLegModeMenu(e, assignment.id, day.id, routeLegs[day.id]![assignment.id])} onKeyDown={e => openLegMenuByKey(e, m => openLegModeMenu(m, assignment.id, day.id, routeLegs[day.id]![assignment.id]))} style={{ cursor: 'pointer' }} aria-label={t('dayplan.transportMode.change')}>
) : ( ))}
) } // Transport booking (flight, train, bus, car, cruise) if (item.type === 'transport') { const res = item.data const spanPhase = getSpanPhase(res, day.id) // Car "active" (middle) days are shown in the day header, skip here if (res.type === 'car' && spanPhase === 'middle') return null // A multi-day parking says nothing on the days in between and gets // no header badge either, only drop-off and pickup (#1937) if (hidesOnMiddleDay(res, day.id)) return null const TransportIcon = RES_ICONS[res.type] || Ticket const color = '#3b82f6' let meta: any = {} try { meta = typeof res.metadata === 'string' ? JSON.parse(res.metadata || '{}') : (res.metadata || {}) } catch { meta = {} } // Subtitle aus Metadaten zusammensetzen let subtitle = '' if (res.__leg) { // One leg of a multi-leg flight/train — show this segment's own detail. const parts = res.type === 'train' ? [res.__leg.train_number, res.__leg.platform ? `Gl. ${res.__leg.platform}` : '', res.__leg.seat ? `Sitz ${res.__leg.seat}` : ''].filter(Boolean) : [res.__leg.airline, res.__leg.flight_number].filter(Boolean) if (res.__leg.from || res.__leg.to) parts.push([res.__leg.from, res.__leg.to].filter(Boolean).join(' → ')) subtitle = parts.join(' · ') } else if (res.type === 'flight') { const parts = [meta.airline, meta.flight_number].filter(Boolean) if (meta.departure_airport || meta.arrival_airport) parts.push([meta.departure_airport, meta.arrival_airport].filter(Boolean).join(' → ')) subtitle = parts.join(' · ') } else if (res.type === 'train') { subtitle = [meta.train_number, meta.platform ? `Gl. ${meta.platform}` : '', meta.seat ? `Sitz ${meta.seat}` : ''].filter(Boolean).join(' · ') } // A transit journey (#1065) renders its itinerary inline — // line badges in their colors instead of a plain subtitle, // so the connection is recognisable at a glance. const transitMeta = res.type === 'transit' && meta.transit && Array.isArray(meta.transit.legs) ? meta.transit : null // Multi-day span phase (single-leg / non-flight only — a // multi-leg flight is shown as one row per leg, see below). const spanLabel = res.__leg ? null : getSpanLabel(res, spanPhase) const displayTime = getDisplayTimeForDay(res, day.id) const legKey = res.__leg ? `leg${res.__leg.index}` : 'x' const openTransportRow = () => { const target = reservations.find(x => x.id === res.id) ?? res // A transit journey opens its own journey view — the rich // stop-by-stop breakdown with its booking fields, never the // generic edit form (#1065). if (transitMeta) { if (onOpenTransit) onOpenTransit(target) else setTransportDetail(target) return } if (!canEditDays) return if (TRANSPORT_TYPES.has(res.type)) onEditTransport?.(target) else onEditReservation?.(target) } return (
{ if (e.target !== e.currentTarget) return if (e.key !== 'Enter' && e.key !== ' ') return e.preventDefault() openTransportRow() }} onDragOver={e => { e.preventDefault(); e.stopPropagation() const rect = e.currentTarget.getBoundingClientRect() const inBottom = e.clientY > rect.top + rect.height / 2 const ls = res.__leg ? `-leg${res.__leg.index}` : '' const key = inBottom ? `transport-after-${res.id}${ls}-${day.id}` : `transport-${res.id}${ls}-${day.id}` if (dropTargetRef.current !== key) setDropTargetKey(key) }} draggable={canEditDays && spanPhase !== 'middle' && !res.__leg && !dragDisabled} onDragStart={e => { if (!canEditDays || spanPhase === 'middle' || res.__leg || dragDisabled) { e.preventDefault(); return } // setData is required for the drag to start reliably (Firefox) and // matches how place/note items initiate their drag. e.dataTransfer.setData('reservationId', String(res.id)) e.dataTransfer.setData('fromDayId', String(day.id)) e.dataTransfer.effectAllowed = 'move' dragDataRef.current = { reservationId: String(res.id), fromDayId: String(day.id), phase: spanPhase } setDraggingId(res.id) }} onDragEnd={() => { setDraggingId(null); setDragOverDayId(null); setDropTargetKey(null); dragDataRef.current = null }} onDrop={e => { e.preventDefault(); e.stopPropagation() const rect = e.currentTarget.getBoundingClientRect() const insertAfter = e.clientY > rect.top + rect.height / 2 const { placeId, assignmentId: fromAssignmentId, noteId, reservationId: fromReservationId, fromDayId, phase } = getDragData(e) if (placeId) { onAssignToDay?.(Number.parseInt(placeId), day.id) } else if (fromReservationId && fromDayId !== day.id) { const r2 = reservations.find(x => x.id === Number(fromReservationId)) if (r2) { const update = computeMultiDayMove(r2, day.id, phase); tripActions.updateReservation(tripId, r2.id, update).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) } } else if (fromReservationId) { handleMergedDrop(day.id, 'transport', Number(fromReservationId), 'transport', res.id, insertAfter, res.__leg?.index ?? null) } else if (fromAssignmentId && fromDayId !== day.id) { tripActions.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) } else if (fromAssignmentId) { handleMergedDrop(day.id, 'place', Number(fromAssignmentId), 'transport', res.id, insertAfter, res.__leg?.index ?? null) } else if (noteId && fromDayId !== day.id) { tripActions.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) } else if (noteId) { handleMergedDrop(day.id, 'note', Number(noteId), 'transport', res.id, insertAfter, res.__leg?.index ?? null) } setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; window.__dragData = null }} onMouseEnter={e => { e.currentTarget.style.background = `${color}12` }} onMouseLeave={e => { e.currentTarget.style.background = `${color}08` }} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '7px 8px 7px 10px', margin: '1px 8px', borderRadius: 6, border: `1px solid ${color}33`, borderTop: showDropLine ? '2px solid var(--text-primary)' : undefined, borderBottom: showDropLineAfter ? '2px solid var(--text-primary)' : undefined, background: `${color}08`, cursor: (transitMeta || (canEditDays && onEditTransport)) ? 'pointer' : 'default', userSelect: 'none', transition: 'background 0.1s', opacity: draggingId === res.id ? 0.4 : spanPhase === 'middle' ? 0.65 : 1, }} > {canEditDays && spanPhase !== 'middle' && !res.__leg && !dragDisabled && (
)}
{spanLabel && ( {spanLabel} )} {transitMeta ? : res.title} {(() => { const { time: dispTime } = splitReservationDateTime(displayTime) const { time: endTime } = splitReservationDateTime(res.reservation_end_time) if (!dispTime && !endTime) return null return ( {dispTime ? formatTime(dispTime, locale, timeFormat) : ''} {spanPhase === 'single' && endTime ? ` – ${formatTime(endTime, locale, timeFormat)}` : ''} {meta.departure_timezone && spanPhase === 'start' && ` ${meta.departure_timezone}`} {meta.arrival_timezone && spanPhase === 'end' && ` ${meta.arrival_timezone}`} ) })()}
{transitMeta ? (
) : subtitle && (
{subtitle}
)}
{transitMeta && (() => { const expanded = expandedTransitIds.has(res.id) return ( ) })()} {!transitMeta && onToggleConnection && (!res.__leg || res.__leg.index === 0) && (res.endpoints || []).length >= 2 && (() => { const active = visibleConnectionIds.includes(res.id) return ( ) })()}
{transitMeta && expandedTransitIds.has(res.id) && (
)} {daySchedule.byReservation[day.id]?.[res.id]?.map(si => )} {routeLegs[day.id]?.[res.id] && (() => { const nextPlaceId = merged.slice(idx + 1).find(i => i.type === 'place' && i.data.place?.lat && i.data.place?.lng)?.data.id const connector = return canEditDays && nextPlaceId != null ? (
openIncomingLegModeMenu(e, Number(nextPlaceId), day.id, routeLegs[day.id]![res.id])} onKeyDown={e => openLegMenuByKey(e, m => openIncomingLegModeMenu(m, Number(nextPlaceId), day.id, routeLegs[day.id]![res.id]))} style={{ cursor: 'pointer' }} aria-label={t('dayplan.transportMode.change')}> {connector}
) : connector })()}
) } // Notizkarte const note = item.data const NoteIcon = getNoteIcon(note.icon) const noteSkin = noteSurface(note.color) const noteIdx = idx return (
{ if (!canEditDays || dragDisabled) { e.preventDefault(); return } e.dataTransfer.setData('noteId', String(note.id)); e.dataTransfer.setData('fromDayId', String(day.id)); e.dataTransfer.effectAllowed = 'move'; dragDataRef.current = { noteId: String(note.id), fromDayId: String(day.id) }; setDraggingId(`note-${note.id}`) }} onDragEnd={() => { setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null }} onDragOver={e => { e.preventDefault(); e.stopPropagation(); if (dropTargetKey !== `note-${note.id}`) setDropTargetKey(`note-${note.id}`) }} onDrop={e => { e.preventDefault(); e.stopPropagation() const { placeId, noteId: fromNoteId, assignmentId: fromAssignmentId, reservationId: fromReservationId, fromDayId, phase } = getDragData(e) if (placeId) { // New place dropped onto a note: insert it among the // assignments at the note's position (after the places // above it), so it lands right where the note sits. const tm = getMergedItems(day.id) const noteIdx = tm.findIndex(i => i.type === 'note' && i.data.id === note.id) const pos = tm.slice(0, noteIdx).filter(i => i.type === 'place').length onAssignToDay?.(Number.parseInt(placeId), day.id, pos) setDropTargetKey(null); window.__dragData = null } else if (fromReservationId && fromDayId !== day.id) { const r = reservations.find(x => x.id === Number(fromReservationId)) if (r) { const update = computeMultiDayMove(r, day.id, phase); tripActions.updateReservation(tripId, r.id, update).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) } setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null } else if (fromReservationId) { handleMergedDrop(day.id, 'transport', Number(fromReservationId), 'note', note.id) } else if (fromNoteId && fromDayId !== day.id) { const tm = getMergedItems(day.id) const toIdx = tm.findIndex(i => i.type === 'note' && i.data.id === note.id) const so = toIdx <= 0 ? (tm[0]?.sortKey ?? 0) - 1 : (tm[toIdx - 1].sortKey + tm[toIdx].sortKey) / 2 tripActions.moveDayNote(tripId, fromDayId, day.id, Number(fromNoteId), so).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) setDraggingId(null); setDropTargetKey(null) } else if (fromNoteId && fromNoteId !== String(note.id)) { handleMergedDrop(day.id, 'note', Number(fromNoteId), 'note', note.id) } else if (fromAssignmentId && fromDayId !== day.id) { const tm = getMergedItems(day.id) const noteIdx = tm.findIndex(i => i.type === 'note' && i.data.id === note.id) const toIdx = tm.slice(0, noteIdx).filter(i => i.type === 'place').length tripActions.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id, toIdx).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) setDraggingId(null); setDropTargetKey(null) } else if (fromAssignmentId) { handleMergedDrop(day.id, 'place', Number(fromAssignmentId), 'note', note.id) } }} onContextMenu={canEditDays ? e => ctxMenu.open(e, [ { label: t('common.edit'), icon: Pencil, onClick: () => openEditNote(day.id, note) }, { divider: true }, { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => setPendingDeleteNote({ dayId: day.id, noteId: note.id }) }, ]) : undefined} {...(canEditDays ? { role: 'button' as const, // No press-scale: index.css shrinks every [role=button] on // :active, which on a draggable row fights the drag (#2158). 'data-no-press': '', tabIndex: 0, // The row is the note's edit affordance — the same gesture the // phone shell already uses (#2249). Links inside the rendered // body and the reorder buttons keep their own click. onClick: (e: React.MouseEvent) => { if ((e.target as HTMLElement).closest('a, button')) return openEditNote(day.id, note) }, onKeyDown: (e: React.KeyboardEvent) => { if (e.target !== e.currentTarget) return if (e.key !== 'Enter' && e.key !== ' ') return e.preventDefault() openEditNote(day.id, note) }, } : {})} onMouseEnter={e => { const grip = e.currentTarget.querySelector('.dp-grip') as HTMLElement | null if (grip) grip.style.opacity = '1' }} onMouseLeave={e => { const grip = e.currentTarget.querySelector('.dp-grip') as HTMLElement | null if (grip) grip.style.opacity = '0.3' }} style={{ position: 'relative', display: 'flex', alignItems: 'center', gap: 8, padding: '7px 8px 7px 2px', margin: '1px 8px', borderRadius: 6, border: `1px solid ${noteSkin.border}`, borderTop: showDropLine ? '2px solid var(--text-primary)' : undefined, background: noteSkin.background, opacity: draggingId === `note-${note.id}` ? 0.4 : 1, transition: 'background 0.1s', cursor: canEditDays ? 'pointer' : 'default', userSelect: 'none', }} > {canEditDays && !dragDisabled &&
}
{note.text} {note.time && (
{/* A link in a note goes to its own tab, and remarkBreaks keeps a single newline a line break — people write notes as lines, not as Markdown paragraphs. */} {note.time}
)}
{canEditDays &&
}
) }) )} {daySchedule.byPosition[day.id]?.end.map(si => )} {hotelLegs[day.id]?.bottom && (() => { const targetId = hotelLegs[day.id]?.bottom?.targetId const connector = return canEditDays && targetId != null ? (
openLegModeMenu(e, targetId, day.id, hotelLegs[day.id]!.bottom!.seg)} onKeyDown={e => openLegMenuByKey(e, m => openLegModeMenu(m, targetId, day.id, hotelLegs[day.id]!.bottom!.seg))} style={{ cursor: 'pointer' }} aria-label={t('dayplan.transportMode.change')}> {connector}
) : connector })()} {/* Drop-Zone am Listenende — immer vorhanden als Drop-Target */}
{ e.preventDefault(); e.stopPropagation(); if (dropTargetKey !== `end-${day.id}`) setDropTargetKey(`end-${day.id}`) }} onDrop={e => { e.preventDefault(); e.stopPropagation() const { placeId, assignmentId, noteId, reservationId: fromReservationId, fromDayId, phase } = getDragData(e) // Neuer Ort von der Orte-Liste if (placeId) { onAssignToDay?.(Number.parseInt(placeId), day.id) setDropTargetKey(null); window.__dragData = null; return } if (fromReservationId && fromDayId !== day.id) { const r = reservations.find(x => x.id === Number(fromReservationId)) if (r) { const update = computeMultiDayMove(r, day.id, phase); tripActions.updateReservation(tripId, r.id, update).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) } setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; window.__dragData = null; return } if (!assignmentId && !noteId && !fromReservationId) { dragDataRef.current = null; window.__dragData = null; return } if (assignmentId && fromDayId !== day.id) { tripActions.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return } if (noteId && fromDayId !== day.id) { tripActions.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return } const m = getMergedItems(day.id) if (m.length === 0) return const lastItem = m[m.length - 1] if (assignmentId && String(lastItem?.data?.id) !== assignmentId) handleMergedDrop(day.id, 'place', Number(assignmentId), lastItem.type, lastItem.data.id, true) else if (noteId && String(lastItem?.data?.id) !== noteId) handleMergedDrop(day.id, 'note', Number(noteId), lastItem.type, lastItem.data.id, true) else if (fromReservationId && String(lastItem?.data?.id) !== fromReservationId) handleMergedDrop(day.id, 'transport', Number(fromReservationId), lastItem.type, lastItem.data.id, true) setDropTargetKey(null); dragDataRef.current = null; window.__dragData = null }} > {dropTargetKey === `end-${day.id}` && (
)}
{/* Routen-Werkzeuge (ausgewählter Tag, 2+ Orte — oder 1 Ort mit Hotel-Bookend #1330 — oder Hotel-zu-Hotel-Transfertag ohne Orte #1297) */} {(isSelected || (showRouteToolsWhenExpanded && isExpanded)) && routeToolsRoutable && (
{/* Open the day's stops as a route in Google Maps (planned order). #1255 */} {/* The same day, handed to CoMaps for offline navigation (#1904). The day's own travel mode rides along, so the route it builds walks when the plan walks. */} {/* Icon-only, like the two map hand-offs beside it (#1981). It was the one button here carrying a label with no room for it: `flex: 1` alongside `padding: '6px 0'` meant the text sat against both edges as soon as the row filled up, and the row gained a button when CoMaps arrived. The label stays as the accessible name and the tooltip. */}
{routeProfileOptions.map(p => { const ModeIcon = p.key === 'driving' ? Car : p.key === 'walking' ? Footprints : Zap const active = (day.default_transport_mode ?? routeProfile) === p.key return ( ) })}
{/* Time plugins contributed to this day (charging, buffers) — the dayScheduleProvider minutes folded into the footer total. */} {isSelected && daySchedule.minutesByDay[day.id] ? (
+{formatScheduleMinutes(daySchedule.minutesByDay[day.id])}
) : null}
)} {/* Mobile: Add Place from list */}
)}
) })}
{/* Notiz-Popup-Modal — über Portal gerendert, um den backdropFilter-Stapelkontext zu umgehen */} setPendingDeleteNote({ dayId, noteId })} t={t} /> {/* Confirm: remove time when reordering a timed place */} {/* Confirm: delete a day note — guards against accidental taps on touch devices */} setPendingDeleteNote(null)} onConfirm={() => { if (!pendingDeleteNote) return // Close the edit modal behind the confirm; leaving it open would go on // editing a note that no longer exists. cancelNote(pendingDeleteNote.dayId) deleteNote(pendingDeleteNote.dayId, pendingDeleteNote.noteId) }} title={t('dayplan.confirmDeleteNoteTitle')} message={t('dayplan.confirmDeleteNoteBody')} /> {/* Transport-Detail-Modal */} { setTransportDetail(null); onEditTransport(res) } : undefined} t={t} locale={locale} timeFormat={timeFormat} /> {/* Budget-Fußzeile */}
) }) export default DayPlanSidebar