Files
TREK/client/src/utils/flightLegs.ts
T
Maurice 6f20c11dff 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.
2026-07-05 00:23:38 +02:00

166 lines
6.1 KiB
TypeScript

// Multi-leg (layover) flight support.
//
// A flight booking is ONE reservation whose route is an ordered chain of airports
// (e.g. FRA -> BER -> HND). The geometry + order are the source of truth in
// `reservation.endpoints` (role 'from' for the first airport, 'stop' for each
// intermediate one, 'to' for the last, ordered by `sequence`). The per-leg detail
// — airline, flight number, and each segment's own day/time — lives in
// `metadata.legs`. The top-level metadata (`departure_airport`/`arrival_airport`/
// `airline`/`flight_number`) and `day_id`/`end_day_id` mirror the FIRST and LAST
// leg so legacy readers keep working.
//
// A legacy single-leg flight (two endpoints, flat metadata, no `metadata.legs`)
// is normalised here into a one-leg chain, so every renderer can use one path.
import type { Reservation, ReservationEndpoint } from '../types'
export interface FlightLeg {
from: string | null // IATA code (or null)
to: string | null
airline?: string
flight_number?: string
dep_day_id?: number | null
dep_time?: string | null // 'HH:mm'
arr_day_id?: number | null
arr_time?: string | null
}
/** reservation.metadata may be a JSON string or an already-parsed object. */
export function parseReservationMetadata(r: Pick<Reservation, 'metadata'>): Record<string, any> {
const m = r.metadata
if (!m) return {}
if (typeof m === 'string') {
try {
let parsed = JSON.parse(m || '{}')
// Defensive: an earlier bug could double-encode metadata (a JSON string of a
// JSON string) — unwrap it once more so saved flights heal on read.
if (typeof parsed === 'string') { try { parsed = JSON.parse(parsed) } catch { /* keep */ } }
return (parsed && typeof parsed === 'object') ? parsed : {}
} catch { return {} }
}
return m as Record<string, any>
}
/** Endpoints ordered by `sequence` (geometry + order source of truth). */
export function orderedEndpoints(r: Pick<Reservation, 'endpoints'>): ReservationEndpoint[] {
return (r.endpoints || []).slice().sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0))
}
/**
* Ordered legs of a flight. `metadata.legs` is preferred; otherwise a single leg
* is derived from the endpoints (and finally the flat metadata) so that legacy
* single-leg flights — and flights created before this feature — still work.
*/
export function getFlightLegs(r: Reservation): FlightLeg[] {
const meta = parseReservationMetadata(r)
if (Array.isArray(meta.legs) && meta.legs.length > 0) {
return meta.legs.map((l: any): FlightLeg => ({
from: l.from ?? null,
to: l.to ?? null,
airline: l.airline || undefined,
flight_number: l.flight_number || 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,
}))
}
// Legacy fallback: one leg from the endpoints / flat metadata.
const eps = orderedEndpoints(r)
const first = eps[0]
const last = eps[eps.length - 1]
const fromCode = first?.code ?? meta.departure_airport ?? null
const toCode = last?.code ?? meta.arrival_airport ?? null
if (!fromCode && !toCode) return []
return [{
from: fromCode,
to: toCode,
airline: meta.airline || undefined,
flight_number: meta.flight_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,
arr_time: last?.local_time ?? null,
}]
}
/**
* 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
}
/**
* Ordered route labels (IATA codes, or names when no code) for display, e.g.
* ['FRA','BER','HND']. Uses endpoints; falls back to the flat metadata pair.
*/
export function routeStops(r: Reservation): string[] {
const eps = orderedEndpoints(r)
if (eps.length >= 2) return eps.map(e => e.code || e.name)
const meta = parseReservationMetadata(r)
return [meta.departure_airport, meta.arrival_airport].filter(Boolean) as string[]
}