Files
TREK/client/src/utils/flightLegs.ts
T
Maurice bb477645a3 Support multi-leg (layover) flights (#1146)
* feat(transport): support multi-leg (layover) flights in the booking form

A flight booking can now hold an ordered chain of airports (e.g. FRA -> BER ->
HND) instead of a single departure/arrival pair. The route is entered as a list
of waypoints with a '+ add stop' button; each stop carries its own arrival and
departure time plus the airline/flight number of the segment leaving it, while
the whole booking keeps one price.

Stored without a schema change: the existing reservation_endpoints rows carry the
ordered waypoints (from/stop/to by sequence) and a metadata.legs array holds the
per-leg detail. Top-level metadata (departure_airport/arrival_airport/airline/
flight_number) mirrors the first and last leg, so a single-leg flight persists
exactly as before and legacy readers keep working.

* feat(planner): show each flight leg as its own day-plan entry, ordered by time

A multi-leg flight now expands into one entry per leg (BER -> FRA, then FRA ->
HND), each on its own day with its own times, instead of a single span. Each leg
is an addressable slot (reservation id + leg index) so places and notes can be
dropped into the layover gap between legs; the per-leg position is persisted in
metadata.legs[i].day_positions and survives a reload.

Day-plan items are now ordered chronologically: anything with a time (a place's
time, a flight leg, a timed note) sorts by that time, and untimed items inherit
the time of the item before them so they stay where they were placed.

* feat(planner): show the full multi-stop route in the bookings panel

The route row now lists every waypoint (FRA -> BER -> HND) by sequence instead of
just the first and last airport.

* feat(map): draw multi-leg flights as connected legs with a marker per airport

Both the Leaflet and Mapbox overlays now render a flight over all its waypoints:
one great-circle arc per leg and a marker at every airport, with the label
showing the full route and the summed distance. A single-leg flight is unchanged.

Also drops the floating stats badge that was drawn on transport arcs.

* fix(map): centre a clicked place above the bottom inspector panel

Selecting a place panned/flew it to the dead centre of the screen, where it sat
behind the detail card. Both overlays now bias the target into the visible area
above the bottom panel (Leaflet offsets the pan by the inspector inset; Mapbox
passes the padding to flyTo).

* feat: show the full multi-stop flight route in PDF and calendar export

The PDF day list and the ICS export now render the whole route (FRA → BER → HND)
for a multi-leg flight instead of just the first and last airport, falling back to
the flat metadata for single-leg flights. The ICS keeps a single event per booking.

* feat(import): group connecting flight legs into one multi-leg booking

When a booking confirmation contains several flight legs sharing a PNR that
connect at the same airport with a short layover (under 24h), they are now
imported as a single multi-leg booking (from/stop/to endpoints + metadata.legs)
instead of one booking per leg. A round trip (same PNR, multi-day gap) stays two
separate bookings, and a single flight is unchanged.

* i18n: translate the new flight-route strings into all languages

* i18n: translate the Costs page into every language

The Budget → Costs rework left the new costs.* strings untranslated in every
non-English locale (they fell back to English). Translate them across all
supported languages.

* Revert "fix(map): centre a clicked place above the bottom inspector panel"

This reverts commit 0936103f04.
2026-06-11 22:17:14 +02:00

106 lines
4.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,
}]
}
/** Number of flight segments. 1 for a simple from -> to booking. */
export function legCount(r: Reservation): number {
return getFlightLegs(r).length
}
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[]
}