mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
6f20c11dff
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.
166 lines
6.1 KiB
TypeScript
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[]
|
|
}
|