From 6f20c11dfffde9989fde004883969f2a6359b827 Mon Sep 17 00:00:00 2001 From: Maurice Date: Sat, 4 Jul 2026 23:56:51 +0200 Subject: [PATCH] feat(transport): multi-leg train bookings (#1150) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- client/src/components/PDF/TripPDF.tsx | 18 +- .../src/components/Planner/DayPlanSidebar.tsx | 6 +- .../Planner/TransportModal.test.tsx | 64 +++++ .../src/components/Planner/TransportModal.tsx | 226 +++++++++++++++--- client/src/pages/SharedTripPage.tsx | 18 +- client/src/utils/dayMerge.test.ts | 26 ++ client/src/utils/dayMerge.ts | 27 ++- client/src/utils/flightLegs.test.ts | 52 ++++ client/src/utils/flightLegs.ts | 60 +++++ 9 files changed, 450 insertions(+), 47 deletions(-) create mode 100644 client/src/utils/flightLegs.test.ts diff --git a/client/src/components/PDF/TripPDF.tsx b/client/src/components/PDF/TripPDF.tsx index 66422d328..850158723 100644 --- a/client/src/components/PDF/TripPDF.tsx +++ b/client/src/components/PDF/TripPDF.tsx @@ -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(' · ') diff --git a/client/src/components/Planner/DayPlanSidebar.tsx b/client/src/components/Planner/DayPlanSidebar.tsx index f28b8a118..a97acda7b 100644 --- a/client/src/components/Planner/DayPlanSidebar.tsx +++ b/client/src/components/Planner/DayPlanSidebar.tsx @@ -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(' · ') diff --git a/client/src/components/Planner/TransportModal.test.tsx b/client/src/components/Planner/TransportModal.test.tsx index c258d75d0..b57032fc1 100644 --- a/client/src/components/Planner/TransportModal.test.tsx +++ b/client/src/components/Planner/TransportModal.test.tsx @@ -390,4 +390,68 @@ describe('TransportModal', () => { render(); 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(); + 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(); + 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(); + 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'); + }); }); diff --git a/client/src/components/Planner/TransportModal.tsx b/client/src/components/Planner/TransportModal.tsx index 658485da3..38ca05291 100644 --- a/client/src/components/Planner/TransportModal.tsx +++ b/client/src/components/Planner/TransportModal.tsx @@ -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 { +function endpointFromLocation(l: LocationPoint, role: 'from' | 'to' | 'stop', sequence: number, date: string | null, time: string | null): Omit { 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({}) // Flight route as an ordered list of airports (origin .. stops .. destination). const [waypoints, setWaypoints] = useState([emptyWaypoint(), emptyWaypoint()]) + // Train route as an ordered list of stations (origin .. stops .. destination). + const [trainWaypoints, setTrainWaypoints] = useState([emptyStationWaypoint(), emptyStationWaypoint()]) const [uploadingFile, setUploadingFile] = useState(false) const [pendingFiles, setPendingFiles] = useState([]) 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 ) })} + ) : form.type === 'train' ? ( + /* ── Train route: ordered stations (origin · stops · destination) ── */ +
+ + {trainWaypoints.map((wp, i) => { + const isFirst = i === 0 + const isLast = i === trainWaypoints.length - 1 + const updateWp = (patch: Partial) => 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 ( +
+
+
+ {roleLabel} +
+ updateWp({ location: l || null })} /> +
+ {!isFirst && !isLast && ( + + )} +
+ {!isFirst && ( +
+
+ + updateWp({ arrDayId: v })} placeholder={t('dayplan.dayN', { n: '?' })} options={dayOptions} size="sm" /> +
+
+ + updateWp({ arrTime: v })} /> +
+
+ )} + {!isLast && ( + <> +
+
+ + updateWp({ depDayId: v })} placeholder={t('dayplan.dayN', { n: '?' })} options={dayOptions} size="sm" /> +
+
+ + updateWp({ depTime: v })} /> +
+
+
+
+ + updateWp({ train_number: e.target.value })} placeholder="ICE 123" className={inputClass} /> +
+
+ + updateWp({ platform: e.target.value })} placeholder="12" className={inputClass} /> +
+
+ + updateWp({ seat: e.target.value })} placeholder="42A" className={inputClass} /> +
+
+ + )} +
+ {!isLast && ( + + )} +
+ ) + })} +
) : ( <> {/* From / To endpoints (non-flight) */} @@ -694,25 +880,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel )} {/* Train-specific fields */} - {form.type === 'train' && ( -
-
- - set('meta_train_number', e.target.value)} - placeholder="ICE 123" className={inputClass} /> -
-
- - set('meta_platform', e.target.value)} - placeholder="12" className={inputClass} /> -
-
- - set('meta_seat', e.target.value)} - placeholder="42A" className={inputClass} /> -
-
- )} + {/* Train number / platform / seat are per-leg now (in the route above). */} {/* Booking Code + Status */}
diff --git a/client/src/pages/SharedTripPage.tsx b/client/src/pages/SharedTripPage.tsx index 1dfe055a2..3e9f79079 100644 --- a/client/src/pages/SharedTripPage.tsx +++ b/client/src/pages/SharedTripPage.tsx @@ -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 (
@@ -298,8 +305,11 @@ export default function SharedTripPage() { ? getFlightLegs(r).map((leg, i) => ( {[leg.airline, leg.flight_number, (leg.from || leg.to) ? [leg.from, leg.to].filter(Boolean).join(' → ') : ''].filter(Boolean).join(' ')} )) - : meta.airline && {meta.airline} {meta.flight_number || ''}} - {meta.train_number && {meta.train_number}} + : r.type === 'train' + ? getTrainLegs(r).map((leg, 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(' ')} + )) + : meta.airline && {meta.airline} {meta.flight_number || ''}}
diff --git a/client/src/utils/dayMerge.test.ts b/client/src/utils/dayMerge.test.ts index bd21552cb..8f10f884c 100644 --- a/client/src/utils/dayMerge.test.ts +++ b/client/src/utils/dayMerge.test.ts @@ -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', () => { diff --git a/client/src/utils/dayMerge.ts b/client/src/utils/dayMerge.ts index 3e7529238..c117693b5 100644 --- a/client/src/utils/dayMerge.ts +++ b/client/src/utils/dayMerge.ts @@ -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 diff --git a/client/src/utils/flightLegs.test.ts b/client/src/utils/flightLegs.test.ts new file mode 100644 index 000000000..9ae8b5af5 --- /dev/null +++ b/client/src/utils/flightLegs.test.ts @@ -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 { + return { id: 1, type: 'train', status: 'confirmed', ...partial } as unknown as Reservation +} + +const ep = (role: 'from' | 'to' | 'stop', seq: number, name: string, extra: Record = {}) => + ({ 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' }) + }) +}) diff --git a/client/src/utils/flightLegs.ts b/client/src/utils/flightLegs.ts index c9504eb5b..d5ba77ff3 100644 --- a/client/src/utils/flightLegs.ts +++ b/client/src/utils/flightLegs.ts @@ -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 }