From 44d8e7234890e538194b12a2a5e7a8e6003cd2c9 Mon Sep 17 00:00:00 2001 From: Maurice Date: Thu, 11 Jun 2026 15:39:05 +0200 Subject: [PATCH] feat(planner): show each flight leg as its own day-plan entry, ordered by time A multi-leg flight now expands into one entry per leg (BER -> FRA, then FRA -> HND), each on its own day with its own times, instead of a single span. Each leg is an addressable slot (reservation id + leg index) so places and notes can be dropped into the layover gap between legs; the per-leg position is persisted in metadata.legs[i].day_positions and survives a reload. Day-plan items are now ordered chronologically: anything with a time (a place's time, a flight leg, a timed note) sorts by that time, and untimed items inherit the time of the item before them so they stay where they were placed. --- .../src/components/Planner/DayPlanSidebar.tsx | 107 +++++++++++++----- client/src/utils/dayMerge.test.ts | 8 +- client/src/utils/dayMerge.ts | 91 ++++++++++++++- 3 files changed, 167 insertions(+), 39 deletions(-) diff --git a/client/src/components/Planner/DayPlanSidebar.tsx b/client/src/components/Planner/DayPlanSidebar.tsx index 342ffdba..8f04e638 100644 --- a/client/src/components/Planner/DayPlanSidebar.tsx +++ b/client/src/components/Planner/DayPlanSidebar.tsx @@ -171,7 +171,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) { const [timeConfirm, setTimeConfirm] = useState<{ dayId: number; fromId: number; time: string; // For drag & drop reorder - fromType?: string; toType?: string; toId?: number; insertAfter?: boolean; + fromType?: string; toType?: string; toId?: number; insertAfter?: boolean; toLegIndex?: number | null; // For arrow reorder reorderIds?: number[]; } | null>(null) @@ -471,6 +471,9 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) { 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 @@ -491,7 +494,10 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) { 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') transportUpdates.push({ id: g.data.id, day_plan_position: 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 }) + } }) } } @@ -510,6 +516,30 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) { })) 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 } + useTripStore.setState(state => ({ reservations: state.reservations.map(x => (x.id === rid ? { ...x, metadata: newMeta } : x)) })) + await tripActions.updateReservation(tripId, rid, { metadata: newMeta }) + } + setTransportPosVersion(v => v + 1) + } if (assignmentIds.length) await onReorder(dayId, assignmentIds) if (transportUpdates.length) { onRouteRefresh?.() @@ -528,8 +558,11 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) { } catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) } } - const handleMergedDrop = async (dayId, fromType, fromId, toType, toId, insertAfter = false) => { + 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') { @@ -537,11 +570,11 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) { 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(i => i.type === toType && i.data.id === toId) + const toIdx = m.findIndex(matchTo) if (fromIdx !== -1 && toIdx !== -1) { const simulated = [...m] const [moved] = simulated.splice(fromIdx, 1) - let insertIdx = simulated.findIndex(i => i.type === toType && i.data.id === toId) + let insertIdx = simulated.findIndex(matchTo) if (insertIdx === -1) insertIdx = simulated.length if (insertAfter) insertIdx += 1 simulated.splice(insertIdx, 0, moved) @@ -558,7 +591,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) { 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, time: timeStr }) + setTimeConfirm({ dayId, fromType, fromId, toType, toId, insertAfter, toLegIndex, time: timeStr }) setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null return } @@ -568,7 +601,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) { // 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(i => i.type === toType && i.data.id === toId) + const toIdx = m.findIndex(matchTo) if (fromIdx === -1 || toIdx === -1 || fromIdx === toIdx) { setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null return @@ -576,7 +609,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) { const newOrder = [...m] const [moved] = newOrder.splice(fromIdx, 1) - let adjustedTo = newOrder.findIndex(i => i.type === toType && i.data.id === toId) + let adjustedTo = newOrder.findIndex(matchTo) if (adjustedTo === -1) adjustedTo = newOrder.length if (insertAfter) adjustedTo += 1 newOrder.splice(adjustedTo, 0, moved) @@ -590,7 +623,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) { const confirmTimeRemoval = async () => { if (!timeConfirm) return const saved = { ...timeConfirm } - const { dayId, fromId, reorderIds, fromType, toType, toId, insertAfter } = saved + const { dayId, fromId, reorderIds, fromType, toType, toId, insertAfter, toLegIndex } = saved setTimeConfirm(null) // Remove time from assignment @@ -633,13 +666,14 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) { // Drag & drop reorder 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(i => i.type === toType && i.data.id === toId) + 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(i => i.type === toType && i.data.id === toId) + let adjustedTo = newOrder.findIndex(matchTo) if (adjustedTo === -1) adjustedTo = newOrder.length if (insertAfter) adjustedTo += 1 newOrder.splice(adjustedTo, 0, moved) @@ -1311,6 +1345,8 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP 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?.(parseInt(placeId), day.id) @@ -1318,15 +1354,15 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP 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) + 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) + 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) + handleMergedDrop(day.id, 'note', Number(noteId), 'transport', transportId, isAfter, toLegIndex) } setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; window.__dragData = null return @@ -1372,9 +1408,10 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP ) : ( merged.map((item, idx) => { - const itemKey = item.type === 'transport' ? `transport-${item.data.id}-${day.id}` : (item.type === 'place' ? `place-${item.data.id}` : `note-${item.data.id}`) + 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}-${day.id}` + const showDropLineAfter = item.type === 'transport' && (!!draggingId || !!dropTargetKey) && dropTargetKey === `transport-after-${item.data.id}${legSuffix}-${day.id}` if (item.type === 'place') { const assignment = item.data @@ -1722,7 +1759,13 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP // Subtitle aus Metadaten zusammensetzen let subtitle = '' - if (res.type === 'flight') { + if (res.__leg) { + // One leg of a multi-leg flight — show this segment's own route. + const parts = [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(' → ')) @@ -1731,28 +1774,32 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP subtitle = [meta.train_number, meta.platform ? `Gl. ${meta.platform}` : '', meta.seat ? `Sitz ${meta.seat}` : ''].filter(Boolean).join(' · ') } - // Multi-day span phase - const spanLabel = getSpanLabel(res, spanPhase) + // 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' return ( - +
{ if (!canEditDays) return - if (TRANSPORT_TYPES.has(res.type)) onEditTransport?.(res) - else onEditReservation?.(res) + const target = reservations.find(x => x.id === res.id) ?? res + if (TRANSPORT_TYPES.has(res.type)) onEditTransport?.(target) + else onEditReservation?.(target) }} onDragOver={e => { e.preventDefault(); e.stopPropagation() const rect = e.currentTarget.getBoundingClientRect() const inBottom = e.clientY > rect.top + rect.height / 2 - const key = inBottom ? `transport-after-${res.id}-${day.id}` : `transport-${res.id}-${day.id}` + 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'} + draggable={canEditDays && spanPhase !== 'middle' && !res.__leg} onDragStart={e => { - if (!canEditDays || spanPhase === 'middle') { e.preventDefault(); return } + if (!canEditDays || spanPhase === 'middle' || res.__leg) { 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)) @@ -1773,15 +1820,15 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP 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) + 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) + 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) + handleMergedDrop(day.id, 'note', Number(noteId), 'transport', res.id, insertAfter, res.__leg?.index ?? null) } setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; window.__dragData = null }} @@ -1801,7 +1848,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP opacity: draggingId === res.id ? 0.4 : spanPhase === 'middle' ? 0.65 : 1, }} > - {canEditDays && spanPhase !== 'middle' && ( + {canEditDays && spanPhase !== 'middle' && !res.__leg && (
@@ -1846,7 +1893,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
)} - {onToggleConnection && (res.endpoints || []).length >= 2 && (() => { + {onToggleConnection && (!res.__leg || res.__leg.index === 0) && (res.endpoints || []).length >= 2 && (() => { const active = visibleConnectionIds.includes(res.id) return (