- )}
+ {/* 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
}