mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-21 14:21:46 +00:00
Support multi-leg (layover) flights (#1146)
* feat(transport): support multi-leg (layover) flights in the booking form
A flight booking can now hold an ordered chain of airports (e.g. FRA -> BER ->
HND) instead of a single departure/arrival pair. The route is entered as a list
of waypoints with a '+ add stop' button; each stop carries its own arrival and
departure time plus the airline/flight number of the segment leaving it, while
the whole booking keeps one price.
Stored without a schema change: the existing reservation_endpoints rows carry the
ordered waypoints (from/stop/to by sequence) and a metadata.legs array holds the
per-leg detail. Top-level metadata (departure_airport/arrival_airport/airline/
flight_number) mirrors the first and last leg, so a single-leg flight persists
exactly as before and legacy readers keep working.
* 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.
* feat(planner): show the full multi-stop route in the bookings panel
The route row now lists every waypoint (FRA -> BER -> HND) by sequence instead of
just the first and last airport.
* feat(map): draw multi-leg flights as connected legs with a marker per airport
Both the Leaflet and Mapbox overlays now render a flight over all its waypoints:
one great-circle arc per leg and a marker at every airport, with the label
showing the full route and the summed distance. A single-leg flight is unchanged.
Also drops the floating stats badge that was drawn on transport arcs.
* fix(map): centre a clicked place above the bottom inspector panel
Selecting a place panned/flew it to the dead centre of the screen, where it sat
behind the detail card. Both overlays now bias the target into the visible area
above the bottom panel (Leaflet offsets the pan by the inspector inset; Mapbox
passes the padding to flyTo).
* feat: show the full multi-stop flight route in PDF and calendar export
The PDF day list and the ICS export now render the whole route (FRA → BER → HND)
for a multi-leg flight instead of just the first and last airport, falling back to
the flat metadata for single-leg flights. The ICS keeps a single event per booking.
* feat(import): group connecting flight legs into one multi-leg booking
When a booking confirmation contains several flight legs sharing a PNR that
connect at the same airport with a short layover (under 24h), they are now
imported as a single multi-leg booking (from/stop/to endpoints + metadata.legs)
instead of one booking per leg. A round trip (same PNR, multi-day gap) stays two
separate bookings, and a single flight is unchanged.
* i18n: translate the new flight-route strings into all languages
* i18n: translate the Costs page into every language
The Budget → Costs rework left the new costs.* strings untranslated in every
non-English locale (they fell back to English). Translate them across all
supported languages.
* Revert "fix(map): centre a clicked place above the bottom inspector panel"
This reverts commit 0936103f04.
This commit is contained in:
@@ -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<number, Record<number, number>> = {}
|
||||
|
||||
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
|
||||
</div>
|
||||
) : (
|
||||
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 (
|
||||
<React.Fragment key={`transport-${res.id}-${day.id}`}>
|
||||
<React.Fragment key={`transport-${res.id}-${legKey}-${day.id}`}>
|
||||
<div
|
||||
onClick={() => {
|
||||
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 && (
|
||||
<div className="dp-grip" style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', opacity: 0.3, transition: 'opacity 0.15s', cursor: 'grab' }}>
|
||||
<GripVertical size={13} strokeWidth={1.8} />
|
||||
</div>
|
||||
@@ -1846,7 +1893,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{onToggleConnection && (res.endpoints || []).length >= 2 && (() => {
|
||||
{onToggleConnection && (!res.__leg || res.__leg.index === 0) && (res.endpoints || []).length >= 2 && (() => {
|
||||
const active = visibleConnectionIds.includes(res.id)
|
||||
return (
|
||||
<button
|
||||
|
||||
Reference in New Issue
Block a user