{mobileSidebarOpen === 'left'
?
{ handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId) }} onReorder={handleReorder} onReorderDays={handleReorderDays} onAddDay={handleAddDay} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute([r.coordinates]); setRouteInfo(r) } else { setRoute(null); setRouteInfo(null) } }} reservations={reservations} visibleConnectionIds={visibleConnections} onToggleConnection={toggleConnection} allConnectionsShown={allConnectionsShown} onToggleAllConnections={toggleAllConnections} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddTransport={can('day_edit', trip) ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill(null); setTransportModalAutomated(false); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onOpenTransit={(r) => { setTransitJourney(r); setMobileSidebarOpen(null) }} onPlanTransit={can('day_edit', trip) && tripHasDates ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill(null); setTransportModalAutomated(true); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onPlanTransitLeg={can('day_edit', trip) && tripHasDates ? ({ dayId, from, to, time }) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill({ from, to, time }); setTransportModalAutomated(true); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null) }} onRemoveAssignment={handleRemoveAssignment} onEditPlace={(place, assignmentId) => { setEditingPlace(place); setEditingAssignmentId(assignmentId || null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} accommodations={tripAccommodations} routeShown={routeShown} routeProfile={routeProfile} onToggleRoute={() => setRouteShown(v => !v)} onSetRouteProfile={setRouteProfile} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true); setMobileSidebarOpen(null) } : undefined} initialScrollTop={mobilePlanScrollTopRef.current} onScrollTopChange={(top) => { mobilePlanScrollTopRef.current = top }} showRouteToolsWhenExpanded isMobile />
- : { handlePlaceClick(placeId); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} onEditPlace={(place) => { openPlaceEditor(place); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} onBulkDeletePlaces={(ids) => setDeletePlaceIds(ids)} onBulkDeleteConfirm={(ids) => confirmDeletePlaces(ids)} onBulkChangeCategory={(ids, catId) => confirmChangeCategory(ids, catId)} days={days} isMobile pushUndo={pushUndo} initialScrollTop={mobilePlacesScrollTopRef.current} onScrollTopChange={(top) => { mobilePlacesScrollTopRef.current = top }} />
+ : { handlePlaceClick(placeId); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} onEditPlace={(place) => { openPlaceEditor(place); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} onBulkDeletePlaces={(ids) => setDeletePlaceIds(ids)} onBulkDeleteConfirm={(ids) => confirmDeletePlaces(ids)} onBulkChangeCategory={(ids, catId) => confirmChangeCategory(ids, catId)} days={days} isMobile pushUndo={pushUndo} initialScrollTop={mobilePlacesScrollTopRef.current} onScrollTopChange={(top) => { mobilePlacesScrollTopRef.current = top }} />
}
diff --git a/client/src/pages/tripPlanner/useTripPlanner.ts b/client/src/pages/tripPlanner/useTripPlanner.ts
index 93172a5a1..5997543d5 100644
--- a/client/src/pages/tripPlanner/useTripPlanner.ts
+++ b/client/src/pages/tripPlanner/useTripPlanner.ts
@@ -35,6 +35,7 @@ import {
toggleConnectionId, toggleAllConnections as flipAllConnectionsMode,
type StoredConnections,
} from '../../utils/connectionsVisibility'
+import { plannedPlaceIds, plannedPlaceIdsForDay } from '../../utils/plannedPlaces'
/**
* Trip planner page logic — the big one. Owns the trip store wiring, addon
@@ -445,10 +446,9 @@ export function useTripPlanner() {
// the whole plan (#2024). 'unplanned' always uses the whole-trip set — a place
// assigned to any day is not unplanned.
const plannedIds = placesFilter === 'unplanned' || placesFilter === 'planned'
- ? new Set((placesFilter === 'planned' && selectedDayId
- ? (assignments[String(selectedDayId)] || [])
- : Object.values(assignments).flat()
- ).map(a => a.place?.id).filter(Boolean))
+ ? (placesFilter === 'planned' && selectedDayId
+ ? plannedPlaceIdsForDay(selectedDayId, days, { assignments, accommodations: tripAccommodations, reservations })
+ : plannedPlaceIds({ assignments, accommodations: tripAccommodations, reservations }))
: null
return places.filter(p => {
@@ -467,7 +467,7 @@ export function useTripPlanner() {
if (placesFilter === 'planned' && plannedIds && !plannedIds.has(p.id)) return false
return true
})
- }, [places, placesCategoryFilter, placesFilter, assignments, expandedDayIds, selectedDayId])
+ }, [places, placesCategoryFilter, placesFilter, assignments, expandedDayIds, selectedDayId, days, tripAccommodations, reservations])
const { route, routeSegments, routeVias, routeInfo, setRoute, setRouteInfo, updateRouteForDay } = useRouteCalculation({ assignments } as any, selectedDayId, routeShown, routeProfile, tripAccommodations)
diff --git a/client/src/utils/dayMerge.ts b/client/src/utils/dayMerge.ts
index 302925694..d8b5dbb5a 100644
--- a/client/src/utils/dayMerge.ts
+++ b/client/src/utils/dayMerge.ts
@@ -1,3 +1,8 @@
+// `orderedEndpoints` is the geometry order's single source of truth, in the module
+// that documents the multi-leg model. Sorting endpoints a second time here is how
+// the two drift.
+import { orderedEndpoints } from './flightLegs'
+
export const TRANSPORT_TYPES = new Set(['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transit', 'transport_other'])
export interface MergedItem {
@@ -60,9 +65,23 @@ export function getTransportRouteEndpoints(
r: any,
dayId: number
): { from: { lat: number; lng: number } | null; to: { lat: number; lng: number } | null } {
+ // A leg of a multi-leg booking spans its OWN pair of airports, endpoints[i] and
+ // endpoints[i+1] — not the booking's outermost two. Handing back the booking's
+ // from/to for every leg put a stop between two legs on a road route from the
+ // arrival airport back to the departure one (#2071).
+ //
+ // Only when the endpoints line up one-per-stop with the legs. An endpoint that
+ // failed to geocode never reaches the row, and a short list would silently map
+ // every earlier leg to the wrong pair — so an ambiguous booking keeps the role
+ // lookup it has always used.
+ const ordered = orderedEndpoints(r)
+ const legIdx: number | null =
+ r.__leg && ordered.length === r.__leg.total + 1 ? (r.__leg.index as number) : null
+ const point = (e: { lat?: number | null; lng?: number | null } | undefined): { lat: number; lng: number } | null =>
+ e && e.lat != null && e.lng != null ? { lat: e.lat, lng: e.lng } : null
const ep = (role: 'from' | 'to'): { lat: number; lng: number } | null => {
- const e = (r.endpoints || []).find((x: any) => x.role === role)
- return e && e.lat != null && e.lng != null ? { lat: e.lat, lng: e.lng } : null
+ if (legIdx != null) return point(ordered[role === 'from' ? legIdx : legIdx + 1])
+ return point((r.endpoints || []).find((x: { role?: string }) => x.role === role))
}
switch (getSpanPhase(r, dayId)) {
case 'start':
@@ -240,8 +259,13 @@ export function getMergedItems(opts: {
const timed = timedTransports[ti]
const minutes = timed.minutes
- // Per-day position takes precedence (set by user reorder)
- const perDayPos = timed.data.day_positions?.[dayId] ?? timed.data.day_positions?.[String(dayId)]
+ // Per-day position takes precedence (set by user reorder), then the booking's
+ // own slot. The map's waypoint builder has always read day_plan_position and
+ // this never did, so a booking carrying only that value sat between the places
+ // on the map and at the bottom of the day everywhere else (#2071).
+ const perDayPos = timed.data.day_positions?.[dayId]
+ ?? timed.data.day_positions?.[String(dayId)]
+ ?? timed.data.day_plan_position
if (perDayPos != null) {
result.push({ type: timed.type, sortKey: perDayPos, data: timed.data })
continue
diff --git a/client/src/utils/plannedPlaces.test.ts b/client/src/utils/plannedPlaces.test.ts
new file mode 100644
index 000000000..a0c786868
--- /dev/null
+++ b/client/src/utils/plannedPlaces.test.ts
@@ -0,0 +1,103 @@
+import { describe, it, expect } from 'vitest'
+import { plannedPlaceIds, plannedPlaceIdsForDay } from './plannedPlaces'
+import type { AssignmentsMap, Day } from '../types'
+
+// FE-UTIL-PLANNED-001 to FE-UTIL-PLANNED-010
+//
+// #2072 — a hotel linked to a booking sat under "Unplanned" while its name was
+// printed in the day header, because being planned meant one thing only: a
+// day_assignments row.
+
+const assignments = (map: Record