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
This commit is contained in:
Maurice
2026-08-17 14:21:44 +02:00
committed by Maurice
parent d156404b84
commit 0a7e460253
51 changed files with 860 additions and 32 deletions
+34 -1
View File
@@ -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,
+16 -5
View File
@@ -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 {
@@ -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(<DayPlanSidebar {...makeDefaultProps({
days, places: [], assignments: assigns, reservations: [parking as any],
selectedDayId: null, showRouteToolsWhenExpanded: true,
})} />)
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(<DayPlanSidebar {...makeDefaultProps({ days, reservations: [parking] })} />)
// 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(<DayPlanSidebar {...makeDefaultProps({ days, reservations: [parking] })} />)
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(<DayPlanSidebar {...makeDefaultProps({ days, reservations: [parking] })} />)
// 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 () => {
@@ -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 => <PluginDayScheduleRow key={`${si.pluginId}:${si.id}`} item={si} />)}
{merged.length === 0 && !dayNoteUi ? (
{visibleCount === 0 && !dayNoteUi ? (
<div
onDragOver={e => { 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'
@@ -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<Reservation>)
render(<DayPlanSidebarTransportDetailModal {...makeProps({ transportDetail: res })} />)
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()
@@ -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 (
@@ -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(<ReservationsPanel {...defaultProps} reservations={[layoverFlight()]} />);
// 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(<ReservationsPanel {...defaultProps} reservations={[layoverFlight()]} />);
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(<ReservationsPanel {...defaultProps} reservations={[res]} />);
@@ -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 (
<div style={{ display: 'grid', gap: 10, gridTemplateColumns: coded.length > 1 ? `repeat(${Math.min(coded.length, 3)}, 1fr)` : '1fr' }}>
{coded.map((l, i) => (
<div key={i}>
<div className={fieldLabelClass}>{[l.from, l.to].filter(Boolean).join(' → ') || t('reservations.confirmationCode')}</div>
<div
onMouseEnter={() => 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}
</div>
</div>
))}
</div>
)
})()}
{/* Type-specific metadata */}
{(() => {
const meta = typeof r.metadata === 'string' ? JSON.parse(r.metadata || '{}') : (r.metadata || {})
@@ -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(<TransportModal {...defaultProps} days={routeDays} selectedDayId={10} onSave={onSave} />);
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(<TransportModal {...defaultProps} days={routeDays} selectedDayId={10} />);
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(<TransportModal {...defaultProps} days={routeDays} reservation={multiLegFlight()} onSave={onSave} />);
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(<TransportModal {...defaultProps} days={routeDays} reservation={multiLegFlight()} onSave={onSave} />);
// 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(<TransportModal {...defaultProps} days={routeDays} selectedDayId={10} onSave={onSave} />);
@@ -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 (
<Modal
isOpen={isOpen}
@@ -792,7 +810,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
</div>
)}
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div className={`grid grid-cols-1 ${writesFlightLegs ? 'sm:grid-cols-4' : 'sm:grid-cols-3'} gap-3`}>
<div>
<label className={labelClass}>{t('reservations.meta.airline')}</label>
<input type="text" value={wp.airline} onChange={e => updateWp({ airline: e.target.value })} placeholder="Lufthansa" className={inputClass} />
@@ -805,6 +823,13 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
<label className={labelClass}>{t('reservations.meta.seat')}</label>
<input type="text" value={wp.seat} onChange={e => updateWp({ seat: e.target.value })} placeholder="12A" className={inputClass} />
</div>
{writesFlightLegs && (
<div>
<label className={labelClass}>{t('reservations.confirmationCode')}</label>
<input type="text" value={wp.confirmation_number} onChange={e => updateWp({ confirmation_number: e.target.value })}
placeholder={t('reservations.confirmationPlaceholder')} className={inputClass} />
</div>
)}
</div>
</>
)}
@@ -866,7 +891,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
<CustomTimePicker value={wp.depTime} onChange={v => updateWp({ depTime: v })} />
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div className={`grid grid-cols-1 ${writesTrainLegs ? 'sm:grid-cols-4' : 'sm:grid-cols-3'} gap-3`}>
<div>
<label className={labelClass}>{t('reservations.meta.trainNumber')}</label>
<input type="text" value={wp.train_number} onChange={e => updateWp({ train_number: e.target.value })} placeholder="ICE 123" className={inputClass} />
@@ -879,6 +904,13 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
<label className={labelClass}>{t('reservations.meta.seat')}</label>
<input type="text" value={wp.seat} onChange={e => updateWp({ seat: e.target.value })} placeholder="42A" className={inputClass} />
</div>
{writesTrainLegs && (
<div>
<label className={labelClass}>{t('reservations.confirmationCode')}</label>
<input type="text" value={wp.confirmation_number} onChange={e => updateWp({ confirmation_number: e.target.value })}
placeholder={t('reservations.confirmationPlaceholder')} className={inputClass} />
</div>
)}
</div>
</>
)}
@@ -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 })
@@ -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
<input type="text" value={wp.seat} onChange={e => updateWp({ seat: e.target.value })} placeholder="12A" className={FIELD_CLS} />
</div>
</div>
{writesFlightLegs && (
<div className="mt-2">
<Eyebrow className="mb-[5px] uppercase">{t('reservations.confirmationCode')}</Eyebrow>
<input
type="text"
value={wp.confirmation_number}
onChange={e => updateWp({ confirmation_number: e.target.value })}
placeholder={t('reservations.confirmationPlaceholder')}
className={FIELD_CLS}
/>
</div>
)}
</>
)}
</div>
@@ -817,6 +846,18 @@ export default function MTransportFormSheet({ planner, onOpenExpense }: MTranspo
<input type="text" value={wp.seat} onChange={e => updateWp({ seat: e.target.value })} placeholder="42A" className={FIELD_CLS} />
</div>
</div>
{writesTrainLegs && (
<div className="mt-2">
<Eyebrow className="mb-[5px] uppercase">{t('reservations.confirmationCode')}</Eyebrow>
<input
type="text"
value={wp.confirmation_number}
onChange={e => updateWp({ confirmation_number: e.target.value })}
placeholder={t('reservations.confirmationPlaceholder')}
className={FIELD_CLS}
/>
</div>
)}
</>
)}
</div>
@@ -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) {
)}
</div>
{/* ── Per-segment booking codes ── */}
{legCodes.length > 0 && (
<div className={`mt-3 flex flex-col gap-[7px] rounded-[13px] px-3 py-[9px] ${INNER_CLS}`}>
{legCodes.map((leg, i) => (
<div key={i} className="flex items-center gap-2">
<span className="min-w-0 flex-1 truncate text-[0.71875rem] font-medium">
{[leg.from, leg.to].filter(Boolean).join(' → ') || t('reservations.confirmationCode')}
</span>
<span
onClick={() => { 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}
</span>
</div>
))}
</div>
)}
{/* ── Notes ── */}
{res.notes && (
<>
+31 -1
View File
@@ -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());
});
});
});
+6 -2
View File
@@ -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 (
<div
+44 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'
import { parseTimeToMinutes, getSpanPhase, getTransportRouteEndpoints, getDisplayTimeForDay, getTransportForDay, getMergedItems } from './dayMerge'
import { parseTimeToMinutes, getSpanPhase, hidesOnMiddleDay, getTransportRouteEndpoints, getDisplayTimeForDay, getTransportForDay, getMergedItems } from './dayMerge'
describe('parseTimeToMinutes', () => {
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 }
+20
View File
@@ -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
+46
View File
@@ -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')
})
})
+10
View File
@@ -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,
@@ -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<string, unknown>[] }).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<string, unknown>[] }).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',
@@ -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(<MTransportSheet planner={makePlanner({ reservations: [layover()] })} shell={makeShell()} />)
// 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(<MTransportSheet planner={makePlanner({ reservations: [layover()] })} shell={makeShell()} />)
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(<MTransportSheet planner={makePlanner()} shell={shell} />)
@@ -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({
@@ -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}`;
@@ -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(),
@@ -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);
@@ -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
+1
View File
@@ -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': 'البداية',
+1
View File
@@ -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',
+1
View File
@@ -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',
+1
View File
@@ -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',
+1
View File
@@ -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',
+4
View File
@@ -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',
+1
View File
@@ -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',
+1
View File
@@ -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',
+1
View File
@@ -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': 'Έναρξη',
+1
View File
@@ -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',
+1
View File
@@ -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',
+1
View File
@@ -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',
+1
View File
@@ -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': '開始',
+1
View File
@@ -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': '시작',
+1
View File
@@ -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',
+1
View File
@@ -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',
+1
View File
@@ -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': 'Начало',
+1
View File
@@ -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',
+1
View File
@@ -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ıç',
+1
View File
@@ -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': 'Початок',
+1
View File
@@ -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',
+1
View File
@@ -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': '開始',
+1
View File
@@ -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': '开始',
@@ -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' },
@@ -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(),