diff --git a/client/src/components/PDF/TripPDF.test.ts b/client/src/components/PDF/TripPDF.test.ts
index e51717018..1b7ac9523 100644
--- a/client/src/components/PDF/TripPDF.test.ts
+++ b/client/src/components/PDF/TripPDF.test.ts
@@ -548,7 +548,7 @@ describe('downloadTripPDF', () => {
})
})
-// FE-W5PDF-001 to FE-W5PDF-018 — multi-day transport spans, the remaining
+// FE-W5PDF-001 to FE-W5PDF-030 — multi-day transport spans, the remaining
// reservation subtitles, accommodation phases and cover fallbacks.
describe('downloadTripPDF remaining branches', () => {
const dA = { id: 10, day_number: 1, title: 'Day A', date: '2025-06-01' } as any
@@ -603,6 +603,20 @@ describe('downloadTripPDF remaining branches', () => {
expect(html).not.toContain('reservations.span.active')
})
+ it('FE-W5PDF-029: a multi-day parking is labelled drop-off/pickup and skips the middle day (#1937)', async () => {
+ await downloadTripPDF(spanArgs([{
+ id: 506, title: 'Airport Parking', type: 'parking', day_id: 10, end_day_id: 12,
+ reservation_time: '2025-06-01T05:30', reservation_end_time: '2025-06-03T19:00',
+ }]))
+ const html = srcdoc()
+
+ expect(html).toContain('reservations.span.dropOff: Airport Parking')
+ expect(html).toContain('reservations.span.pickup: Airport Parking')
+ expect(html).not.toContain('reservations.span.ongoing')
+ // Day B holds nothing else, so it prints the empty-day hint rather than the booking.
+ expect(html).toContain('dayplan.emptyDay')
+ })
+
it('FE-W5PDF-004: hotels, day-less and unknown-day reservations are all skipped', async () => {
await downloadTripPDF(spanArgs([
{ id: 503, title: 'Hotel Row', type: 'hotel', day_id: 10 },
@@ -691,6 +705,25 @@ describe('downloadTripPDF remaining branches', () => {
expect(html).toContain('AirX · X2 · AAA → BBB')
})
+ it('FE-W5PDF-030: a stopover flight prints the booking code of each segment that has one (#1943)', async () => {
+ await downloadTripPDF(spanArgs([{
+ id: 518, title: 'Layover', type: 'flight', day_id: 10, confirmation_number: 'BOOK1',
+ metadata: JSON.stringify({
+ legs: [
+ { airline: 'LH', flight_number: 'LH1', from: 'FRA', to: 'BER', confirmation_number: 'ABC123' },
+ { airline: 'ANA', flight_number: 'NH2', from: 'BER', to: 'HND' },
+ ],
+ }),
+ }]))
+ const html = srcdoc()
+
+ expect(html).toContain('LH · LH1 · FRA → BER · ABC123')
+ // A segment without one keeps the line it had before, and the booking's own
+ // reference still prints once for the whole card.
+ expect(html).toContain('ANA · NH2 · BER → HND')
+ expect(html).toContain('Code: BOOK1')
+ })
+
it('FE-W5PDF-010: a single-leg flight with waypoints joins the whole route', async () => {
await downloadTripPDF(spanArgs([{
id: 517, title: 'Long Haul', type: 'flight', day_id: 10,
diff --git a/client/src/components/PDF/TripPDF.tsx b/client/src/components/PDF/TripPDF.tsx
index 1ede6be52..f5d4745b3 100644
--- a/client/src/components/PDF/TripPDF.tsx
+++ b/client/src/components/PDF/TripPDF.tsx
@@ -5,6 +5,7 @@ import { FileText, Info, Clock, MapPin, Navigation, Train, Plane, Bus, Car, Ship
import { accommodationsApi, mapsApi, pluginsApi } from '../../api/client'
import type { Trip, Day, Place, Category, AssignmentsMap, DayNote } from '../../types'
import { isDayInAccommodationRange, getDayOrder } from '../../utils/dayOrder'
+import { hidesOnMiddleDay } from '../../utils/dayMerge'
import { safeHexColor } from '../../utils/safeColor'
import { renderIconMarkup } from '../../utils/iconMarkup'
import { formatMoney, formatMoneySum, splitReservationDateTime, type MoneyEntry } from '../../utils/formatters'
@@ -228,6 +229,9 @@ export async function downloadTripPDF({ trip, days, places, assignments = {}, ca
if (phase === 'single') return null
if (r.type === 'flight') return tr(`reservations.span.${phase === 'start' ? 'departure' : phase === 'end' ? 'arrival' : 'inTransit'}`)
if (r.type === 'car') return tr(`reservations.span.${phase === 'start' ? 'pickup' : phase === 'end' ? 'return' : 'active'}`)
+ // Parking reuses span.pickup for its end day: you drop the car off and collect it
+ // again later, so the rental car's wording carries over without a second key.
+ if (r.type === 'parking') return tr(`reservations.span.${phase === 'start' ? 'dropOff' : phase === 'end' ? 'pickup' : 'ongoing'}`)
return tr(`reservations.span.${phase === 'start' ? 'start' : phase === 'end' ? 'end' : 'ongoing'}`)
}
const pdfGetTransportForDay = (dayId: number) => (reservations || []).filter(r => {
@@ -251,9 +255,11 @@ export async function downloadTripPDF({ trip, days, places, assignments = {}, ca
const notes = (dayNotes || []).filter(n => n.day_id === day.id)
const cost = dayCost(assignments, day.id, loc, tripCur, fxRates)
- // Reservations for this day (hotel rendered via accommodations block; car middle-phase rendered in sidebar header only)
+ // Reservations for this day (hotel rendered via accommodations block; car middle-phase rendered in sidebar header only,
+ // a multi-day parking's middle days nowhere at all)
const dayReservations = pdfGetTransportForDay(day.id)
.filter(r => !(r.type === 'car' && pdfGetSpanPhase(r, day.id) === 'middle'))
+ .filter(r => !hidesOnMiddleDay(r, day.id))
const merged = []
assigned.forEach(a => merged.push({ type: 'place', k: a.order_index ?? 0, data: a }))
@@ -279,10 +285,13 @@ export async function downloadTripPDF({ trip, days, places, assignments = {}, ca
if (r.type === 'flight') {
const legs = getFlightLegs(r)
if (legs.length > 1) {
- // Multi-leg: one line per leg so every flight number + segment route is shown.
+ // Multi-leg: one line per leg so every flight number + segment route is
+ // shown, with the segment's own booking code where it has one (#1943).
+ // At the gate that is the number the airline asks for.
subtitleLines = legs.map(l =>
[l.airline, l.flight_number,
- (l.from || l.to) ? [l.from, l.to].filter(Boolean).join(' → ') : '']
+ (l.from || l.to) ? [l.from, l.to].filter(Boolean).join(' → ') : '',
+ l.confirmation_number]
.filter(Boolean).join(' · '))
.filter(Boolean)
} else {
@@ -296,10 +305,12 @@ export async function downloadTripPDF({ trip, days, places, assignments = {}, ca
else if (r.type === 'train') {
const legs = getTrainLegs(r)
if (legs.length > 1) {
- // Multi-leg: one line per leg so every train number + segment route shows.
+ // Multi-leg: one line per leg so every train number + segment route shows,
+ // with the segment's own booking code where it has one (#1943).
subtitleLines = legs.map(l =>
[l.train_number, l.platform ? `Gl. ${l.platform}` : '',
- (l.from || l.to) ? [l.from, l.to].filter(Boolean).join(' → ') : '']
+ (l.from || l.to) ? [l.from, l.to].filter(Boolean).join(' → ') : '',
+ l.confirmation_number]
.filter(Boolean).join(' · '))
.filter(Boolean)
} else {
diff --git a/client/src/components/Planner/DayPlanSidebar.test.tsx b/client/src/components/Planner/DayPlanSidebar.test.tsx
index f0c7fb10f..21a1a6207 100644
--- a/client/src/components/Planner/DayPlanSidebar.test.tsx
+++ b/client/src/components/Planner/DayPlanSidebar.test.tsx
@@ -2286,6 +2286,41 @@ describe('DayPlanSidebar', () => {
expect(await screen.findByText('2 km')).toBeInTheDocument()
})
+ it('FE-PLANNER-DAYPLAN-203: leg distance survives a parking on its middle days (#1937)', async () => {
+ const user = userEvent.setup()
+ const { calculateRouteWithLegs } = await import('../Map/RouteCalculator')
+ vi.mocked(calculateRouteWithLegs as any).mockImplementation((wp: any) => Promise.resolve({
+ distanceText: '2 km', durationText: '10 min',
+ legs: Array.from({ length: Math.max(0, (wp?.length ?? 0) - 1) }, () => ({
+ distanceText: '2 km', durationText: '10 min', drivingText: '10 min', walkingText: '25 min',
+ })),
+ }))
+ // Same trap as the car rental in -106: the parking row is hidden on day 11, so the
+ // through-leg between the places around it must stay keyed to the place.
+ const parking = {
+ ...buildReservation({ id: 323, type: 'parking', title: 'Airport Parking', day_id: 10 }),
+ end_day_id: 12,
+ day_positions: { 11: 0.5 },
+ }
+ const days = [
+ buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }),
+ buildDay({ id: 11, date: '2025-06-02', title: 'Day 2' }),
+ buildDay({ id: 12, date: '2025-06-03', title: 'Day 3' }),
+ ]
+ const assigns = {
+ '11': [
+ buildAssignment({ id: 1, day_id: 11, order_index: 0, place: buildPlace({ id: 1, name: 'A', lat: 48.85, lng: 2.35 }) }),
+ buildAssignment({ id: 2, day_id: 11, order_index: 1, place: buildPlace({ id: 2, name: 'B', lat: 48.86, lng: 2.36 }) }),
+ ],
+ }
+ render()
+ await user.click(screen.getByRole('button', { name: 'Route' }))
+ expect(await screen.findByText('2 km')).toBeInTheDocument()
+ })
+
// ── Persisted / externally driven state ──────────────────────────────────
it('FE-PLANNER-DAYPLAN-110: the saved collapse state wins over expanding every day', () => {
@@ -2443,6 +2478,56 @@ describe('DayPlanSidebar', () => {
expect(screen.getAllByText('Ghost rental')).toHaveLength(1)
})
+ it('FE-PLANNER-DAYPLAN-200: a multi-day parking shows on the first and last day only (#1937)', () => {
+ const days = [
+ buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }),
+ buildDay({ id: 11, date: '2025-06-02', title: 'Day 2' }),
+ buildDay({ id: 12, date: '2025-06-03', title: 'Day 3' }),
+ ]
+ const parking = buildReservation({
+ id: 320, type: 'parking', title: 'Airport Parking', day_id: 10, end_day_id: 12,
+ reservation_time: '2025-06-01T05:30:00', reservation_end_time: '2025-06-03T19:00:00',
+ })
+ render()
+ // Drop-off on day 1, pickup on day 3, nothing in between.
+ expect(screen.getAllByText('Airport Parking')).toHaveLength(2)
+ // And no smaller tag in the day header either, unlike a rental car.
+ expect(within(dayHeader('Day 2')).queryByText('Airport Parking')).not.toBeInTheDocument()
+ })
+
+ it('FE-PLANNER-DAYPLAN-201: a multi-day parking labels its drop-off and its pickup day', () => {
+ const days = [
+ buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }),
+ buildDay({ id: 11, date: '2025-06-02', title: 'Day 2' }),
+ buildDay({ id: 12, date: '2025-06-03', title: 'Day 3' }),
+ ]
+ const parking = buildReservation({
+ id: 321, type: 'parking', title: 'Airport Parking', day_id: 10, end_day_id: 12,
+ reservation_time: '2025-06-01T05:30:00', reservation_end_time: '2025-06-03T19:00:00',
+ })
+ render()
+ expect(screen.getByText('Drop-off')).toBeInTheDocument()
+ expect(screen.getByText('Pickup')).toBeInTheDocument()
+ // The generic span wording never appears for a parking.
+ expect(screen.queryByText('Ongoing')).not.toBeInTheDocument()
+ })
+
+ it('FE-PLANNER-DAYPLAN-202: a day left empty by a spanning parking shows the empty-day hint', () => {
+ const days = [
+ buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }),
+ buildDay({ id: 11, date: '2025-06-02', title: 'Day 2' }),
+ buildDay({ id: 12, date: '2025-06-03', title: 'Day 3' }),
+ ]
+ const parking = buildReservation({
+ id: 322, type: 'parking', title: 'Airport Parking', day_id: 10, end_day_id: 12,
+ reservation_time: '2025-06-01T05:30:00', reservation_end_time: '2025-06-03T19:00:00',
+ })
+ render()
+ // Days 1 and 3 carry the booking, so only day 2 is empty, and it says so instead
+ // of leaving a blank gap where the hidden row used to be.
+ expect(screen.getAllByText('No places planned for this day')).toHaveLength(1)
+ })
+
// ── Route legs, hotel bookends, travel modes ─────────────────────────────
it('FE-PLANNER-DAYPLAN-120: a routed day draws the hotel bookends around its stops', async () => {
diff --git a/client/src/components/Planner/DayPlanSidebar.tsx b/client/src/components/Planner/DayPlanSidebar.tsx
index e120c0c06..f01c78560 100644
--- a/client/src/components/Planner/DayPlanSidebar.tsx
+++ b/client/src/components/Planner/DayPlanSidebar.tsx
@@ -28,7 +28,7 @@ import { placeToSaveTarget } from '../Collections/saveTarget'
import { useTranslation } from '../../i18n'
import { isDayInAccommodationRange, getAccommodationAnchors, getDayBookendHotels, shouldDrawMorningLeg, shouldDrawEveningLeg } from '../../utils/dayOrder'
import {
- TRANSPORT_TYPES, parseTimeToMinutes, getSpanPhase, getDisplayTimeForDay, getTransportRouteEndpoints,
+ TRANSPORT_TYPES, parseTimeToMinutes, getSpanPhase, hidesOnMiddleDay, getDisplayTimeForDay, getTransportRouteEndpoints,
getTransportForDay as _getTransportForDay, getMergedItems as _getMergedItems,
type MergedItem,
} from '../../utils/dayMerge'
@@ -362,6 +362,9 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
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'}`)
}
@@ -513,11 +516,12 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
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')) {
+ } 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 though — that row isn't rendered
- // in the timeline, so re-keying would drop the leg entirely (#1504).
+ // 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 }
}
}
@@ -1477,6 +1481,10 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
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
@@ -1735,7 +1743,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
) : connector
})()}
{daySchedule.byPosition[day.id]?.start.map(si => )}
- {merged.length === 0 && !dayNoteUi ? (
+ {visibleCount === 0 && !dayNoteUi ? (
{ e.preventDefault(); if (dragOverDayId !== day.id) setDragOverDayId(day.id) }}
onDrop={e => handleDropOnDay(e, day.id)}
@@ -2121,6 +2129,9 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
// 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'
diff --git a/client/src/components/Planner/DayPlanSidebarTransportDetailModal.test.tsx b/client/src/components/Planner/DayPlanSidebarTransportDetailModal.test.tsx
index 3b1b319b6..204a4e2a1 100644
--- a/client/src/components/Planner/DayPlanSidebarTransportDetailModal.test.tsx
+++ b/client/src/components/Planner/DayPlanSidebarTransportDetailModal.test.tsx
@@ -1,4 +1,4 @@
-// FE-PLANNER-DPTRANSPORT-001 to FE-PLANNER-DPTRANSPORT-018
+// FE-PLANNER-DPTRANSPORT-001 to FE-PLANNER-DPTRANSPORT-019
import { render, screen, fireEvent } from '../../../tests/helpers/render'
import userEvent from '@testing-library/user-event'
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
@@ -235,6 +235,28 @@ describe('DayPlanSidebarTransportDetailModal', () => {
expect(setTransportDetail).toHaveBeenCalledWith(null)
})
+ it('FE-PLANNER-DPTRANSPORT-019: a segment with its own booking code gets its own blurred field (#1943)', () => {
+ seedStore(useSettingsStore, { settings: { time_format: '24h', blur_booking_codes: true } })
+ const res = buildReservation({
+ id: 42, type: 'flight', title: 'BER → CDG → JFK', status: 'confirmed',
+ reservation_time: '2025-06-15T08:30:00',
+ confirmation_number: 'XY7Z9Q',
+ metadata: JSON.stringify({
+ legs: [
+ { from: 'BER', to: 'CDG', confirmation_number: 'ABC123' },
+ { from: 'CDG', to: 'JFK' },
+ ],
+ }),
+ } as Partial
)
+ render()
+
+ expect(screen.getByText('BER → CDG')).toBeInTheDocument()
+ expect(screen.getByText('ABC123')).toHaveStyle({ filter: 'blur(5px)' })
+ // The booking's own reference keeps its own field, a code-less segment adds none.
+ expect(screen.getByText('XY7Z9Q')).toBeInTheDocument()
+ expect(screen.queryByText('CDG → JFK')).not.toBeInTheDocument()
+ })
+
it('FE-PLANNER-DPTRANSPORT-018: clicking the backdrop closes, clicking the card does not', async () => {
const user = userEvent.setup()
const setTransportDetail = vi.fn()
diff --git a/client/src/components/Planner/DayPlanSidebarTransportDetailModal.tsx b/client/src/components/Planner/DayPlanSidebarTransportDetailModal.tsx
index 4bda8aa8f..84d98f3ee 100644
--- a/client/src/components/Planner/DayPlanSidebarTransportDetailModal.tsx
+++ b/client/src/components/Planner/DayPlanSidebarTransportDetailModal.tsx
@@ -57,6 +57,16 @@ export function DayPlanSidebarTransportDetailModal({
if (meta.seat) detailFields.push({ label: t('reservations.meta.seat'), value: meta.seat })
}
if (res.confirmation_number) detailFields.push({ label: t('reservations.confirmationCode'), value: res.confirmation_number, sensitive: true })
+ // A stopover booking can carry its own reference per segment (#1943); the
+ // flat fields above only ever describe the first leg. Marked sensitive so
+ // the blur setting covers them like the booking's own code.
+ if (Array.isArray(meta.legs) && meta.legs.length > 1) {
+ for (const leg of meta.legs) {
+ if (!leg?.confirmation_number) continue
+ const segment = [leg.from, leg.to].filter(Boolean).join(' → ')
+ detailFields.push({ label: segment || t('reservations.confirmationCode'), value: leg.confirmation_number, sensitive: true })
+ }
+ }
if (res.location) detailFields.push({ label: t('reservations.locationAddress'), value: res.location })
return (
diff --git a/client/src/components/Planner/ReservationsPanel.test.tsx b/client/src/components/Planner/ReservationsPanel.test.tsx
index c17c2b329..054db8410 100644
--- a/client/src/components/Planner/ReservationsPanel.test.tsx
+++ b/client/src/components/Planner/ReservationsPanel.test.tsx
@@ -1,4 +1,4 @@
-// FE-COMP-RES-001 to FE-COMP-RES-040, FE-PLANNER-RESP-016 to FE-PLANNER-RESP-072
+// FE-COMP-RES-001 to FE-COMP-RES-040, FE-PLANNER-RESP-016 to FE-PLANNER-RESP-078
import { render, screen, fireEvent, waitFor, act } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
@@ -239,6 +239,39 @@ describe('ReservationsPanel', () => {
expect(codeEl.style.filter).toBe('none');
});
+ const layoverFlight = () => buildReservation({
+ id: 7, type: 'flight', status: 'confirmed', confirmation_number: 'BOOK1',
+ metadata: JSON.stringify({
+ departure_airport: 'FRA', arrival_airport: 'HND',
+ legs: [
+ { from: 'FRA', to: 'BER', confirmation_number: 'ABC123' },
+ { from: 'BER', to: 'HND' },
+ ],
+ }),
+ });
+
+ it('FE-PLANNER-RESP-077: a segment with its own booking code shows it under its route (#1943)', () => {
+ render();
+ // The booking's own reference keeps its own cell.
+ expect(screen.getByText('BOOK1')).toBeInTheDocument();
+ expect(screen.getByText('FRA → BER')).toBeInTheDocument();
+ expect(screen.getByText('ABC123')).toBeInTheDocument();
+ // A segment without its own code adds no cell.
+ expect(screen.queryByText('BER → HND')).not.toBeInTheDocument();
+ });
+
+ it('FE-PLANNER-RESP-078: a segment code obeys blur_booking_codes and reveals with the card', async () => {
+ const user = userEvent.setup();
+ seedStore(useSettingsStore, { settings: { time_format: '24h', blur_booking_codes: true, temperature_unit: 'celsius', language: 'en', dark_mode: false, default_currency: 'USD', map_tile_url: '', show_place_description: false } });
+ render();
+ const legCode = screen.getByText('ABC123');
+ expect(legCode.style.filter).toContain('blur');
+ // One reveal per card, not per segment: the booking's own cell uncovers too.
+ await user.hover(legCode);
+ expect(legCode.style.filter).toBe('none');
+ expect(screen.getByText('BOOK1').style.filter).toBe('none');
+ });
+
it('FE-PLANNER-RESP-024: reservation notes are shown', () => {
const res = buildReservation({ notes: 'Window seat requested', status: 'pending' });
render();
diff --git a/client/src/components/Planner/ReservationsPanel.tsx b/client/src/components/Planner/ReservationsPanel.tsx
index 24adba0d0..d096d98d8 100644
--- a/client/src/components/Planner/ReservationsPanel.tsx
+++ b/client/src/components/Planner/ReservationsPanel.tsx
@@ -23,6 +23,7 @@ import { usePluginViewContributions, PluginCardFooter } from '../Plugins/PluginC
import { usePluginStore, type ActivePlugin } from '../../store/pluginStore'
import PluginFrame from '../Plugins/PluginFrame'
import { splitReservationDateTime, formatTime } from '../../utils/formatters'
+import { getFlightLegs, getTrainLegs } from '../../utils/flightLegs'
import EmptyState from '../shared/EmptyState'
import { TravelerAvatarRow, TravelerFilterAvatars } from './TravelerPicker'
import type { TripMember } from '../Budget/BudgetPanelMemberChips'
@@ -329,6 +330,41 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
)
})()}
+ {/* Per-segment booking codes (#1943). A stopover booking can carry its own
+ reference per leg, and the cell above only ever shows the booking's own.
+ The reveal is shared with that cell on purpose: uncovering the codes is
+ one gesture per card, not one per segment. */}
+ {(() => {
+ if (r.type !== 'flight' && r.type !== 'train') return null
+ const legs = r.type === 'flight' ? getFlightLegs(r) : getTrainLegs(r)
+ if (legs.length < 2) return null
+ const coded = legs.filter(l => l.confirmation_number)
+ if (coded.length === 0) return null
+ return (
+ 1 ? `repeat(${Math.min(coded.length, 3)}, 1fr)` : '1fr' }}>
+ {coded.map((l, i) => (
+
+
{[l.from, l.to].filter(Boolean).join(' → ') || t('reservations.confirmationCode')}
+
blurCodes && setCodeRevealed(true)}
+ onMouseLeave={() => blurCodes && setCodeRevealed(false)}
+ onClick={() => blurCodes && setCodeRevealed(v => !v)}
+ className={`${fieldValueClass} text-center`}
+ style={{
+ fontFamily: '"SF Mono", "JetBrains Mono", Menlo, monospace', fontSize: 'calc(12.5px * var(--fs-scale-body, 1))',
+ filter: blurCodes && !codeRevealed ? 'blur(5px)' : 'none',
+ cursor: blurCodes ? 'pointer' : 'default',
+ transition: 'filter 0.2s',
+ }}
+ >
+ {l.confirmation_number}
+
+
+ ))}
+
+ )
+ })()}
+
{/* Type-specific metadata */}
{(() => {
const meta = typeof r.metadata === 'string' ? JSON.parse(r.metadata || '{}') : (r.metadata || {})
diff --git a/client/src/components/Planner/TransportModal.test.tsx b/client/src/components/Planner/TransportModal.test.tsx
index b519daa08..45c0b0500 100644
--- a/client/src/components/Planner/TransportModal.test.tsx
+++ b/client/src/components/Planner/TransportModal.test.tsx
@@ -1,4 +1,4 @@
-// FE-PLANNER-TRANSMODAL-001 to FE-PLANNER-TRANSMODAL-053
+// FE-PLANNER-TRANSMODAL-001 to FE-PLANNER-TRANSMODAL-061
import { render, screen, waitFor, fireEvent, within } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
@@ -650,6 +650,105 @@ describe('TransportModal', () => {
expect(payload.metadata.legs).toBeUndefined();
});
+ // ── Per-segment booking references (#1943) ─────────────────────────────────
+
+ function multiLegFlight(): Reservation {
+ const res = buildReservation({ id: 31, title: 'BRU → HEL → JFK', type: 'flight' });
+ return Object.assign(res, {
+ day_id: 10,
+ end_day_id: 12,
+ reservation_time: '2026-08-01T08:00',
+ reservation_end_time: '2026-08-03T15:00',
+ confirmation_number: 'BOOK1',
+ metadata: {
+ airline: 'Brussels Airlines', flight_number: 'SN 1234', departure_airport: 'BRU', arrival_airport: 'JFK',
+ legs: [
+ { from: 'BRU', to: 'HEL', airline: 'Brussels Airlines', flight_number: 'SN 1234', confirmation_number: 'ABC123', dep_day_id: 10, dep_time: '08:00', arr_day_id: 10, arr_time: '12:30' },
+ { from: 'HEL', to: 'JFK', airline: 'Finnair', flight_number: 'AY 15', confirmation_number: 'XYZ789', dep_day_id: 11, dep_time: '14:00', arr_day_id: 12, arr_time: '15:00' },
+ ],
+ },
+ endpoints: [
+ { id: 1, reservation_id: 31, role: 'from', sequence: 0, name: 'Brussels (BRU)', code: 'BRU', lat: 50.9, lng: 4.48, timezone: 'Europe/Brussels', local_date: '2026-08-01', local_time: '08:00' },
+ { id: 2, reservation_id: 31, role: 'stop', sequence: 1, name: 'Helsinki (HEL)', code: 'HEL', lat: 60.31, lng: 24.96, timezone: 'Europe/Helsinki', local_date: '2026-08-02', local_time: '14:00' },
+ { id: 3, reservation_id: 31, role: 'to', sequence: 2, name: 'New York (JFK)', code: 'JFK', lat: 40.64, lng: -73.78, timezone: 'America/New_York', local_date: '2026-08-03', local_time: '15:00' },
+ ],
+ }) as unknown as Reservation;
+ }
+
+ it('FE-PLANNER-TRANSMODAL-058: each segment of a stopover flight saves its own booking code', async () => {
+ const onSave = vi.fn().mockResolvedValue(undefined);
+ render();
+
+ await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'BRU → HEL → JFK');
+ await userEvent.click(screen.getByRole('button', { name: /Add stop/i }));
+ const airports = screen.getAllByTestId('airport-select');
+ fireEvent.change(airports[0], { target: { value: 'BRU' } });
+ fireEvent.change(airports[1], { target: { value: 'HEL' } });
+ fireEvent.change(airports[2], { target: { value: 'JFK' } });
+
+ // Two departing waypoints get a code field, the booking's own field stays last.
+ const codes = screen.getAllByPlaceholderText('e.g. ABC12345');
+ expect(codes).toHaveLength(3);
+ fireEvent.change(codes[0], { target: { value: 'ABC123' } });
+ fireEvent.change(codes[1], { target: { value: 'XYZ789' } });
+ fireEvent.change(codes[2], { target: { value: 'BOOK1' } });
+
+ await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
+ await waitFor(() => expect(onSave).toHaveBeenCalled());
+ const payload = onSave.mock.calls[0][0];
+ expect(payload.metadata.legs.map((l: { confirmation_number?: string }) => l.confirmation_number)).toEqual(['ABC123', 'XYZ789']);
+ // The booking's own reference stays where it was and is never mirrored.
+ expect(payload.confirmation_number).toBe('BOOK1');
+ expect(payload.metadata.confirmation_number).toBeUndefined();
+ });
+
+ it('FE-PLANNER-TRANSMODAL-059: no per-segment field while the route writes no legs', async () => {
+ render();
+
+ fireEvent.change(screen.getAllByTestId('airport-select')[0], { target: { value: 'BRU' } });
+ fireEvent.change(screen.getAllByTestId('airport-select')[1], { target: { value: 'JFK' } });
+ // Only the booking's own field: a direct flight stores no legs.
+ expect(screen.getAllByPlaceholderText('e.g. ABC12345')).toHaveLength(1);
+
+ // A third waypoint whose airport is never picked drops out of the route, so
+ // the legs are still not written and the field must stay away. Otherwise the
+ // typed code would vanish on save.
+ await userEvent.click(screen.getByRole('button', { name: /Add stop/i }));
+ expect(screen.getAllByTestId('airport-select')).toHaveLength(3);
+ expect(screen.getAllByPlaceholderText('e.g. ABC12345')).toHaveLength(1);
+ });
+
+ it('FE-PLANNER-TRANSMODAL-060: editing a stopover flight pre-fills every segment code and a re-save keeps them', async () => {
+ const onSave = vi.fn().mockResolvedValue(undefined);
+ render();
+
+ const codes = screen.getAllByPlaceholderText('e.g. ABC12345') as HTMLInputElement[];
+ expect(codes.map(i => i.value)).toEqual(['ABC123', 'XYZ789', 'BOOK1']);
+
+ await userEvent.click(screen.getByRole('button', { name: /^Update$/i }));
+ await waitFor(() => expect(onSave).toHaveBeenCalled());
+ const payload = onSave.mock.calls[0][0];
+ expect(payload.metadata.legs.map((l: { confirmation_number?: string }) => l.confirmation_number)).toEqual(['ABC123', 'XYZ789']);
+ expect(payload.confirmation_number).toBe('BOOK1');
+ });
+
+ it('FE-PLANNER-TRANSMODAL-061: dropping the stop drops the segment codes with the legs, never the booking code', async () => {
+ const onSave = vi.fn().mockResolvedValue(undefined);
+ render();
+
+ // Removing the only stop collapses the route to a direct flight, so there are
+ // no legs left to hold a per-segment code. Documented behaviour, not a bug:
+ // the field disappears with them and the booking keeps its own reference.
+ await userEvent.click(screen.getByRole('button', { name: /^Delete$/i }));
+ expect(screen.getAllByPlaceholderText('e.g. ABC12345')).toHaveLength(1);
+
+ await userEvent.click(screen.getByRole('button', { name: /^Update$/i }));
+ await waitFor(() => expect(onSave).toHaveBeenCalled());
+ const payload = onSave.mock.calls[0][0];
+ expect(payload.metadata.legs).toBeUndefined();
+ expect(payload.confirmation_number).toBe('BOOK1');
+ });
+
it('FE-PLANNER-TRANSMODAL-034: the departure-date picker moves the whole flight to another day', async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
render();
diff --git a/client/src/components/Planner/TransportModal.tsx b/client/src/components/Planner/TransportModal.tsx
index 1d6656acf..9e0422185 100644
--- a/client/src/components/Planner/TransportModal.tsx
+++ b/client/src/components/Planner/TransportModal.tsx
@@ -85,9 +85,12 @@ interface WaypointForm {
airline: string
flight_number: string
seat: string
+ // Booking reference of the leg leaving this waypoint: airlines often issue one
+ // per flight rather than one per booking (#1943). Empty means the booking's own.
+ confirmation_number: string
}
function emptyWaypoint(dayId: string | number = ''): WaypointForm {
- return { airport: null, arrDayId: dayId, arrTime: '', depDayId: dayId, depTime: '', airline: '', flight_number: '', seat: '' }
+ return { airport: null, arrDayId: dayId, arrTime: '', depDayId: dayId, depTime: '', airline: '', flight_number: '', seat: '', confirmation_number: '' }
}
// ── Multi-leg train stations ───────────────────────────────────────────────
@@ -103,9 +106,10 @@ interface StationWaypointForm {
train_number: string
platform: string
seat: string
+ confirmation_number: string
}
function emptyStationWaypoint(dayId: string | number = ''): StationWaypointForm {
- return { location: null, arrDayId: dayId, arrTime: '', depDayId: dayId, depTime: '', train_number: '', platform: '', seat: '' }
+ return { location: null, arrDayId: dayId, arrTime: '', depDayId: dayId, depTime: '', train_number: '', platform: '', seat: '', confirmation_number: '' }
}
const TYPE_OPTIONS = [
@@ -264,6 +268,10 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
airline: legOut?.airline ?? (isFirst ? (meta.airline ?? '') : ''),
flight_number: legOut?.flight_number ?? (isFirst ? (meta.flight_number ?? '') : ''),
seat: legOut?.seat ?? (isFirst ? (meta.seat ?? '') : ''),
+ // No fallback to src.confirmation_number: that one belongs to the
+ // whole booking and stays in the form's own field, otherwise a
+ // plain re-save would copy it onto the first leg.
+ confirmation_number: legOut?.confirmation_number ?? '',
}
})
} else {
@@ -305,6 +313,8 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
train_number: legOut?.train_number ?? (isFirst ? (meta.train_number ?? '') : ''),
platform: legOut?.platform ?? (isFirst ? (meta.platform ?? '') : ''),
seat: legOut?.seat ?? (isFirst ? (meta.seat ?? '') : ''),
+ // See the flight branch: the booking's own reference stays out of the legs.
+ confirmation_number: legOut?.confirmation_number ?? '',
}
})
} else {
@@ -399,6 +409,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
...(w.airline ? { airline: w.airline } : {}),
...(w.flight_number ? { flight_number: w.flight_number } : {}),
...(w.seat ? { seat: w.seat } : {}),
+ ...(w.confirmation_number ? { confirmation_number: w.confirmation_number } : {}),
dep_day_id: w.depDayId ? Number(w.depDayId) : null,
dep_time: w.depTime || null,
arr_day_id: next.arrDayId ? Number(next.arrDayId) : null,
@@ -426,6 +437,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
...(w.train_number ? { train_number: w.train_number } : {}),
...(w.platform ? { platform: w.platform } : {}),
...(w.seat ? { seat: w.seat } : {}),
+ ...(w.confirmation_number ? { confirmation_number: w.confirmation_number } : {}),
dep_day_id: w.depDayId ? Number(w.depDayId) : null,
dep_time: w.depTime || null,
arr_day_id: next.arrDayId ? Number(next.arrDayId) : null,
@@ -630,6 +642,12 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
}),
]
+ // The per-leg booking code is only offered where handleSubmit actually writes
+ // metadata.legs: the SAME condition, over the waypoints that become endpoints,
+ // not over the raw rows. Anywhere else the value would vanish on save (#1943).
+ const writesFlightLegs = waypoints.filter(w => w.airport).length > 2
+ const writesTrainLegs = trainWaypoints.filter(w => w.location).length > 2
+
return (
)}
-
+
>
)}
@@ -866,7 +891,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
updateWp({ depTime: v })} />
-
+
>
)}
diff --git a/client/src/mobile/screens/trip/plan/planTimelineModel.ts b/client/src/mobile/screens/trip/plan/planTimelineModel.ts
index 39fb9da5d..c31a9fbc7 100644
--- a/client/src/mobile/screens/trip/plan/planTimelineModel.ts
+++ b/client/src/mobile/screens/trip/plan/planTimelineModel.ts
@@ -1,6 +1,6 @@
import { Cloud, CloudDrizzle, CloudLightning, CloudRain, CloudSnow, Sun, Wind } from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
-import { getDisplayTimeForDay, getSpanPhase, parseTimeToMinutes } from '../../../../utils/dayMerge'
+import { getDisplayTimeForDay, getSpanPhase, hidesOnMiddleDay, parseTimeToMinutes } from '../../../../utils/dayMerge'
import { getDayBookendHotels, isDayInAccommodationRange } from '../../../../utils/dayOrder'
import type { MergedItem } from '../../../../utils/dayMerge'
import type { TransitLegDisplay } from '../../../../components/Planner/transitDisplay'
@@ -107,8 +107,10 @@ export function buildPlanRows(opts: {
base.push({ key: `note-${note.id}`, kind: 'note', item, note })
} else {
const res = item.data as TransportEntry
- // A car rental's middle days live in the day header, not the timeline.
+ // A car rental's middle days live in the day header, not the timeline; a
+ // multi-day parking drops out of both on those days (#1937).
if (res.type === 'car' && getSpanPhase(res, dayId) === 'middle') continue
+ if (hidesOnMiddleDay(res, dayId)) continue
const transit = getTransitMeta(res)
const key = `tr-${res.id}${res.__leg ? `-leg${res.__leg.index}` : ''}`
if (transit) base.push({ key, kind: 'transit', item, res, transit })
diff --git a/client/src/mobile/screens/trip/sheets/MTransportFormSheet.tsx b/client/src/mobile/screens/trip/sheets/MTransportFormSheet.tsx
index 7a6b83c28..c81ba3425 100644
--- a/client/src/mobile/screens/trip/sheets/MTransportFormSheet.tsx
+++ b/client/src/mobile/screens/trip/sheets/MTransportFormSheet.tsx
@@ -74,9 +74,12 @@ interface WaypointForm {
airline: string
flight_number: string
seat: string
+ // Booking reference of the leg leaving this waypoint (#1943); empty means the
+ // booking's own reference covers it.
+ confirmation_number: string
}
function emptyWaypoint(dayId: string | number = ''): WaypointForm {
- return { airport: null, arrDayId: dayId, arrTime: '', depDayId: dayId, depTime: '', airline: '', flight_number: '', seat: '' }
+ return { airport: null, arrDayId: dayId, arrTime: '', depDayId: dayId, depTime: '', airline: '', flight_number: '', seat: '', confirmation_number: '' }
}
// A train mirrors the flight route model, but its waypoints are STATIONS
@@ -90,9 +93,10 @@ interface StationWaypointForm {
train_number: string
platform: string
seat: string
+ confirmation_number: string
}
function emptyStationWaypoint(dayId: string | number = ''): StationWaypointForm {
- return { location: null, arrDayId: dayId, arrTime: '', depDayId: dayId, depTime: '', train_number: '', platform: '', seat: '' }
+ return { location: null, arrDayId: dayId, arrTime: '', depDayId: dayId, depTime: '', train_number: '', platform: '', seat: '', confirmation_number: '' }
}
// Traveler picker row — same surface as the cost-split rows (bg on --m-ic).
@@ -220,6 +224,9 @@ export default function MTransportFormSheet({ planner, onOpenExpense }: MTranspo
airline: legOut?.airline ?? (isFirst ? (meta.airline ?? '') : ''),
flight_number: legOut?.flight_number ?? (isFirst ? (meta.flight_number ?? '') : ''),
seat: legOut?.seat ?? (isFirst ? (meta.seat ?? '') : ''),
+ // The booking's own reference stays in the form's field, never on a
+ // leg, so a plain re-save cannot duplicate it onto the first segment.
+ confirmation_number: legOut?.confirmation_number ?? '',
}
})
} else {
@@ -258,6 +265,8 @@ export default function MTransportFormSheet({ planner, onOpenExpense }: MTranspo
train_number: legOut?.train_number ?? (isFirst ? (meta.train_number ?? '') : ''),
platform: legOut?.platform ?? (isFirst ? (meta.platform ?? '') : ''),
seat: legOut?.seat ?? (isFirst ? (meta.seat ?? '') : ''),
+ // See the flight branch: the booking's own reference stays out of the legs.
+ confirmation_number: legOut?.confirmation_number ?? '',
}
})
} else {
@@ -326,6 +335,12 @@ export default function MTransportFormSheet({ planner, onOpenExpense }: MTranspo
}),
]
+ // Same condition handleSubmit uses to write metadata.legs, over the waypoints
+ // that actually become endpoints: below it there are no legs to hold a
+ // per-segment booking code and the value would be dropped on save (#1943).
+ const writesFlightLegs = waypoints.filter(w => w.airport).length > 2
+ const writesTrainLegs = trainWaypoints.filter(w => w.location).length > 2
+
const handleClose = () => {
if (importReviewActive) { advanceImportReview(); return }
setShowTransportModal(false)
@@ -396,6 +411,7 @@ export default function MTransportFormSheet({ planner, onOpenExpense }: MTranspo
...(w.airline ? { airline: w.airline } : {}),
...(w.flight_number ? { flight_number: w.flight_number } : {}),
...(w.seat ? { seat: w.seat } : {}),
+ ...(w.confirmation_number ? { confirmation_number: w.confirmation_number } : {}),
dep_day_id: w.depDayId ? Number(w.depDayId) : null,
dep_time: w.depTime || null,
arr_day_id: next.arrDayId ? Number(next.arrDayId) : null,
@@ -418,6 +434,7 @@ export default function MTransportFormSheet({ planner, onOpenExpense }: MTranspo
...(w.train_number ? { train_number: w.train_number } : {}),
...(w.platform ? { platform: w.platform } : {}),
...(w.seat ? { seat: w.seat } : {}),
+ ...(w.confirmation_number ? { confirmation_number: w.confirmation_number } : {}),
dep_day_id: w.depDayId ? Number(w.depDayId) : null,
dep_time: w.depTime || null,
arr_day_id: next.arrDayId ? Number(next.arrDayId) : null,
@@ -739,6 +756,18 @@ export default function MTransportFormSheet({ planner, onOpenExpense }: MTranspo
updateWp({ seat: e.target.value })} placeholder="12A" className={FIELD_CLS} />
+ {writesFlightLegs && (
+
+ {t('reservations.confirmationCode')}
+ updateWp({ confirmation_number: e.target.value })}
+ placeholder={t('reservations.confirmationPlaceholder')}
+ className={FIELD_CLS}
+ />
+
+ )}
>
)}
@@ -817,6 +846,18 @@ export default function MTransportFormSheet({ planner, onOpenExpense }: MTranspo
updateWp({ seat: e.target.value })} placeholder="42A" className={FIELD_CLS} />
+ {writesTrainLegs && (
+
+ {t('reservations.confirmationCode')}
+ updateWp({ confirmation_number: e.target.value })}
+ placeholder={t('reservations.confirmationPlaceholder')}
+ className={FIELD_CLS}
+ />
+
+ )}
>
)}
diff --git a/client/src/mobile/screens/trip/sheets/MTransportSheet.tsx b/client/src/mobile/screens/trip/sheets/MTransportSheet.tsx
index e3638a5b3..dc3665ffd 100644
--- a/client/src/mobile/screens/trip/sheets/MTransportSheet.tsx
+++ b/client/src/mobile/screens/trip/sheets/MTransportSheet.tsx
@@ -6,6 +6,7 @@ import { useTranslation } from '../../../../i18n'
import { useSettingsStore } from '../../../../store/settingsStore'
import { RES_ICONS } from '../../../../components/Planner/DayPlanSidebar.constants'
import { splitReservationDateTime } from '../../../../utils/formatters'
+import { getFlightLegs, getTrainLegs } from '../../../../utils/flightLegs'
import { openFile } from '../../../../utils/fileDownload'
import type { Reservation } from '../../../../types'
import { Eyebrow, INNER_CLS, StatBox, TileHeader, displayTime } from './MTripSheetUi'
@@ -95,6 +96,11 @@ export default function MTransportSheet({ planner, shell }: MTripSheetsProps) {
const platform = meta.platform
const transitLegs: TransitLeg[] = Array.isArray(meta.transit?.legs) ? meta.transit.legs : []
+ // Per-segment booking codes (#1943), only on a real stopover booking: the
+ // single-leg fallback would just echo the booking's own code shown below.
+ const routeLegs = res.type === 'flight' ? getFlightLegs(res) : res.type === 'train' ? getTrainLegs(res) : []
+ const legCodes = routeLegs.length > 1 ? routeLegs.filter(l => l.confirmation_number) : []
+
const resFiles = (planner.files || []).filter(f =>
!f.deleted_at && (f.reservation_id === res.id || (f.linked_reservation_ids || []).includes(res.id)),
)
@@ -221,6 +227,25 @@ export default function MTransportSheet({ planner, shell }: MTripSheetsProps) {
)}
+ {/* ── Per-segment booking codes ── */}
+ {legCodes.length > 0 && (
+
+ {legCodes.map((leg, i) => (
+
+
+ {[leg.from, leg.to].filter(Boolean).join(' → ') || t('reservations.confirmationCode')}
+
+ { if (blurCodes) setCodeRevealed(v => !v) }}
+ className={`flex-none font-geist text-[0.71875rem] tabular-nums text-m-muted ${codeBlurred ? 'blur-[4px] select-none' : ''}`}
+ >
+ #{leg.confirmation_number}
+
+
+ ))}
+
+ )}
+
{/* ── Notes ── */}
{res.notes && (
<>
diff --git a/client/src/pages/SharedTripPage.test.tsx b/client/src/pages/SharedTripPage.test.tsx
index 10099aaa0..8a96ca8cb 100644
--- a/client/src/pages/SharedTripPage.test.tsx
+++ b/client/src/pages/SharedTripPage.test.tsx
@@ -568,7 +568,7 @@ describe('SharedTripPage', () => {
});
});
- // FE-PAGE-SHARED-021 to FE-PAGE-SHARED-036 drive the remaining render branches of
+ // FE-PAGE-SHARED-021 to FE-PAGE-SHARED-037 drive the remaining render branches of
// the page: header variants, the permission-driven tab strip, and every item kind
// the day timeline, bookings, packing, costs and chat sections can produce.
@@ -1134,4 +1134,34 @@ describe('SharedTripPage', () => {
expect(option.style.background).toBe('none');
});
});
+
+ describe('FE-PAGE-SHARED-037: a multi-day parking only shows on its drop-off and pickup day (#1937)', () => {
+ const days = [
+ { id: 9, trip_id: 1, day_number: 1, date: '2026-07-01', title: 'Day One' },
+ { id: 10, trip_id: 1, day_number: 2, date: '2026-07-02', title: 'Day Two' },
+ { id: 11, trip_id: 1, day_number: 3, date: '2026-07-03', title: 'Day Three' },
+ ];
+
+ it('skips the days in between and leaves that day collapsed', async () => {
+ await open('parking-token', payload({
+ days,
+ reservations: [
+ {
+ id: 81, title: 'Airport Parking', type: 'parking', status: 'confirmed', day_id: 9, end_day_id: 11,
+ reservation_time: '2026-07-01T05:30:00', reservation_end_time: '2026-07-03T19:00:00', metadata: null,
+ },
+ ],
+ }));
+
+ // Only one day is expanded at a time, so each day is checked on its own.
+ fireEvent.click(screen.getByText('Day One'));
+ await waitFor(() => expect(screen.getByText(/Airport Parking/)).toBeInTheDocument());
+
+ fireEvent.click(screen.getByText('Day Two'));
+ await waitFor(() => expect(screen.queryByText(/Airport Parking/)).toBeNull());
+
+ fireEvent.click(screen.getByText('Day Three'));
+ await waitFor(() => expect(screen.getByText(/Airport Parking/)).toBeInTheDocument());
+ });
+ });
});
diff --git a/client/src/pages/SharedTripPage.tsx b/client/src/pages/SharedTripPage.tsx
index d8ca68722..d16cc9df2 100644
--- a/client/src/pages/SharedTripPage.tsx
+++ b/client/src/pages/SharedTripPage.tsx
@@ -24,7 +24,7 @@ import { SUPPORTED_LANGUAGES, useTranslation } from '../i18n';
import { useSettingsStore } from '../store/settingsStore';
import { avatarSrc } from '../utils/avatarSrc';
import { safeHexColor } from '../utils/safeColor';
-import { getMergedItems, getTransportForDay } from '../utils/dayMerge';
+import { getMergedItems, getTransportForDay, hidesOnMiddleDay } from '../utils/dayMerge';
import { isDayInAccommodationRange } from '../utils/dayOrder';
import { getFlightLegs, getTrainLegs } from '../utils/flightLegs';
import { splitReservationDateTime } from '../utils/formatters';
@@ -443,12 +443,16 @@ export default function SharedTripPage() {
isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, sortedDays)
);
+ // The shared link has to say what the app says: a multi-day parking only
+ // shows up on its drop-off and pickup day (#1937). Filtered here rather
+ // than skipped in the loop below so the day body isn't gated open on a
+ // row that never renders.
const merged = getMergedItems({
dayAssignments: da,
dayNotes: notes,
dayTransports: dayTransport,
dayId: day.id,
- });
+ }).filter(item => !(item.type === 'transport' && hidesOnMiddleDay(item.data, day.id)));
return (
{
it('parses HH:MM string', () => {
@@ -34,6 +34,49 @@ describe('getSpanPhase', () => {
})
})
+describe('hidesOnMiddleDay', () => {
+ it('keeps a one-day parking on its only day', () => {
+ expect(hidesOnMiddleDay({ type: 'parking', day_id: 1, end_day_id: 1 }, 1)).toBe(false)
+ })
+
+ it('keeps a two-day parking on both days (no day in between exists)', () => {
+ const parking = { type: 'parking', day_id: 1, end_day_id: 2 }
+ expect(hidesOnMiddleDay(parking, 1)).toBe(false)
+ expect(hidesOnMiddleDay(parking, 2)).toBe(false)
+ })
+
+ it('hides a three-day parking only on the day in between', () => {
+ const parking = { type: 'parking', day_id: 1, end_day_id: 3 }
+ expect(hidesOnMiddleDay(parking, 1)).toBe(false)
+ expect(hidesOnMiddleDay(parking, 2)).toBe(true)
+ expect(hidesOnMiddleDay(parking, 3)).toBe(false)
+ })
+
+ it('hides every day in between of a longer parking span (#1937)', () => {
+ const parking = { type: 'parking', day_id: 1, end_day_id: 5 }
+ expect([1, 2, 3, 4, 5].map(d => hidesOnMiddleDay(parking, d)))
+ .toEqual([false, true, true, true, false])
+ })
+
+ it('leaves a car rental visible, since its middle days move to the day header', () => {
+ expect(hidesOnMiddleDay({ type: 'car', day_id: 1, end_day_id: 3 }, 2)).toBe(false)
+ })
+
+ it('leaves every other booking type alone', () => {
+ for (const type of ['train', 'cruise', 'event', 'hotel', 'other']) {
+ expect(hidesOnMiddleDay({ type, day_id: 1, end_day_id: 3 }, 2)).toBe(false)
+ }
+ })
+
+ it('keeps a parking whose end day is not part of the trip', () => {
+ expect(hidesOnMiddleDay({ type: 'parking', day_id: 1, end_day_id: 999 }, 1)).toBe(false)
+ })
+
+ it('keeps an unscheduled parking', () => {
+ expect(hidesOnMiddleDay({ type: 'parking', day_id: null, end_day_id: null }, 2)).toBe(false)
+ })
+})
+
describe('getTransportRouteEndpoints', () => {
const pickup = { role: 'from', lat: 48.1, lng: 11.5 }
const dropoff = { role: 'to', lat: 52.5, lng: 13.4 }
diff --git a/client/src/utils/dayMerge.ts b/client/src/utils/dayMerge.ts
index c117693b5..871668a2c 100644
--- a/client/src/utils/dayMerge.ts
+++ b/client/src/utils/dayMerge.ts
@@ -29,6 +29,26 @@ export function getSpanPhase(
return 'middle'
}
+/**
+ * Booking types that carry no information on the days between their start and end and
+ * therefore drop out of the day timeline there. A parked car just stands in the garage:
+ * you only need to know when you hand it over and when you collect it, so the days in
+ * between show nothing at all, not even a badge in the day header (#1937).
+ *
+ * A car rental is deliberately NOT in this set: its middle days are not dropped but
+ * moved into the day header, which is what people expect from a vehicle they are
+ * driving around. Kept as a set so another such type can join without editing every
+ * render site again.
+ */
+const MIDDLE_DAY_HIDDEN_TYPES = new Set(['parking'])
+
+export function hidesOnMiddleDay(
+ r: { type?: string | null; day_id?: number | null; end_day_id?: number | null },
+ dayId: number
+): boolean {
+ return !!r.type && MIDDLE_DAY_HIDDEN_TYPES.has(r.type) && getSpanPhase(r, dayId) === 'middle'
+}
+
/**
* The route waypoints a transport contributes on a given day, respecting multi-day spans.
* A car rental (or any reservation whose span covers several days) is only routed to on its
diff --git a/client/src/utils/flightLegs.test.ts b/client/src/utils/flightLegs.test.ts
index 9ae8b5af5..33d6e7bd8 100644
--- a/client/src/utils/flightLegs.test.ts
+++ b/client/src/utils/flightLegs.test.ts
@@ -50,3 +50,49 @@ describe('getTrainLegs (#1150)', () => {
expect(legs[0]).toMatchObject({ from: 'FRA', to: 'JFK', airline: 'LH', flight_number: 'LH 400' })
})
})
+
+describe('per-segment booking references (#1943)', () => {
+ it('reads each segment its own code, and leaves a code-less segment undefined', () => {
+ const flight = res({
+ type: 'flight',
+ confirmation_number: 'BOOK1',
+ metadata: JSON.stringify({
+ legs: [
+ { from: 'FRA', to: 'BER', confirmation_number: 'ABC123' },
+ { from: 'BER', to: 'HND' },
+ ],
+ }),
+ })
+ const legs = getFlightLegs(flight)
+ expect(legs[0].confirmation_number).toBe('ABC123')
+ // No fallback to the booking's own code: a segment either has one or it does
+ // not, and the booking's reference is shown separately.
+ expect(legs[1].confirmation_number).toBeUndefined()
+ })
+
+ it('reads a train segment code the same way', () => {
+ const train = res({
+ metadata: JSON.stringify({
+ legs: [
+ { from: 'Berlin Hbf', to: 'Frankfurt Hbf', confirmation_number: 'DB-1' },
+ { from: 'Frankfurt Hbf', to: 'München Hbf', confirmation_number: 'DB-2' },
+ ],
+ }),
+ })
+ expect(getTrainLegs(train).map(l => l.confirmation_number)).toEqual(['DB-1', 'DB-2'])
+ })
+
+ it('gives the derived single leg of a legacy booking the booking code', () => {
+ const flight = res({
+ type: 'flight', confirmation_number: 'BOOK1', day_id: 3,
+ metadata: JSON.stringify({ departure_airport: 'FRA', arrival_airport: 'JFK' }),
+ })
+ expect(getFlightLegs(flight)[0].confirmation_number).toBe('BOOK1')
+ const train = res({
+ confirmation_number: 'BOOK2',
+ endpoints: [ep('from', 0, 'Köln Hbf'), ep('to', 1, 'Aachen Hbf')],
+ metadata: '{}',
+ })
+ expect(getTrainLegs(train)[0].confirmation_number).toBe('BOOK2')
+ })
+})
diff --git a/client/src/utils/flightLegs.ts b/client/src/utils/flightLegs.ts
index d5ba77ff3..fa7179db0 100644
--- a/client/src/utils/flightLegs.ts
+++ b/client/src/utils/flightLegs.ts
@@ -19,6 +19,8 @@ export interface FlightLeg {
to: string | null
airline?: string
flight_number?: string
+ /** This segment's own booking reference, when it was booked separately (#1943). */
+ confirmation_number?: string
dep_day_id?: number | null
dep_time?: string | null // 'HH:mm'
arr_day_id?: number | null
@@ -59,6 +61,7 @@ export function getFlightLegs(r: Reservation): FlightLeg[] {
to: l.to ?? null,
airline: l.airline || undefined,
flight_number: l.flight_number || undefined,
+ confirmation_number: l.confirmation_number || undefined,
dep_day_id: l.dep_day_id ?? null,
dep_time: l.dep_time ?? null,
arr_day_id: l.arr_day_id ?? null,
@@ -77,6 +80,8 @@ export function getFlightLegs(r: Reservation): FlightLeg[] {
to: toCode,
airline: meta.airline || undefined,
flight_number: meta.flight_number || undefined,
+ // The single segment IS the booking, so its reference is the booking's.
+ confirmation_number: r.confirmation_number || undefined,
dep_day_id: r.day_id ?? null,
dep_time: first?.local_time ?? null,
arr_day_id: r.end_day_id ?? r.day_id ?? null,
@@ -95,6 +100,8 @@ export interface TrainLeg {
train_number?: string
platform?: string
seat?: string
+ /** This segment's own booking reference, when it was booked separately (#1943). */
+ confirmation_number?: string
dep_day_id?: number | null
dep_time?: string | null
arr_day_id?: number | null
@@ -115,6 +122,7 @@ export function getTrainLegs(r: Reservation): TrainLeg[] {
train_number: l.train_number || undefined,
platform: l.platform || undefined,
seat: l.seat || undefined,
+ confirmation_number: l.confirmation_number || undefined,
dep_day_id: l.dep_day_id ?? null,
dep_time: l.dep_time ?? null,
arr_day_id: l.arr_day_id ?? null,
@@ -133,6 +141,8 @@ export function getTrainLegs(r: Reservation): TrainLeg[] {
train_number: meta.train_number || undefined,
platform: meta.platform || undefined,
seat: meta.seat || undefined,
+ // The single segment IS the booking, so its reference is the booking's.
+ confirmation_number: r.confirmation_number || undefined,
dep_day_id: r.day_id ?? null,
dep_time: first?.local_time ?? null,
arr_day_id: r.end_day_id ?? r.day_id ?? null,
diff --git a/client/tests/unit/mobile/trip/MTransportFormSheet.test.tsx b/client/tests/unit/mobile/trip/MTransportFormSheet.test.tsx
index be5ecf4a5..dec2b9b68 100644
--- a/client/tests/unit/mobile/trip/MTransportFormSheet.test.tsx
+++ b/client/tests/unit/mobile/trip/MTransportFormSheet.test.tsx
@@ -8,7 +8,7 @@ import { buildPlanner } from '../../../helpers/mobileTrip'
import { resetAllStores, seedStore } from '../../../helpers/store'
import { act, fireEvent, render, screen } from '../../../helpers/render'
-// FE-MOB-TRFRM-001 to FE-MOB-TRFRM-046
+// FE-MOB-TRFRM-001 to FE-MOB-TRFRM-048
//
// The sheet's own pickers (airport/location search, day select, time picker) and
// the embedded transit panel are replaced by minimal controlled stand-ins so the
@@ -632,6 +632,62 @@ describe('MTransportFormSheet', () => {
expect(legs[1]).not.toHaveProperty('day_positions')
})
+ it('FE-MOB-TRFRM-047: each segment of a layover saves its own booking code (#1943)', async () => {
+ const handleSaveTransport = makeSave()
+ renderSheet(makePlanner({ handleSaveTransport }))
+ typeTitle('FRA-IST-HND')
+ // A direct flight writes no legs, so only the booking's own field is offered.
+ expect(screen.getAllByPlaceholderText('reservations.confirmationPlaceholder')).toHaveLength(1)
+
+ fireEvent.click(screen.getByRole('button', { name: 'reservations.layover.addStop' }))
+ setValue(airportInputs()[0], 'FRA')
+ setValue(airportInputs()[1], 'IST')
+ setValue(airportInputs()[2], 'HND')
+
+ const codes = screen.getAllByPlaceholderText('reservations.confirmationPlaceholder')
+ expect(codes).toHaveLength(3)
+ setValue(codes[0], 'ABC123')
+ setValue(codes[1], 'XYZ789')
+ setValue(codes[2], 'BOOK1')
+
+ await submit()
+
+ const payload = handleSaveTransport.mock.calls[0][0]
+ const legs = (payload.metadata as { legs: Record[] }).legs
+ expect(legs.map(l => l.confirmation_number)).toEqual(['ABC123', 'XYZ789'])
+ // The booking's own reference stays on its column, never on a segment.
+ expect(payload.confirmation_number).toBe('BOOK1')
+ expect(payload.metadata).not.toHaveProperty('confirmation_number')
+ })
+
+ it('FE-MOB-TRFRM-048: a re-save on the phone keeps the stored segment codes (#1943)', async () => {
+ const handleSaveTransport = makeSave()
+ const editingTransport = {
+ id: 45, trip_id: 1, type: 'flight', title: 'FRA-IST-HND', status: 'pending',
+ day_id: 11, end_day_id: 12, confirmation_number: 'BOOK1',
+ metadata: {
+ legs: [
+ { from: 'FRA', to: 'IST', confirmation_number: 'ABC123', dep_day_id: 11, dep_time: '10:20', arr_day_id: 11, arr_time: '14:05' },
+ { from: 'IST', to: 'HND', confirmation_number: 'XYZ789', dep_day_id: 11, dep_time: '16:30', arr_day_id: 12, arr_time: '09:00' },
+ ],
+ },
+ endpoints: [
+ { id: 1, reservation_id: 45, role: 'from', sequence: 0, name: 'Frankfurt (FRA)', code: 'FRA', lat: 50.03, lng: 8.57, timezone: 'Europe/Berlin', local_date: null, local_time: null },
+ { id: 2, reservation_id: 45, role: 'stop', sequence: 1, name: 'Istanbul (IST)', code: 'IST', lat: 41.27, lng: 28.75, timezone: 'Europe/Istanbul', local_date: null, local_time: null },
+ { id: 3, reservation_id: 45, role: 'to', sequence: 2, name: 'Tokyo (HND)', code: 'HND', lat: 35.55, lng: 139.78, timezone: 'Asia/Tokyo', local_date: null, local_time: null },
+ ],
+ } as unknown as Reservation
+ renderSheet(makePlanner({ editingTransport, handleSaveTransport }))
+
+ const codes = screen.getAllByPlaceholderText('reservations.confirmationPlaceholder') as HTMLInputElement[]
+ expect(codes.map(i => i.value)).toEqual(['ABC123', 'XYZ789', 'BOOK1'])
+
+ await submit('common.update')
+
+ const legs = (handleSaveTransport.mock.calls[0][0].metadata as { legs: Record[] }).legs
+ expect(legs.map(l => l.confirmation_number)).toEqual(['ABC123', 'XYZ789'])
+ })
+
it('FE-MOB-TRFRM-023: an edited flight without endpoints falls back to the reservation times', () => {
const editingTransport = {
id: 44, trip_id: 1, type: 'flight', title: 'Legacy flight', status: 'pending',
diff --git a/client/tests/unit/mobile/trip/MTransportSheet.test.tsx b/client/tests/unit/mobile/trip/MTransportSheet.test.tsx
index d91d60070..a5488a3a8 100644
--- a/client/tests/unit/mobile/trip/MTransportSheet.test.tsx
+++ b/client/tests/unit/mobile/trip/MTransportSheet.test.tsx
@@ -7,7 +7,7 @@ import { buildPlanner, buildShell } from '../../../helpers/mobileTrip'
import { resetAllStores, seedStore } from '../../../helpers/store'
import { fireEvent, render, screen } from '../../../helpers/render'
-// FE-MOB-TRSH-001 to FE-MOB-TRSH-020
+// FE-MOB-TRSH-001 to FE-MOB-TRSH-024
vi.mock('../../../../src/utils/fileDownload', () => ({ openFile: vi.fn() }))
@@ -265,6 +265,36 @@ describe('MTransportSheet', () => {
expect(screen.getByText('FRA → HND')).toBeInTheDocument()
})
+ const layover = (): Reservation => ({
+ ...FLIGHT,
+ metadata: JSON.stringify({
+ airline: 'Lufthansa', flight_number: 'LH 716',
+ legs: [
+ { from: 'FRA', to: 'BER', airline: 'Lufthansa', flight_number: 'LH 716', confirmation_number: 'ABC123' },
+ { from: 'BER', to: 'HND', airline: 'ANA', flight_number: 'NH 204' },
+ ],
+ }),
+ } as unknown as Reservation)
+
+ it('FE-MOB-TRSH-023: lists the booking code of every segment that has its own (#1943)', () => {
+ render()
+ // The booking's own reference keeps its row above.
+ expect(screen.getByText('#QW7788')).toBeInTheDocument()
+ expect(screen.getByText('FRA → BER')).toBeInTheDocument()
+ expect(screen.getByText('#ABC123')).toBeInTheDocument()
+ // A segment without one contributes no row.
+ expect(screen.queryByText('BER → HND')).not.toBeInTheDocument()
+ })
+
+ it('FE-MOB-TRSH-024: a segment code is blurred until it is tapped', () => {
+ seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, blur_booking_codes: true } })
+ render()
+ const code = screen.getByText('#ABC123')
+ expect(code.className).toContain('blur-[4px]')
+ fireEvent.click(code)
+ expect(screen.getByText('#ABC123').className).not.toContain('blur-[4px]')
+ })
+
it('FE-MOB-TRSH-022: closes from the header button', () => {
const shell = makeShell()
render()
diff --git a/client/tests/unit/mobile/trip/planTimelineModel.test.ts b/client/tests/unit/mobile/trip/planTimelineModel.test.ts
index 2df86b296..725d0a11c 100644
--- a/client/tests/unit/mobile/trip/planTimelineModel.test.ts
+++ b/client/tests/unit/mobile/trip/planTimelineModel.test.ts
@@ -11,7 +11,7 @@ import type {
Accommodation, Assignment, Day, DayNote, Reservation, RouteSegment, TranslationFn,
} from '../../../../src/types'
-// FE-MOB-PTLM-001 to FE-MOB-PTLM-042
+// FE-MOB-PTLM-001 to FE-MOB-PTLM-043
const DAYS = [
{ id: 1, trip_id: 1, day_number: 1, date: '2026-05-01', title: null },
@@ -192,6 +192,13 @@ describe('planTimelineModel — buildPlanRows', () => {
expect(buildPlanRows({ merged: [transportItem(car)], reservations: [], routeSegments: [], dayId: 1 })).toHaveLength(1)
})
+ it('FE-MOB-PTLM-043: hides a parking on the days between drop-off and pickup (#1937)', () => {
+ const parking = buildReservation({ id: 63, type: 'parking', day_id: 1, end_day_id: 3 })
+ expect(buildPlanRows({ merged: [transportItem(parking)], reservations: [], routeSegments: [], dayId: 2 })).toEqual([])
+ expect(buildPlanRows({ merged: [transportItem(parking)], reservations: [], routeSegments: [], dayId: 1 })).toHaveLength(1)
+ expect(buildPlanRows({ merged: [transportItem(parking)], reservations: [], routeSegments: [], dayId: 3 })).toHaveLength(1)
+ })
+
it('FE-MOB-PTLM-017: slots a connector between two located places and tags its origin', () => {
const leg = seg([48.1, 16.1], [48.2, 16.2])
const rows = buildPlanRows({
diff --git a/server/src/nest/calendar/calendar.service.ts b/server/src/nest/calendar/calendar.service.ts
index aa232a945..4dc31f582 100644
--- a/server/src/nest/calendar/calendar.service.ts
+++ b/server/src/nest/calendar/calendar.service.ts
@@ -412,6 +412,14 @@ export class CalendarService {
// Multi-leg flight: show the whole route (FRA → BER → HND) on one event.
const stops = [meta.legs[0]?.from, ...meta.legs.map((l: { to?: string }) => l.to)].filter(Boolean);
if (stops.length) desc += `\nRoute: ${stops.join(' → ')}`;
+ // A segment booked under its own reference gets its own line (#1943): at
+ // the gate it is that code the airline asks for, not the booking's. No
+ // leg reference, no extra line, so an existing feed stays byte-identical.
+ for (const leg of meta.legs as { from?: string; to?: string; confirmation_number?: string }[]) {
+ if (!leg.confirmation_number) continue;
+ const segment = [leg.from, leg.to].filter(Boolean).join('-');
+ desc += `\nConfirmation${segment ? ` ${segment}` : ''}: ${leg.confirmation_number}`;
+ }
} else if (meta.departure_airport || meta.arrival_airport) {
if (meta.departure_airport) desc += `\nFrom: ${meta.departure_airport}`;
if (meta.arrival_airport) desc += `\nTo: ${meta.arrival_airport}`;
diff --git a/server/src/nest/reservations/reservations.mcp.ts b/server/src/nest/reservations/reservations.mcp.ts
index f8b3926ba..869528099 100644
--- a/server/src/nest/reservations/reservations.mcp.ts
+++ b/server/src/nest/reservations/reservations.mcp.ts
@@ -90,6 +90,7 @@ function parseId(value: string | string[]): number | null {
const legsSchema = transportLegsInputSchema.optional().describe(
'Per-segment detail of a stopover flight or train: one entry per segment, in route order, exactly ONE FEWER than endpoints[]. '
+ 'Each leg carries its own departure and arrival day + local time (dep_day_id/dep_time, arr_day_id/arr_time) plus airline/flight_number (flights) or train_number/platform (trains). '
+ + 'A leg may also carry its own confirmation_number when that segment was issued a separate booking reference; leave it out and the booking-level confirmation_number covers the segment. '
+ "Required for a booking with stopovers: a stop's endpoint stores only the onward departure, so without legs that one time is shown as both the arrival and the departure. "
+ 'Omit it for a direct booking. Blank endpoint times/dates, day_id/end_day_id and reservation_time are derived from the legs; a value that contradicts them is rejected.'
);
@@ -207,7 +208,7 @@ function applyLegs(plan: LegPlan): LegOutcome | { error: string } {
from: leg.from ?? depEp.code ?? depEp.name,
to: leg.to ?? arrEp.code ?? arrEp.name,
};
- for (const key of ['airline', 'flight_number', 'train_number', 'platform', 'seat'] as const) {
+ for (const key of ['airline', 'flight_number', 'train_number', 'platform', 'seat', 'confirmation_number'] as const) {
const value = leg[key];
if (value) entry[key] = value;
}
@@ -570,7 +571,7 @@ export class ReservationsMcp {
@Tool({
name: 'create_transport',
- description: 'Create a transport booking (flight, train, car, or cruise) for a trip. Use endpoints[] to record origin/destination and intermediate stops — for flights, set code to the IATA airport code (use search_airports first). For a booking WITH STOPOVERS also pass legs[] (one entry per segment, one fewer than endpoints[]), otherwise every segment inherits the stop time as both its arrival and its departure. Created as pending — confirm with update_transport. Set price to record the cost; it will appear on the booking and in the Budget tab.',
+ description: 'Create a transport booking (flight, train, car, or cruise) for a trip. Use endpoints[] to record origin/destination and intermediate stops — for flights, set code to the IATA airport code (use search_airports first). For a booking WITH STOPOVERS also pass legs[] (one entry per segment, one fewer than endpoints[]), otherwise every segment inherits the stop time as both its arrival and its departure. The top-level confirmation_number is the booking reference; when a single segment was booked under its own reference, put that one on the leg instead. Created as pending — confirm with update_transport. Set price to record the cost; it will appear on the booking and in the Budget tab.',
inputSchema: {
tripId: z.number().int().positive(),
type: z.enum(['flight', 'train', 'car', 'cruise']),
@@ -678,7 +679,7 @@ export class ReservationsMcp {
@Tool({
name: 'update_transport',
- description: 'Update an existing transport booking. Pass endpoints[] to replace the full list of stops (origin, destination, intermediates), and legs[] to write the per-segment times of a stopover booking. Sending legs[] without metadata keeps the stored metadata (departure_airport, airtrail_ids, transit) and only replaces the segments. Use status "confirmed" to confirm.',
+ description: 'Update an existing transport booking. Pass endpoints[] to replace the full list of stops (origin, destination, intermediates), and legs[] to write the per-segment times of a stopover booking. Sending legs[] without metadata keeps the stored metadata (departure_airport, airtrail_ids, transit) and only replaces the segments. A per-segment booking reference lives on the leg (legs[].confirmation_number); the top-level one stays the booking reference. Use status "confirmed" to confirm.',
inputSchema: {
tripId: z.number().int().positive(),
reservationId: z.number().int().positive(),
diff --git a/server/tests/unit/mcp/tools-transports.test.ts b/server/tests/unit/mcp/tools-transports.test.ts
index 65fe84f40..f118cb353 100644
--- a/server/tests/unit/mcp/tools-transports.test.ts
+++ b/server/tests/unit/mcp/tools-transports.test.ts
@@ -506,6 +506,33 @@ describe('Tool: create_transport (multi-leg)', () => {
});
});
+ it('stores a per-segment booking reference and leaves the booking-level one alone (#1943)', async () => {
+ const { user } = createUser(testDb);
+ const trip = createTrip(testDb, user.id);
+ await withHarness(user.id, async (h) => {
+ const data = parseToolResult(await h.client.callTool({
+ name: 'create_transport',
+ arguments: {
+ tripId: trip.id, type: 'flight', title: 'AMS to FCO',
+ endpoints: stopoverEndpoints,
+ legs: [
+ { from: 'AMS', to: 'CDG', confirmation_number: 'ABC123' },
+ { from: 'CDG', to: 'FCO' },
+ ],
+ },
+ })) as any;
+ const meta = storedMetadata(data.reservation.id);
+ expect(meta.legs[0].confirmation_number).toBe('ABC123');
+ // A leg without one gets no key at all, rather than an undefined that
+ // serialises away and back as null.
+ expect(Object.keys(meta.legs[1])).not.toContain('confirmation_number');
+ // The reference of a single segment must never become the booking's: that
+ // is a column of its own, fed only by the tool's own parameter.
+ expect(meta.confirmation_number).toBeUndefined();
+ expect(data.reservation.confirmation_number).toBeNull();
+ });
+ });
+
it('never overwrites a flat key the caller set itself', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
@@ -806,6 +833,29 @@ describe('Tool: update_transport (multi-leg)', () => {
});
});
+ it('keeps a per-segment booking reference through a legs-only update (#1943)', async () => {
+ const { user } = createUser(testDb);
+ const trip = createTrip(testDb, user.id);
+ await withHarness(user.id, async (h) => {
+ const created = await seedStopover(h, trip.id);
+ const result = await h.client.callTool({
+ name: 'update_transport',
+ arguments: {
+ tripId: trip.id, reservationId: created.reservation.id,
+ legs: [
+ { from: 'AMS', to: 'CDG', dep_time: '09:00', arr_time: '10:00', confirmation_number: 'ABC123' },
+ { from: 'CDG', to: 'FCO', dep_time: '11:00', arr_time: '12:00', confirmation_number: 'XYZ789' },
+ ],
+ },
+ });
+ const meta = storedMetadata((parseToolResult(result) as any).reservation.id);
+ // The copy-through list in applyLegs is what carries the field: a field
+ // missing from it is accepted by the schema and then silently dropped.
+ expect(meta.legs.map((l: any) => l.confirmation_number)).toEqual(['ABC123', 'XYZ789']);
+ expect(meta.confirmation_number).toBeUndefined();
+ });
+ });
+
it('uses the supplied metadata as the base when both are sent', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
diff --git a/server/tests/unit/nest/calendar.service.test.ts b/server/tests/unit/nest/calendar.service.test.ts
index 5ec1b1ef2..cf04e4e77 100644
--- a/server/tests/unit/nest/calendar.service.test.ts
+++ b/server/tests/unit/nest/calendar.service.test.ts
@@ -595,6 +595,44 @@ describe('exportICS', () => {
expect(ics).not.toContain('Route:');
});
+ it('CAL-043: a segment with its own booking reference gets its own Confirmation line (#1943)', () => {
+ const { user } = createUser(testDb);
+ const trip = createTrip(testDb, user.id, { title: 'Layover' });
+ const flight = createReservation(testDb, trip.id, { title: 'FRA to HND', type: 'flight' });
+ testDb.prepare('UPDATE reservations SET reservation_time=?, confirmation_number=?, metadata=? WHERE id=?').run(
+ '2025-06-02T09:00', 'BOOK1',
+ JSON.stringify({ legs: [
+ { from: 'FRA', to: 'BER', confirmation_number: 'ABC123' },
+ { from: 'BER', to: 'HND', confirmation_number: 'XYZ789' },
+ ] }),
+ flight.id,
+ );
+
+ const ics = svc.exportICS(trip.id).ics.replace(/\r\n /g, '');
+
+ expect(ics).toContain(
+ 'DESCRIPTION:Type: flight\\nConfirmation: BOOK1\\nRoute: FRA → BER → HND'
+ + '\\nConfirmation FRA-BER: ABC123\\nConfirmation BER-HND: XYZ789\r\n'
+ );
+ });
+
+ it('CAL-044: a multi-leg flight without per-segment references reads exactly as before', () => {
+ const { user } = createUser(testDb);
+ const trip = createTrip(testDb, user.id, { title: 'Layover' });
+ const flight = createReservation(testDb, trip.id, { title: 'FRA to HND', type: 'flight' });
+ testDb.prepare('UPDATE reservations SET reservation_time=?, confirmation_number=?, metadata=? WHERE id=?').run(
+ '2025-06-02T09:00', 'BOOK1',
+ JSON.stringify({ legs: [{ from: 'FRA', to: 'BER' }, { from: 'BER', to: 'HND' }] }),
+ flight.id,
+ );
+
+ const ics = svc.exportICS(trip.id).ics.replace(/\r\n /g, '');
+
+ // Byte parity for every feed that exists today: the description ends after
+ // the route line, so no subscriber sees a changed event.
+ expect(ics).toContain('DESCRIPTION:Type: flight\\nConfirmation: BOOK1\\nRoute: FRA → BER → HND\r\n');
+ });
+
it('CAL-020: an empty trip title falls back for SUMMARY, X-WR-CALNAME and the filename', () => {
const { user } = createUser(testDb);
// The title is only NOT NULL, not non-empty; an empty one used to produce
diff --git a/shared/src/i18n/ar/reservations.ts b/shared/src/i18n/ar/reservations.ts
index 2ac79509e..0d6f065b1 100644
--- a/shared/src/i18n/ar/reservations.ts
+++ b/shared/src/i18n/ar/reservations.ts
@@ -116,6 +116,7 @@ const reservations: TranslationStrings = {
'reservations.span.arrival': 'الوصول',
'reservations.span.inTransit': 'في الطريق',
'reservations.span.pickup': 'الاستلام',
+ 'reservations.span.dropOff': 'التسليم',
'reservations.span.return': 'الإرجاع',
'reservations.span.active': 'نشط',
'reservations.span.start': 'البداية',
diff --git a/shared/src/i18n/br/reservations.ts b/shared/src/i18n/br/reservations.ts
index 8e9cf325c..b945b8c03 100644
--- a/shared/src/i18n/br/reservations.ts
+++ b/shared/src/i18n/br/reservations.ts
@@ -116,6 +116,7 @@ const reservations: TranslationStrings = {
'reservations.span.arrival': 'Chegada',
'reservations.span.inTransit': 'Em trânsito',
'reservations.span.pickup': 'Retirada',
+ 'reservations.span.dropOff': 'Entrega',
'reservations.span.return': 'Devolução',
'reservations.span.active': 'Ativo',
'reservations.span.start': 'Início',
diff --git a/shared/src/i18n/ca/reservations.ts b/shared/src/i18n/ca/reservations.ts
index 55733a858..8439c90f9 100644
--- a/shared/src/i18n/ca/reservations.ts
+++ b/shared/src/i18n/ca/reservations.ts
@@ -89,6 +89,7 @@ const reservations: TranslationStrings = {
'reservations.span.arrival': 'Arribada',
'reservations.span.inTransit': 'En trànsit',
'reservations.span.pickup': 'Recollida',
+ 'reservations.span.dropOff': 'Lliurament',
'reservations.span.return': 'Devolució',
'reservations.span.active': 'Actiu',
'reservations.span.start': 'Inici',
diff --git a/shared/src/i18n/cs/reservations.ts b/shared/src/i18n/cs/reservations.ts
index 503fdf4bc..48a9a9089 100644
--- a/shared/src/i18n/cs/reservations.ts
+++ b/shared/src/i18n/cs/reservations.ts
@@ -116,6 +116,7 @@ const reservations: TranslationStrings = {
'reservations.span.arrival': 'Přílet',
'reservations.span.inTransit': 'Na cestě',
'reservations.span.pickup': 'Vyzvednutí',
+ 'reservations.span.dropOff': 'Odevzdání',
'reservations.span.return': 'Vrácení',
'reservations.span.active': 'Aktivní',
'reservations.span.start': 'Začátek',
diff --git a/shared/src/i18n/de/reservations.ts b/shared/src/i18n/de/reservations.ts
index 1bcbf344a..7ce74be4b 100644
--- a/shared/src/i18n/de/reservations.ts
+++ b/shared/src/i18n/de/reservations.ts
@@ -119,6 +119,7 @@ const reservations: TranslationStrings = {
'reservations.span.arrival': 'Ankunft',
'reservations.span.inTransit': 'Unterwegs',
'reservations.span.pickup': 'Abholung',
+ 'reservations.span.dropOff': 'Abgabe',
'reservations.span.return': 'Rückgabe',
'reservations.span.active': 'Aktiv',
'reservations.span.start': 'Start',
diff --git a/shared/src/i18n/en/reservations.ts b/shared/src/i18n/en/reservations.ts
index 282910ee2..8a71978a8 100644
--- a/shared/src/i18n/en/reservations.ts
+++ b/shared/src/i18n/en/reservations.ts
@@ -117,7 +117,11 @@ const reservations: TranslationStrings = {
'reservations.span.departure': 'Departure',
'reservations.span.arrival': 'Arrival',
'reservations.span.inTransit': 'In transit',
+ // span.pickup does double duty: the day a rental car is picked up, and the day a
+ // multi-day parking ends and you collect your own car again. Both read the same way
+ // in every locale, so parking only needs the drop-off wording of its own.
'reservations.span.pickup': 'Pickup',
+ 'reservations.span.dropOff': 'Drop-off',
'reservations.span.return': 'Return',
'reservations.span.active': 'Active',
'reservations.span.start': 'Start',
diff --git a/shared/src/i18n/es/reservations.ts b/shared/src/i18n/es/reservations.ts
index b8eef6615..15a8428c4 100644
--- a/shared/src/i18n/es/reservations.ts
+++ b/shared/src/i18n/es/reservations.ts
@@ -89,6 +89,7 @@ const reservations: TranslationStrings = {
'reservations.span.arrival': 'Llegada',
'reservations.span.inTransit': 'En tránsito',
'reservations.span.pickup': 'Recogida',
+ 'reservations.span.dropOff': 'Entrega',
'reservations.span.return': 'Devolución',
'reservations.span.active': 'Activo',
'reservations.span.start': 'Inicio',
diff --git a/shared/src/i18n/fr/reservations.ts b/shared/src/i18n/fr/reservations.ts
index f16cc7ccd..1a518a31b 100644
--- a/shared/src/i18n/fr/reservations.ts
+++ b/shared/src/i18n/fr/reservations.ts
@@ -118,6 +118,7 @@ const reservations: TranslationStrings = {
'reservations.span.arrival': 'Arrivée',
'reservations.span.inTransit': 'En transit',
'reservations.span.pickup': 'Prise en charge',
+ 'reservations.span.dropOff': 'Dépôt',
'reservations.span.return': 'Restitution',
'reservations.span.active': 'Actif',
'reservations.span.start': 'Début',
diff --git a/shared/src/i18n/gr/reservations.ts b/shared/src/i18n/gr/reservations.ts
index add068375..2b15473a8 100644
--- a/shared/src/i18n/gr/reservations.ts
+++ b/shared/src/i18n/gr/reservations.ts
@@ -118,6 +118,7 @@ const reservations: TranslationStrings = {
'reservations.span.arrival': 'Άφιξη',
'reservations.span.inTransit': 'Σε διέλευση',
'reservations.span.pickup': 'Παραλαβή',
+ 'reservations.span.dropOff': 'Παράδοση',
'reservations.span.return': 'Επιστροφή',
'reservations.span.active': 'Ενεργό',
'reservations.span.start': 'Έναρξη',
diff --git a/shared/src/i18n/hu/reservations.ts b/shared/src/i18n/hu/reservations.ts
index 5e77ccfb5..4c1a8fbe6 100644
--- a/shared/src/i18n/hu/reservations.ts
+++ b/shared/src/i18n/hu/reservations.ts
@@ -117,6 +117,7 @@ const reservations: TranslationStrings = {
'reservations.span.arrival': 'Érkezés',
'reservations.span.inTransit': 'Úton',
'reservations.span.pickup': 'Felvétel',
+ 'reservations.span.dropOff': 'Leadás',
'reservations.span.return': 'Visszaadás',
'reservations.span.active': 'Aktív',
'reservations.span.start': 'Kezdés',
diff --git a/shared/src/i18n/id/reservations.ts b/shared/src/i18n/id/reservations.ts
index 25b2ffd94..6e0877372 100644
--- a/shared/src/i18n/id/reservations.ts
+++ b/shared/src/i18n/id/reservations.ts
@@ -117,6 +117,7 @@ const reservations: TranslationStrings = {
'reservations.span.arrival': 'Kedatangan',
'reservations.span.inTransit': 'Dalam perjalanan',
'reservations.span.pickup': 'Penjemputan',
+ 'reservations.span.dropOff': 'Penyerahan',
'reservations.span.return': 'Pengembalian',
'reservations.span.active': 'Aktif',
'reservations.span.start': 'Mulai',
diff --git a/shared/src/i18n/it/reservations.ts b/shared/src/i18n/it/reservations.ts
index 2954b525b..1d2da1fa4 100644
--- a/shared/src/i18n/it/reservations.ts
+++ b/shared/src/i18n/it/reservations.ts
@@ -117,6 +117,7 @@ const reservations: TranslationStrings = {
'reservations.span.arrival': 'Arrivo',
'reservations.span.inTransit': 'In transito',
'reservations.span.pickup': 'Ritiro',
+ 'reservations.span.dropOff': 'Consegna',
'reservations.span.return': 'Riconsegna',
'reservations.span.active': 'Attivo',
'reservations.span.start': 'Inizio',
diff --git a/shared/src/i18n/ja/reservations.ts b/shared/src/i18n/ja/reservations.ts
index 37934e506..f05a9dc88 100644
--- a/shared/src/i18n/ja/reservations.ts
+++ b/shared/src/i18n/ja/reservations.ts
@@ -116,6 +116,7 @@ const reservations: TranslationStrings = {
'reservations.span.arrival': '到着',
'reservations.span.inTransit': '移動中',
'reservations.span.pickup': '受取',
+ 'reservations.span.dropOff': '預け入れ',
'reservations.span.return': '返却',
'reservations.span.active': '有効',
'reservations.span.start': '開始',
diff --git a/shared/src/i18n/ko/reservations.ts b/shared/src/i18n/ko/reservations.ts
index 34003a5bb..bd965f15a 100644
--- a/shared/src/i18n/ko/reservations.ts
+++ b/shared/src/i18n/ko/reservations.ts
@@ -116,6 +116,7 @@ const reservations: TranslationStrings = {
'reservations.span.arrival': '도착',
'reservations.span.inTransit': '이동 중',
'reservations.span.pickup': '픽업',
+ 'reservations.span.dropOff': '입차',
'reservations.span.return': '반납',
'reservations.span.active': '활성',
'reservations.span.start': '시작',
diff --git a/shared/src/i18n/nl/reservations.ts b/shared/src/i18n/nl/reservations.ts
index 1009699c2..b97bd9bac 100644
--- a/shared/src/i18n/nl/reservations.ts
+++ b/shared/src/i18n/nl/reservations.ts
@@ -117,6 +117,7 @@ const reservations: TranslationStrings = {
'reservations.span.arrival': 'Aankomst',
'reservations.span.inTransit': 'Onderweg',
'reservations.span.pickup': 'Ophalen',
+ 'reservations.span.dropOff': 'Inleveren',
'reservations.span.return': 'Inleveren',
'reservations.span.active': 'Actief',
'reservations.span.start': 'Start',
diff --git a/shared/src/i18n/pl/reservations.ts b/shared/src/i18n/pl/reservations.ts
index 48a701712..8444da3cd 100644
--- a/shared/src/i18n/pl/reservations.ts
+++ b/shared/src/i18n/pl/reservations.ts
@@ -117,6 +117,7 @@ const reservations: TranslationStrings = {
'reservations.span.arrival': 'Przylot',
'reservations.span.inTransit': 'W tranzycie',
'reservations.span.pickup': 'Odbiór',
+ 'reservations.span.dropOff': 'Oddanie',
'reservations.span.return': 'Zwrot',
'reservations.span.active': 'Aktywny',
'reservations.span.start': 'Start',
diff --git a/shared/src/i18n/ru/reservations.ts b/shared/src/i18n/ru/reservations.ts
index feb426934..6b372e9b5 100644
--- a/shared/src/i18n/ru/reservations.ts
+++ b/shared/src/i18n/ru/reservations.ts
@@ -116,6 +116,7 @@ const reservations: TranslationStrings = {
'reservations.span.arrival': 'Прилёт',
'reservations.span.inTransit': 'В пути',
'reservations.span.pickup': 'Получение',
+ 'reservations.span.dropOff': 'Сдача',
'reservations.span.return': 'Возврат',
'reservations.span.active': 'Активно',
'reservations.span.start': 'Начало',
diff --git a/shared/src/i18n/sv/reservations.ts b/shared/src/i18n/sv/reservations.ts
index 3b9b79835..108f8f829 100644
--- a/shared/src/i18n/sv/reservations.ts
+++ b/shared/src/i18n/sv/reservations.ts
@@ -117,6 +117,7 @@ const reservations: TranslationStrings = {
'reservations.span.arrival': 'Ankomst',
'reservations.span.inTransit': 'Under transport',
'reservations.span.pickup': 'Upphämtning',
+ 'reservations.span.dropOff': 'Inlämning',
'reservations.span.return': 'Återlämning',
'reservations.span.active': 'Aktiv',
'reservations.span.start': 'Start',
diff --git a/shared/src/i18n/tr/reservations.ts b/shared/src/i18n/tr/reservations.ts
index cf6b52b37..5184fa1b5 100644
--- a/shared/src/i18n/tr/reservations.ts
+++ b/shared/src/i18n/tr/reservations.ts
@@ -117,6 +117,7 @@ const reservations: TranslationStrings = {
'reservations.span.arrival': 'Varış',
'reservations.span.inTransit': 'Yolda',
'reservations.span.pickup': 'Alış',
+ 'reservations.span.dropOff': 'Teslim',
'reservations.span.return': 'İade',
'reservations.span.active': 'Aktif',
'reservations.span.start': 'Başlangıç',
diff --git a/shared/src/i18n/uk/reservations.ts b/shared/src/i18n/uk/reservations.ts
index cb4fe5bf8..c6e562707 100644
--- a/shared/src/i18n/uk/reservations.ts
+++ b/shared/src/i18n/uk/reservations.ts
@@ -116,6 +116,7 @@ const reservations: TranslationStrings = {
'reservations.span.arrival': 'Приліт',
'reservations.span.inTransit': 'У дорозі',
'reservations.span.pickup': 'Отримання',
+ 'reservations.span.dropOff': 'Здача',
'reservations.span.return': 'Повернення',
'reservations.span.active': 'Активно',
'reservations.span.start': 'Початок',
diff --git a/shared/src/i18n/vi/reservations.ts b/shared/src/i18n/vi/reservations.ts
index 020ecb70a..3e5f0fb68 100644
--- a/shared/src/i18n/vi/reservations.ts
+++ b/shared/src/i18n/vi/reservations.ts
@@ -117,6 +117,7 @@ const reservations: TranslationStrings = {
'reservations.span.arrival': 'Đến',
'reservations.span.inTransit': 'Đang chuyển tiếp',
'reservations.span.pickup': 'Đón',
+ 'reservations.span.dropOff': 'Gửi xe',
'reservations.span.return': 'Trở lại',
'reservations.span.active': 'Tích cực',
'reservations.span.start': 'Bắt đầu',
diff --git a/shared/src/i18n/zh-TW/reservations.ts b/shared/src/i18n/zh-TW/reservations.ts
index d684ae749..057e05df7 100644
--- a/shared/src/i18n/zh-TW/reservations.ts
+++ b/shared/src/i18n/zh-TW/reservations.ts
@@ -116,6 +116,7 @@ const reservations: TranslationStrings = {
'reservations.span.arrival': '到達',
'reservations.span.inTransit': '途中',
'reservations.span.pickup': '取車',
+ 'reservations.span.dropOff': '停放',
'reservations.span.return': '還車',
'reservations.span.active': '進行中',
'reservations.span.start': '開始',
diff --git a/shared/src/i18n/zh/reservations.ts b/shared/src/i18n/zh/reservations.ts
index 197991f25..e46365b14 100644
--- a/shared/src/i18n/zh/reservations.ts
+++ b/shared/src/i18n/zh/reservations.ts
@@ -116,6 +116,7 @@ const reservations: TranslationStrings = {
'reservations.span.arrival': '到达',
'reservations.span.inTransit': '途中',
'reservations.span.pickup': '取车',
+ 'reservations.span.dropOff': '停放',
'reservations.span.return': '还车',
'reservations.span.active': '使用中',
'reservations.span.start': '开始',
diff --git a/shared/src/reservation/reservation.schema.spec.ts b/shared/src/reservation/reservation.schema.spec.ts
index a2ceac57b..2741a6b06 100644
--- a/shared/src/reservation/reservation.schema.spec.ts
+++ b/shared/src/reservation/reservation.schema.spec.ts
@@ -95,6 +95,17 @@ describe('transportLegInputSchema', () => {
expect(transportLegInputSchema.safeParse({ dep_time: 42 }).success).toBe(false);
});
+ it('accepts a per-segment booking reference and its absence (#1943)', () => {
+ expect(
+ transportLegInputSchema.safeParse({ from: 'AMS', to: 'CDG', confirmation_number: 'ABC123' }).success,
+ ).toBe(true);
+ // null is what a form writes for a segment the user left blank.
+ expect(transportLegInputSchema.safeParse({ confirmation_number: null }).success).toBe(true);
+ // Same ceiling as the reservation-level reference on the MCP tools.
+ expect(transportLegInputSchema.safeParse({ confirmation_number: 'A'.repeat(100) }).success).toBe(true);
+ expect(transportLegInputSchema.safeParse({ confirmation_number: 'A'.repeat(101) }).success).toBe(false);
+ });
+
it('parses a whole leg list', () => {
const parsed = transportLegsInputSchema.safeParse([
{ from: 'AMS', to: 'CDG', dep_time: '09:00', arr_time: '10:00' },
diff --git a/shared/src/reservation/reservation.schema.ts b/shared/src/reservation/reservation.schema.ts
index 6064be70c..a0c239b2f 100644
--- a/shared/src/reservation/reservation.schema.ts
+++ b/shared/src/reservation/reservation.schema.ts
@@ -101,6 +101,14 @@ export const transportLegInputSchema = z.object({
train_number: z.string().max(20).nullable().optional(),
platform: z.string().max(20).nullable().optional(),
seat: z.string().max(20).nullable().optional(),
+ /**
+ * This segment's OWN booking reference (#1943). Airlines and railways often
+ * issue one per flight instead of one per booking, so the reservation-level
+ * `confirmation_number` cannot cover the chain. Unset means the booking's own
+ * reference is the one that counts for this segment; the column stays the
+ * reference for the booking as a whole and is never derived from a leg.
+ */
+ confirmation_number: z.string().max(100).nullable().optional(),
dep_day_id: z.number().int().positive().nullable().optional(),
/** Local departure time of this segment, 'HH:mm'. */
dep_time: z.string().max(10).nullable().optional(),