feat(transport): multi-leg train bookings (#1150)

Long train trips are usually several trains under one booking. Trains
now get the same multi-leg editor flights have: an ordered chain of
stations (station search instead of the airport picker) with a per-leg
train number + platform, saved as from/stop/to endpoints + metadata.legs
— mirroring the flight leg contract, so the map draws the whole chain
and the day plan splits it into one row per leg (drag/reorder/position
persistence come for free from the shared __leg machinery). A single-leg
train saves exactly as before (flat metadata, no legs), and the flat
train-fields block is gone in favour of the per-leg inputs. Day sidebar,
shared trip view and the PDF render each train leg like a flight leg.
This commit is contained in:
Maurice
2026-07-04 23:56:51 +02:00
committed by Maurice
parent 3ef122c843
commit 6f20c11dff
9 changed files with 450 additions and 47 deletions
+16 -2
View File
@@ -6,7 +6,7 @@ import { accommodationsApi, mapsApi } from '../../api/client'
import type { Trip, Day, Place, Category, AssignmentsMap, DayNote } from '../../types'
import { isDayInAccommodationRange, getDayOrder } from '../../utils/dayOrder'
import { splitReservationDateTime } from '../../utils/formatters'
import { getFlightLegs } from '../../utils/flightLegs'
import { getFlightLegs, getTrainLegs } from '../../utils/flightLegs'
function renderLucideIcon(icon:LucideIcon, props = {}) {
if (!_renderToStaticMarkup) return ''
@@ -243,7 +243,21 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
subtitle = [meta.airline, meta.flight_number, route].filter(Boolean).join(' · ')
}
}
else if (r.type === 'train') subtitle = [meta.train_number, meta.platform ? `Gl. ${meta.platform}` : '', meta.seat ? `Seat ${meta.seat}` : ''].filter(Boolean).join(' · ')
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.
subtitleLines = legs.map(l =>
[l.train_number, l.platform ? `Gl. ${l.platform}` : '',
(l.from || l.to) ? [l.from, l.to].filter(Boolean).join(' → ') : '']
.filter(Boolean).join(' · '))
.filter(Boolean)
} else {
const stops = (r.endpoints || []).slice().sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0)).map(e => e.code || e.name)
const route = stops.length >= 2 ? stops.join(' → ') : ''
subtitle = [meta.train_number, meta.platform ? `Gl. ${meta.platform}` : '', meta.seat ? `Seat ${meta.seat}` : '', route].filter(Boolean).join(' · ')
}
}
else if (r.type === 'restaurant') subtitle = [meta.party_size ? `${meta.party_size} guests` : ''].filter(Boolean).join(' · ')
else if (r.type === 'event') subtitle = [meta.venue].filter(Boolean).join(' · ')
else if (r.type === 'tour') subtitle = [meta.operator].filter(Boolean).join(' · ')
@@ -1953,8 +1953,10 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
// Subtitle aus Metadaten zusammensetzen
let subtitle = ''
if (res.__leg) {
// One leg of a multi-leg flight — show this segment's own route.
const parts = [res.__leg.airline, res.__leg.flight_number].filter(Boolean)
// One leg of a multi-leg flight/train — show this segment's own detail.
const parts = res.type === 'train'
? [res.__leg.train_number, res.__leg.platform ? `Gl. ${res.__leg.platform}` : '', res.__leg.seat ? `Sitz ${res.__leg.seat}` : ''].filter(Boolean)
: [res.__leg.airline, res.__leg.flight_number].filter(Boolean)
if (res.__leg.from || res.__leg.to)
parts.push([res.__leg.from, res.__leg.to].filter(Boolean).join(' → '))
subtitle = parts.join(' · ')
@@ -390,4 +390,68 @@ describe('TransportModal', () => {
render(<TransportModal {...defaultProps} reservation={res} />);
expect(screen.queryByRole('button', { name: 'Automated transport' })).not.toBeInTheDocument();
});
// ── Multi-leg trains (#1150) ───────────────────────────────────────────────
it('FE-PLANNER-TRANSMODAL-025: a train with an added stop saves from/stop/to endpoints + metadata.legs', async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
render(<TransportModal {...defaultProps} onSave={onSave} />);
await userEvent.click(screen.getByRole('button', { name: /^Train$/i }));
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Berlin → München');
// Insert an intermediate station (2 → 3 stations = 2 legs).
await userEvent.click(screen.getByRole('button', { name: /Add stop/i }));
const stations = screen.getAllByTestId('location-select');
expect(stations).toHaveLength(3);
fireEvent.change(stations[0], { target: { value: 'Berlin Hbf' } });
fireEvent.change(stations[1], { target: { value: 'Frankfurt Hbf' } });
fireEvent.change(stations[2], { target: { value: 'München Hbf' } });
// Per-leg train number on the first station (placeholder ICE 123).
const trainNumbers = screen.getAllByPlaceholderText('ICE 123');
fireEvent.change(trainNumbers[0], { target: { value: 'ICE 100' } });
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
await waitFor(() => expect(onSave).toHaveBeenCalled());
const payload = onSave.mock.calls[0][0];
expect(payload.type).toBe('train');
expect(payload.endpoints.map((e: { role: string }) => e.role)).toEqual(['from', 'stop', 'to']);
expect(payload.endpoints.map((e: { name: string }) => e.name)).toEqual(['Berlin Hbf', 'Frankfurt Hbf', 'München Hbf']);
expect(payload.metadata.legs).toHaveLength(2);
expect(payload.metadata.legs[0]).toMatchObject({ from: 'Berlin Hbf', to: 'Frankfurt Hbf', train_number: 'ICE 100' });
expect(payload.metadata.train_number).toBe('ICE 100'); // flat mirror of leg 0
});
it('FE-PLANNER-TRANSMODAL-027: a train with a day + train number but no geocoded station still saves them (#1150 regression)', async () => {
const days = [{ id: 10, trip_id: 1, day_number: 1, date: '2026-08-01', title: 'Day 1' }] as any;
const onSave = vi.fn().mockResolvedValue(undefined);
render(<TransportModal {...defaultProps} days={days} selectedDayId={10} onSave={onSave} />);
await userEvent.click(screen.getByRole('button', { name: /^Train$/i }));
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'ICE 599');
// Fill the train number + a departure time, but never pick a geocoded station.
fireEvent.change(screen.getAllByPlaceholderText('ICE 123')[0], { target: { value: 'ICE 599' } });
fireEvent.change(screen.getAllByTestId('time-picker')[0], { target: { value: '08:00' } });
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
await waitFor(() => expect(onSave).toHaveBeenCalled());
const payload = onSave.mock.calls[0][0];
// The day, time and train number survive even without any map-picked station.
expect(payload.day_id).toBe(10);
expect(payload.reservation_time).toBe('2026-08-01T08:00');
expect(payload.metadata.train_number).toBe('ICE 599');
expect(payload.endpoints).toEqual([]); // no geocoded station → no map endpoints, like before
});
it('FE-PLANNER-TRANSMODAL-026: a two-station train saves flat (no metadata.legs)', async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
render(<TransportModal {...defaultProps} onSave={onSave} />);
await userEvent.click(screen.getByRole('button', { name: /^Train$/i }));
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Köln → Aachen');
const stations = screen.getAllByTestId('location-select');
fireEvent.change(stations[0], { target: { value: 'Köln Hbf' } });
fireEvent.change(stations[1], { target: { value: 'Aachen Hbf' } });
fireEvent.change(screen.getAllByPlaceholderText('ICE 123')[0], { target: { value: 'RE 9' } });
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
await waitFor(() => expect(onSave).toHaveBeenCalled());
const payload = onSave.mock.calls[0][0];
expect(payload.endpoints.map((e: { role: string }) => e.role)).toEqual(['from', 'to']);
expect(payload.metadata.legs).toBeUndefined();
expect(payload.metadata.train_number).toBe('RE 9');
});
});
+197 -29
View File
@@ -41,7 +41,7 @@ function endpointFromAirport(a: Airport, role: 'from' | 'to' | 'stop', sequence:
}
}
function endpointFromLocation(l: LocationPoint, role: 'from' | 'to', sequence: number, date: string | null, time: string | null): Omit<ReservationEndpoint, 'id' | 'reservation_id'> {
function endpointFromLocation(l: LocationPoint, role: 'from' | 'to' | 'stop', sequence: number, date: string | null, time: string | null): Omit<ReservationEndpoint, 'id' | 'reservation_id'> {
return {
role, sequence,
name: l.name,
@@ -88,6 +88,24 @@ function emptyWaypoint(dayId: string | number = ''): WaypointForm {
return { airport: null, arrDayId: dayId, arrTime: '', depDayId: dayId, depTime: '', airline: '', flight_number: '', seat: '' }
}
// ── Multi-leg train stations ───────────────────────────────────────────────
// A train mirrors the flight route model, but its waypoints are STATIONS
// (location search, no timezone) and each leg carries a train number + platform
// instead of an airline + flight number. N stations = N-1 legs.
interface StationWaypointForm {
location: LocationPoint | null
arrDayId: string | number
arrTime: string
depDayId: string | number
depTime: string
train_number: string
platform: string
seat: string
}
function emptyStationWaypoint(dayId: string | number = ''): StationWaypointForm {
return { location: null, arrDayId: dayId, arrTime: '', depDayId: dayId, depTime: '', train_number: '', platform: '', seat: '' }
}
const TYPE_OPTIONS = [
{ value: 'flight', labelKey: 'reservations.type.flight', Icon: Plane },
{ value: 'train', labelKey: 'reservations.type.train', Icon: Train },
@@ -159,6 +177,8 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
const [toPick, setToPick] = useState<EndpointPick>({})
// Flight route as an ordered list of airports (origin .. stops .. destination).
const [waypoints, setWaypoints] = useState<WaypointForm[]>([emptyWaypoint(), emptyWaypoint()])
// Train route as an ordered list of stations (origin .. stops .. destination).
const [trainWaypoints, setTrainWaypoints] = useState<StationWaypointForm[]>([emptyStationWaypoint(), emptyStationWaypoint()])
const [uploadingFile, setUploadingFile] = useState(false)
const [pendingFiles, setPendingFiles] = useState<File[]>([])
const [showFilePicker, setShowFilePicker] = useState(false)
@@ -237,6 +257,46 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
wps = [dep, arr]
}
setWaypoints(wps)
} else if (type === 'train') {
// Mirror the flight seeding with stations + per-leg train fields. A
// current single-leg train (2 endpoints, no metadata.legs) round-trips
// through the >=2 branch: the flat train_number/platform/seat land on
// the first station, dep/arr day+time from src.day_id/end_day_id.
const orderedEps = orderedEndpoints(src)
const metaLegs: any[] = Array.isArray(meta.legs) ? meta.legs : []
let wps: StationWaypointForm[]
if (orderedEps.length >= 2) {
wps = orderedEps.map((ep, i) => {
const legInto = metaLegs[i - 1]
const legOut = metaLegs[i]
const isFirst = i === 0
const isLast = i === orderedEps.length - 1
return {
location: locationFromEndpoint(ep),
arrDayId: legInto?.arr_day_id ?? (isLast ? (src.end_day_id ?? '') : ''),
arrTime: legInto?.arr_time ?? (!isFirst ? (ep.local_time ?? '') : ''),
depDayId: legOut?.dep_day_id ?? (isFirst ? (src.day_id ?? '') : ''),
depTime: legOut?.dep_time ?? (!isLast ? (ep.local_time ?? '') : ''),
train_number: legOut?.train_number ?? (isFirst ? (meta.train_number ?? '') : ''),
platform: legOut?.platform ?? (isFirst ? (meta.platform ?? '') : ''),
seat: legOut?.seat ?? (isFirst ? (meta.seat ?? '') : ''),
}
})
} else {
const dep = emptyStationWaypoint(src.day_id ?? '')
dep.location = locationFromEndpoint(from)
dep.depTime = splitReservationDateTime(src.reservation_time).time ?? ''
dep.train_number = meta.train_number ?? ''
dep.platform = meta.platform ?? ''
dep.seat = meta.seat ?? ''
const arr = emptyStationWaypoint(src.end_day_id ?? src.day_id ?? '')
arr.location = locationFromEndpoint(to)
arr.arrTime = splitReservationDateTime(src.reservation_end_time).time ?? ''
wps = [dep, arr]
}
setTrainWaypoints(wps)
setFromPick({})
setToPick({})
} else {
setFromPick({ location: locationFromEndpoint(from) || undefined })
setToPick({ location: locationFromEndpoint(to) || undefined })
@@ -247,6 +307,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
setFromPick({})
setToPick({})
setWaypoints([emptyWaypoint(selectedDayId ?? ''), emptyWaypoint(selectedDayId ?? '')])
setTrainWaypoints([emptyStationWaypoint(selectedDayId ?? ''), emptyStationWaypoint(selectedDayId ?? '')])
}
}, [isOpen, reservation, prefill, selectedDayId, budgetItems])
@@ -270,6 +331,15 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
const flightWps = form.type === 'flight' ? waypoints.filter(w => w.airport) : []
const firstWp = flightWps[0]
const lastWp = flightWps[flightWps.length - 1]
// Train route: the first/last waypoint drive the span + flat metadata
// (day/time/train number are entered independently of geocoding, exactly
// like the old form fields, so a train with no map-picked station still
// saves its day/time/train number). Only geocoded stations become map
// endpoints + legs, mirroring the old "push only if the location is set".
const trainWps = form.type === 'train' ? trainWaypoints : []
const firstTrainWp = trainWps[0]
const lastTrainWp = trainWps[trainWps.length - 1]
const trainStations = form.type === 'train' ? trainWaypoints.filter(w => w.location) : []
// Per-leg day-plan positions are owned by the day planner, not this form — keep
// them when re-saving so editing a flight doesn't reset where its legs sit.
const origLegs: any[] = reservation ? (parseReservationMetadata(reservation).legs || []) : []
@@ -308,9 +378,31 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
}
if (firstWp?.seat) metadata.seat = firstWp.seat
} else if (form.type === 'train') {
if (form.meta_train_number) metadata.train_number = form.meta_train_number
if (form.meta_platform) metadata.platform = form.meta_platform
if (form.meta_seat) metadata.seat = form.meta_seat
// Flat keys mirror the first leg so legacy readers keep working; a
// 2-station train emits exactly {train_number?,platform?,seat?} — the
// same shape it saved before this feature.
if (firstTrainWp?.train_number) metadata.train_number = firstTrainWp.train_number
if (firstTrainWp?.platform) metadata.platform = firstTrainWp.platform
if (firstTrainWp?.seat) metadata.seat = firstTrainWp.seat
// Per-leg detail only for a true multi-leg train (>2 geocoded stations);
// a simple train keeps the same flat metadata it saved before.
if (trainStations.length > 2) {
metadata.legs = trainStations.slice(0, -1).map((w, i) => {
const next = trainStations[i + 1]
return {
from: w.location!.name,
to: next.location!.name,
...(w.train_number ? { train_number: w.train_number } : {}),
...(w.platform ? { platform: w.platform } : {}),
...(w.seat ? { seat: w.seat } : {}),
dep_day_id: w.depDayId ? Number(w.depDayId) : null,
dep_time: w.depTime || null,
arr_day_id: next.arrDayId ? Number(next.arrDayId) : null,
arr_time: next.arrTime || null,
...(origLegs[i]?.day_positions ? { day_positions: origLegs[i].day_positions } : {}),
}
})
}
}
// A transit itinerary (#1065) lives in metadata.transit + 'stop' endpoints,
@@ -340,6 +432,18 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
const time = isLast ? w.arrTime : w.depTime
endpoints.push(endpointFromAirport(w.airport!, role, i, dayDate(dId), time || null))
})
} else if (form.type === 'train') {
trainStations.forEach((w, i) => {
const isFirst = i === 0
const isLast = i === trainStations.length - 1
const role: 'from' | 'to' | 'stop' = isFirst ? 'from' : isLast ? 'to' : 'stop'
const dId = isLast ? w.arrDayId : w.depDayId
const time = isLast ? w.arrTime : w.depTime
// The destination date falls back to the departure day (as the old flat
// path did via `endDay ?? startDay`) when the arrival day is left blank.
const date = dayDate(dId) ?? (isLast ? dayDate(firstTrainWp?.depDayId ?? '') : null)
endpoints.push(endpointFromLocation(w.location!, role, i, date, time || null))
})
} else {
if (fromPick.location) endpoints.push(endpointFromLocation(fromPick.location, 'from', 0, startDate, form.departure_time || null))
// Keep the itinerary's transfer stops while the route is unchanged (#1065).
@@ -354,22 +458,30 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
if (toPick.location) endpoints.push(endpointFromLocation(toPick.location, 'to', stops.length + 1, endDate, form.arrival_time || null))
}
// Flights derive their span from the first/last waypoint; other transports
// keep using the single departure/arrival form fields unchanged.
// Flights and trains derive their span from the first/last waypoint; other
// transports keep using the single departure/arrival form fields unchanged.
const flightDepDay = firstWp && firstWp.depDayId ? Number(firstWp.depDayId) : null
const flightArrDay = lastWp && lastWp.arrDayId ? Number(lastWp.arrDayId) : null
const trainDepDay = firstTrainWp && firstTrainWp.depDayId ? Number(firstTrainWp.depDayId) : null
const trainArrDay = lastTrainWp && lastTrainWp.arrDayId ? Number(lastTrainWp.arrDayId) : null
const payload = {
title: form.title,
type: form.type,
status: form.status,
day_id: form.type === 'flight' ? flightDepDay : (form.start_day_id ? Number(form.start_day_id) : null),
end_day_id: form.type === 'flight' ? flightArrDay : (form.end_day_id ? Number(form.end_day_id) : null),
day_id: form.type === 'flight' ? flightDepDay : form.type === 'train' ? trainDepDay : (form.start_day_id ? Number(form.start_day_id) : null),
end_day_id: form.type === 'flight' ? flightArrDay : form.type === 'train' ? trainArrDay : (form.end_day_id ? Number(form.end_day_id) : null),
reservation_time: form.type === 'flight'
? buildTime(days.find(d => d.id === flightDepDay), firstWp?.depTime || '')
: buildTime(startDay, form.departure_time),
: form.type === 'train'
? buildTime(days.find(d => d.id === trainDepDay), firstTrainWp?.depTime || '')
: buildTime(startDay, form.departure_time),
reservation_end_time: form.type === 'flight'
? buildTime(days.find(d => d.id === flightArrDay), lastWp?.arrTime || '')
: buildTime(endDay ?? startDay, form.arrival_time),
: form.type === 'train'
// Fall back to the departure day so a same-day train (arrival day left
// blank) still gets its date, matching the non-flight `endDay ?? startDay`.
? buildTime(days.find(d => d.id === trainArrDay) ?? days.find(d => d.id === trainDepDay), lastTrainWp?.arrTime || '')
: buildTime(endDay ?? startDay, form.arrival_time),
location: null,
confirmation_number: form.confirmation_number || null,
notes: form.notes || null,
@@ -653,6 +765,80 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
)
})}
</div>
) : form.type === 'train' ? (
/* ── Train route: ordered stations (origin · stops · destination) ── */
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<label className={labelClass}>{t('reservations.layover.route')}</label>
{trainWaypoints.map((wp, i) => {
const isFirst = i === 0
const isLast = i === trainWaypoints.length - 1
const updateWp = (patch: Partial<StationWaypointForm>) => setTrainWaypoints(prev => prev.map((w, j) => (j === i ? { ...w, ...patch } : w)))
const roleLabel = isFirst ? t('reservations.meta.from') : isLast ? t('reservations.meta.to') : t('reservations.layover.stop')
return (
<div key={i} style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<div className="bg-surface-card" style={{ border: '1px solid var(--border-primary)', borderRadius: 10, padding: 10, display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span className="text-content-faint" style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.03em', flexShrink: 0 }}>{roleLabel}</span>
<div style={{ flex: 1, minWidth: 0 }}>
<LocationSelect value={wp.location} onChange={l => updateWp({ location: l || null })} />
</div>
{!isFirst && !isLast && (
<button type="button" onClick={() => setTrainWaypoints(prev => prev.filter((_, j) => j !== i))} aria-label={t('common.delete')} className="text-content-faint" style={{ background: 'none', border: 'none', cursor: 'pointer', display: 'flex', padding: 4, flexShrink: 0 }}>
<Trash2 size={14} />
</button>
)}
</div>
{!isFirst && (
<div style={{ display: 'flex', gap: 8 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<label className={labelClass}>{t('reservations.arrivalDate')}</label>
<CustomSelect value={wp.arrDayId} onChange={v => updateWp({ arrDayId: v })} placeholder={t('dayplan.dayN', { n: '?' })} options={dayOptions} size="sm" />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<label className={labelClass}>{t('reservations.arrivalTime')}</label>
<CustomTimePicker value={wp.arrTime} onChange={v => updateWp({ arrTime: v })} />
</div>
</div>
)}
{!isLast && (
<>
<div style={{ display: 'flex', gap: 8 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<label className={labelClass}>{t('reservations.departureDate')}</label>
<CustomSelect value={wp.depDayId} onChange={v => updateWp({ depDayId: v })} placeholder={t('dayplan.dayN', { n: '?' })} options={dayOptions} size="sm" />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<label className={labelClass}>{t('reservations.departureTime')}</label>
<CustomTimePicker value={wp.depTime} onChange={v => updateWp({ depTime: v })} />
</div>
</div>
<div className="grid grid-cols-1 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} />
</div>
<div>
<label className={labelClass}>{t('reservations.meta.platform')}</label>
<input type="text" value={wp.platform} onChange={e => updateWp({ platform: e.target.value })} placeholder="12" className={inputClass} />
</div>
<div>
<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>
</div>
</>
)}
</div>
{!isLast && (
<button type="button" onClick={() => setTrainWaypoints(prev => [...prev.slice(0, i + 1), emptyStationWaypoint(prev[i]?.depDayId || ''), ...prev.slice(i + 1)])}
className="text-content-faint hover:text-content-secondary" style={{ width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5, padding: '6px 10px', border: '1px dashed var(--border-primary)', borderRadius: 8, background: 'none', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', cursor: 'pointer', fontFamily: 'inherit' }}>
<Plus size={12} /> {t('reservations.layover.addStop')}
</button>
)}
</div>
)
})}
</div>
) : (
<>
{/* From / To endpoints (non-flight) */}
@@ -694,25 +880,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
)}
{/* Train-specific fields */}
{form.type === 'train' && (
<div className="grid grid-cols-3 gap-3">
<div>
<label className={labelClass}>{t('reservations.meta.trainNumber')}</label>
<input type="text" value={form.meta_train_number} onChange={e => set('meta_train_number', e.target.value)}
placeholder="ICE 123" className={inputClass} />
</div>
<div>
<label className={labelClass}>{t('reservations.meta.platform')}</label>
<input type="text" value={form.meta_platform} onChange={e => set('meta_platform', e.target.value)}
placeholder="12" className={inputClass} />
</div>
<div>
<label className={labelClass}>{t('reservations.meta.seat')}</label>
<input type="text" value={form.meta_seat} onChange={e => set('meta_seat', e.target.value)}
placeholder="42A" className={inputClass} />
</div>
</div>
)}
{/* Train number / platform / seat are per-leg now (in the route above). */}
{/* Booking Code + Status */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
+14 -4
View File
@@ -12,7 +12,7 @@ import { renderToStaticMarkup } from 'react-dom/server'
import { Clock, MapPin, FileText, Train, Plane, Bus, Car, Ship, Ticket, Hotel, Map, Luggage, Wallet, MessageCircle } from 'lucide-react'
import { isDayInAccommodationRange } from '../utils/dayOrder'
import { getTransportForDay, getMergedItems } from '../utils/dayMerge'
import { getFlightLegs } from '../utils/flightLegs'
import { getFlightLegs, getTrainLegs } from '../utils/flightLegs'
import { splitReservationDateTime } from '../utils/formatters'
const TRANSPORT_ICONS = { flight: Plane, train: Train, bus: Bus, car: Car, cruise: Ship }
@@ -226,7 +226,14 @@ export default function SharedTripPage() {
sub = [meta.airline, meta.flight_number, meta.departure_airport && meta.arrival_airport ? `${meta.departure_airport} → ${meta.arrival_airport}` : ''].filter(Boolean).join(' · ')
}
}
else if (r.type === 'train') sub = [meta.train_number, meta.platform ? `Gl. ${meta.platform}` : ''].filter(Boolean).join(' · ')
else if (r.type === 'train') {
if (r.__leg) {
// One leg of a multi-leg train — show this segment's own train/route.
sub = [r.__leg.train_number, r.__leg.platform ? `Gl. ${r.__leg.platform}` : '', (r.__leg.from || r.__leg.to) ? [r.__leg.from, r.__leg.to].filter(Boolean).join(' → ') : ''].filter(Boolean).join(' · ')
} else {
sub = [meta.train_number, meta.platform ? `Gl. ${meta.platform}` : ''].filter(Boolean).join(' · ')
}
}
return (
<div key={r.__leg ? `t-${r.id}-leg${r.__leg.index}` : `t-${r.id}`} className="bg-[rgba(59,130,246,0.06)]" style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '6px 8px', borderRadius: 6, border: '1px solid rgba(59,130,246,0.15)' }}>
<div className="bg-[rgba(59,130,246,0.12)]" style={{ width: 24, height: 24, borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
@@ -298,8 +305,11 @@ export default function SharedTripPage() {
? getFlightLegs(r).map((leg, i) => (
<span key={i}>{[leg.airline, leg.flight_number, (leg.from || leg.to) ? [leg.from, leg.to].filter(Boolean).join(' → ') : ''].filter(Boolean).join(' ')}</span>
))
: meta.airline && <span>{meta.airline} {meta.flight_number || ''}</span>}
{meta.train_number && <span>{meta.train_number}</span>}
: r.type === 'train'
? getTrainLegs(r).map((leg, i) => (
<span key={i}>{[leg.train_number, leg.platform ? `${t('reservations.meta.platform')} ${leg.platform}` : '', (leg.from || leg.to) ? [leg.from, leg.to].filter(Boolean).join(' → ') : ''].filter(Boolean).join(' ')}</span>
))
: meta.airline && <span>{meta.airline} {meta.flight_number || ''}</span>}
</div>
</div>
<span className={r.status === 'confirmed' ? 'bg-[rgba(22,163,74,0.1)] text-[#16a34a]' : 'bg-[rgba(217,119,6,0.1)] text-[#d97706]'} style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', padding: '2px 8px', borderRadius: 20, fontWeight: 600 }}>
+26
View File
@@ -128,6 +128,32 @@ describe('getTransportForDay', () => {
expect(getTransportForDay({ reservations, dayId: 1, dayAssignmentIds: [42], days })).toHaveLength(0)
expect(getTransportForDay({ reservations, dayId: 1, dayAssignmentIds: [99], days })).toHaveLength(1)
})
it('expands a multi-leg TRAIN into one row per leg with train detail on __leg (#1150)', () => {
const reservations = [{
id: 40, type: 'train', day_id: 1, end_day_id: 2,
metadata: JSON.stringify({
train_number: 'ICE 100',
legs: [
{ from: 'Berlin', to: 'Frankfurt', train_number: 'ICE 100', platform: '5', dep_day_id: 1, dep_time: '08:00', arr_day_id: 1, arr_time: '12:00' },
{ from: 'Frankfurt', to: 'München', train_number: 'ICE 500', platform: '9', dep_day_id: 2, dep_time: '09:00', arr_day_id: 2, arr_time: '12:00' },
],
}),
}]
const day1 = getTransportForDay({ reservations, dayId: 1, dayAssignmentIds: [], days })
expect(day1).toHaveLength(1)
expect(day1[0].__leg).toMatchObject({ index: 0, total: 2, from: 'Berlin', to: 'Frankfurt', train_number: 'ICE 100', platform: '5' })
const day2 = getTransportForDay({ reservations, dayId: 2, dayAssignmentIds: [], days })
expect(day2).toHaveLength(1)
expect(day2[0].__leg).toMatchObject({ index: 1, from: 'Frankfurt', to: 'München', train_number: 'ICE 500', platform: '9' })
})
it('leaves a single-leg train untouched (no __leg)', () => {
const reservations = [{ id: 41, type: 'train', day_id: 1, end_day_id: 1, metadata: JSON.stringify({ train_number: 'RE 1' }) }]
const rows = getTransportForDay({ reservations, dayId: 1, dayAssignmentIds: [], days })
expect(rows).toHaveLength(1)
expect(rows[0].__leg).toBeUndefined()
})
})
describe('getMergedItems', () => {
+17 -10
View File
@@ -66,23 +66,23 @@ export function getDisplayTimeForDay(
return r.reservation_time || null
}
/** Per-leg detail of a multi-leg flight, or null for single-leg / non-flight. */
function parseFlightLegs(r: any): any[] | null {
if (r?.type !== 'flight') return null
/** Per-leg detail of a multi-leg flight or train, or null for single-leg / other. */
function parseMultiLegs(r: any): any[] | null {
if (r?.type !== 'flight' && r?.type !== 'train') return null
let meta = r.metadata
if (typeof meta === 'string') { try { meta = JSON.parse(meta || '{}') } catch { meta = {} } }
// Defensive: recover metadata that was accidentally double-encoded by an earlier
// bug (a JSON string of a JSON string) so already-saved flights heal on read.
// bug (a JSON string of a JSON string) so already-saved bookings heal on read.
if (typeof meta === 'string') { try { meta = JSON.parse(meta || '{}') } catch { meta = {} } }
if (meta && Array.isArray(meta.legs) && meta.legs.length > 1) return meta.legs
return null
}
/**
* Expand a multi-leg flight into one synthetic reservation per leg that touches
* `dayId`, each with its own day span + departure/arrival time so it slots into
* the timeline independently. A single-leg flight (or any other reservation) is
* returned untouched, so existing behaviour is unchanged.
* Expand a multi-leg flight/train into one synthetic reservation per leg that
* touches `dayId`, each with its own day span + departure/arrival time so it
* slots into the timeline independently. A single-leg booking (or any other
* reservation) is returned untouched, so existing behaviour is unchanged.
*/
export function expandFlightLegsForDay(
r: any,
@@ -90,7 +90,7 @@ export function expandFlightLegsForDay(
getDayOrder: (id: number) => number,
days: Array<{ id: number; date?: string | null }>
): any[] {
const legs = parseFlightLegs(r)
const legs = parseMultiLegs(r)
if (!legs) return [r]
const dateOf = (id: number | null): string | null => (id == null ? null : (days.find(d => d.id === id)?.date ?? null))
const thisOrder = getDayOrder(dayId)
@@ -114,7 +114,14 @@ export function expandFlightLegsForDay(
// dropped between legs and persist; absent → falls back to time ordering.
day_positions: leg.day_positions || undefined,
day_plan_position: undefined,
__leg: { index: i, total: legs.length, from: leg.from ?? null, to: leg.to ?? null, airline: leg.airline ?? null, flight_number: leg.flight_number ?? null },
__leg: {
index: i, total: legs.length,
from: leg.from ?? null, to: leg.to ?? null,
airline: leg.airline ?? null, flight_number: leg.flight_number ?? null,
// Train legs carry their own per-leg detail; added only for trains so a
// flight's __leg object stays byte-identical to before.
...(r.type === 'train' ? { train_number: leg.train_number ?? null, platform: leg.platform ?? null, seat: leg.seat ?? null } : {}),
},
})
})
return out
+52
View File
@@ -0,0 +1,52 @@
import { describe, it, expect } from 'vitest'
import { getFlightLegs, getTrainLegs, isMultiLegTrain } from './flightLegs'
import type { Reservation } from '../types'
function res(partial: Partial<Reservation>): Reservation {
return { id: 1, type: 'train', status: 'confirmed', ...partial } as unknown as Reservation
}
const ep = (role: 'from' | 'to' | 'stop', seq: number, name: string, extra: Record<string, unknown> = {}) =>
({ role, sequence: seq, name, code: null, lat: 0, lng: 0, timezone: null, local_time: null, local_date: null, ...extra })
describe('getTrainLegs (#1150)', () => {
it('reads ordered legs from metadata.legs', () => {
const r = res({
metadata: JSON.stringify({
train_number: 'ICE 100', platform: '5',
legs: [
{ from: 'Berlin Hbf', to: 'Frankfurt Hbf', train_number: 'ICE 100', platform: '5', dep_time: '08:00', arr_time: '12:00' },
{ from: 'Frankfurt Hbf', to: 'München Hbf', train_number: 'ICE 500', platform: '9', dep_time: '12:30', arr_time: '15:30' },
],
}),
})
const legs = getTrainLegs(r)
expect(legs).toHaveLength(2)
expect(legs[0]).toMatchObject({ from: 'Berlin Hbf', to: 'Frankfurt Hbf', train_number: 'ICE 100', platform: '5' })
expect(legs[1]).toMatchObject({ from: 'Frankfurt Hbf', to: 'München Hbf', train_number: 'ICE 500', platform: '9' })
expect(isMultiLegTrain(r)).toBe(true)
})
it('derives a single leg from endpoints + flat metadata (legacy train)', () => {
const r = res({
day_id: 3, end_day_id: 3,
metadata: JSON.stringify({ train_number: 'RE 42', platform: '2' }),
endpoints: [ep('from', 0, 'Köln Hbf', { local_time: '09:00' }), ep('to', 1, 'Aachen Hbf', { local_time: '10:00' })],
})
const legs = getTrainLegs(r)
expect(legs).toHaveLength(1)
expect(legs[0]).toMatchObject({ from: 'Köln Hbf', to: 'Aachen Hbf', train_number: 'RE 42', platform: '2', dep_time: '09:00', arr_time: '10:00' })
expect(isMultiLegTrain(r)).toBe(false)
})
it('returns [] for a train with no stations and no train number', () => {
expect(getTrainLegs(res({ metadata: '{}' }))).toEqual([])
})
it('does not disturb getFlightLegs for flights', () => {
const flight = res({ type: 'flight', metadata: JSON.stringify({ departure_airport: 'FRA', arrival_airport: 'JFK', airline: 'LH', flight_number: 'LH 400' }) })
const legs = getFlightLegs(flight)
expect(legs).toHaveLength(1)
expect(legs[0]).toMatchObject({ from: 'FRA', to: 'JFK', airline: 'LH', flight_number: 'LH 400' })
})
})
+60
View File
@@ -84,11 +84,71 @@ export function getFlightLegs(r: Reservation): FlightLeg[] {
}]
}
/**
* A train booking mirrors the flight leg model (#1150), but its stops are
* STATIONS (labels, not IATA codes) and each leg carries a train number +
* platform instead of an airline + flight number.
*/
export interface TrainLeg {
from: string | null // station label (or null)
to: string | null
train_number?: string
platform?: string
seat?: string
dep_day_id?: number | null
dep_time?: string | null
arr_day_id?: number | null
arr_time?: string | null
}
/**
* Ordered legs of a train booking. Prefers `metadata.legs`; otherwise derives a
* single leg from the endpoints + flat metadata, so single-leg trains — and
* trains created before this feature — still work.
*/
export function getTrainLegs(r: Reservation): TrainLeg[] {
const meta = parseReservationMetadata(r)
if (Array.isArray(meta.legs) && meta.legs.length > 0) {
return meta.legs.map((l: any): TrainLeg => ({
from: l.from ?? null,
to: l.to ?? null,
train_number: l.train_number || undefined,
platform: l.platform || undefined,
seat: l.seat || undefined,
dep_day_id: l.dep_day_id ?? null,
dep_time: l.dep_time ?? null,
arr_day_id: l.arr_day_id ?? null,
arr_time: l.arr_time ?? null,
}))
}
const eps = orderedEndpoints(r)
const first = eps[0]
const last = eps[eps.length - 1]
const fromLabel = first ? (first.code || first.name) : null
const toLabel = last ? (last.code || last.name) : null
if (!fromLabel && !toLabel && !meta.train_number) return []
return [{
from: fromLabel,
to: toLabel,
train_number: meta.train_number || undefined,
platform: meta.platform || undefined,
seat: meta.seat || 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,
arr_time: last?.local_time ?? null,
}]
}
/** Number of flight segments. 1 for a simple from -> to booking. */
export function legCount(r: Reservation): number {
return getFlightLegs(r).length
}
export function isMultiLegTrain(r: Reservation): boolean {
return r.type === 'train' && getTrainLegs(r).length > 1
}
export function isMultiLegFlight(r: Reservation): boolean {
return r.type === 'flight' && legCount(r) > 1
}