From 0a7e4602532d68b89bc05c4a08a12d4314f9ea0a Mon Sep 17 00:00:00 2001 From: Maurice Date: Mon, 17 Aug 2026 14:21:44 +0200 Subject: [PATCH] fix(planner): keep a parked car off the days between drop-off and pickup A parking booking spanning the trip showed up on every day, sometimes in between planned activities. It now appears on its first and last day only, with drop-off and pickup wording, and contributes nothing to the days in between, not even a badge in the day header. Applied to the day plan, the mobile timeline, the PDF export and the shared trip view, so a shared link shows what the app shows. Rental cars stay as they are. The report names the car's smaller tag as the thing it does not want for parking, not as something to change, and a car's middle days move into the day header, which is what people expect from a vehicle they are driving around. The distance connector between legs is keyed off the row that gets hidden, so hiding it naively takes the "2 km" label with it, which is what #1504 was about. There is a test that fails without that guard. Each segment of a stopover flight or train can also carry its own booking reference now, since airlines often issue one per flight rather than one per booking. Legs already live as a JSON array on the reservation, so this is an optional field per entry: no migration, no contract change, and a booking without it renders exactly as before. Readable everywhere it can be entered, desktop and mobile, through the same blur treatment other booking codes get, plus the PDF and the calendar description. create_transport and update_transport preserve it too, otherwise they would accept the field and silently drop it. Closes #1937 Closes #1943 --- client/src/components/PDF/TripPDF.test.ts | 35 +++++- client/src/components/PDF/TripPDF.tsx | 21 +++- .../Planner/DayPlanSidebar.test.tsx | 85 +++++++++++++++ .../src/components/Planner/DayPlanSidebar.tsx | 21 +++- ...ayPlanSidebarTransportDetailModal.test.tsx | 24 ++++- .../DayPlanSidebarTransportDetailModal.tsx | 10 ++ .../Planner/ReservationsPanel.test.tsx | 35 +++++- .../components/Planner/ReservationsPanel.tsx | 36 +++++++ .../Planner/TransportModal.test.tsx | 101 +++++++++++++++++- .../src/components/Planner/TransportModal.tsx | 40 ++++++- .../screens/trip/plan/planTimelineModel.ts | 6 +- .../trip/sheets/MTransportFormSheet.tsx | 45 +++++++- .../screens/trip/sheets/MTransportSheet.tsx | 25 +++++ client/src/pages/SharedTripPage.test.tsx | 32 +++++- client/src/pages/SharedTripPage.tsx | 8 +- client/src/utils/dayMerge.test.ts | 45 +++++++- client/src/utils/dayMerge.ts | 20 ++++ client/src/utils/flightLegs.test.ts | 46 ++++++++ client/src/utils/flightLegs.ts | 10 ++ .../mobile/trip/MTransportFormSheet.test.tsx | 58 +++++++++- .../unit/mobile/trip/MTransportSheet.test.tsx | 32 +++++- .../mobile/trip/planTimelineModel.test.ts | 9 +- server/src/nest/calendar/calendar.service.ts | 8 ++ .../src/nest/reservations/reservations.mcp.ts | 7 +- .../tests/unit/mcp/tools-transports.test.ts | 50 +++++++++ .../tests/unit/nest/calendar.service.test.ts | 38 +++++++ shared/src/i18n/ar/reservations.ts | 1 + shared/src/i18n/br/reservations.ts | 1 + shared/src/i18n/ca/reservations.ts | 1 + shared/src/i18n/cs/reservations.ts | 1 + shared/src/i18n/de/reservations.ts | 1 + shared/src/i18n/en/reservations.ts | 4 + shared/src/i18n/es/reservations.ts | 1 + shared/src/i18n/fr/reservations.ts | 1 + shared/src/i18n/gr/reservations.ts | 1 + shared/src/i18n/hu/reservations.ts | 1 + shared/src/i18n/id/reservations.ts | 1 + shared/src/i18n/it/reservations.ts | 1 + shared/src/i18n/ja/reservations.ts | 1 + shared/src/i18n/ko/reservations.ts | 1 + shared/src/i18n/nl/reservations.ts | 1 + shared/src/i18n/pl/reservations.ts | 1 + shared/src/i18n/ru/reservations.ts | 1 + shared/src/i18n/sv/reservations.ts | 1 + shared/src/i18n/tr/reservations.ts | 1 + shared/src/i18n/uk/reservations.ts | 1 + shared/src/i18n/vi/reservations.ts | 1 + shared/src/i18n/zh-TW/reservations.ts | 1 + shared/src/i18n/zh/reservations.ts | 1 + .../reservation/reservation.schema.spec.ts | 11 ++ shared/src/reservation/reservation.schema.ts | 8 ++ 51 files changed, 860 insertions(+), 32 deletions(-) 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 ( )}
-
+
updateWp({ airline: e.target.value })} placeholder="Lufthansa" className={inputClass} /> @@ -805,6 +823,13 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel updateWp({ seat: e.target.value })} placeholder="12A" className={inputClass} />
+ {writesFlightLegs && ( +
+ + updateWp({ confirmation_number: e.target.value })} + placeholder={t('reservations.confirmationPlaceholder')} className={inputClass} /> +
+ )}
)} @@ -866,7 +891,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel updateWp({ depTime: v })} />
-
+
updateWp({ train_number: e.target.value })} placeholder="ICE 123" className={inputClass} /> @@ -879,6 +904,13 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel updateWp({ seat: e.target.value })} placeholder="42A" className={inputClass} />
+ {writesTrainLegs && ( +
+ + updateWp({ confirmation_number: e.target.value })} + placeholder={t('reservations.confirmationPlaceholder')} className={inputClass} /> +
+ )}
)} 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(),