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(),