Files
TREK/client/src/native/widgetFlight.ts
T
Maurice e525e0b838 feat(flight-live): route mini map and a calmer live card; fix the CI gates
The live flight card shows a small real map behind the pass: the great
circle between the airports, solid where flown and dotted ahead, the
aircraft on the line turned along the route. The route is framed in the
free middle, the map fades out under the airports and times laid over it,
and its place names are hidden. Data and map credits sit on the map as its
hover text instead of a footer line.

The card head now follows the booking dialog: a tinted band with pills for
the state, what comes next and the delay; the numbers of the moment use the
dialog's fields on the desktop and the sheet's stat boxes on the phone; both
times sit on equal tiles in one line. Without live data the card, the strips
and the plan line show nothing, and the pulsing dots are gone.

Also fixes the failed dev run: unit symbols and the leg position are listed
as NOT_PLURAL, the aircraft label uses logical sides, the flight store is in
the test reset registry, the sync loop stops once no flight lies ahead (no
endless timers under fake timers), and timeCell and widgetFlight drop below
the max-params and complexity limits. Guards the GL bridge's resize frame
against a layer removed before it runs, and keeps the booking code pill as
tall as its neighbours.
2026-11-13 16:39:46 +01:00

128 lines
5.0 KiB
TypeScript

import type { FlightLiveLeg } from '@trek/shared';
import { airportTime, toMs } from '../utils/flightLiveModel';
/**
* The flight a home-screen widget follows: the trip's next one or the one in
* the air. Times go along as epoch seconds so the widget moves its countdown
* and its aircraft on with the clock between two snapshots; the clock times
* at either airport go along formatted, since the widget has no zone data.
* Optional on the native side (WidgetSnapshot.swift, WidgetSnapshot.kt): an
* older app binary ignores it.
*/
export interface WidgetFlight {
number: string;
from: string;
to: string;
fromCity?: string;
toCity?: string;
/** Planned departure and arrival, epoch seconds. */
dep: number;
arr: number;
/** Observed take-off, epoch seconds. */
depActual?: number;
/** Before take-off: the planned departure plus the delay seen so far, epoch seconds. */
depExpected?: number;
/** The feed sees the aircraft in the air right now. */
airborne?: boolean;
/** Touchdown once landed, else the estimate while in the air, epoch seconds. */
arrExpected?: number;
landed?: boolean;
/** Clock times at the airports: planned departure, planned arrival, expected arrival. */
depTime: string;
arrTime: string;
arrTimeExpected?: string;
/** Minutes late against the schedule (negative when early). */
delay?: number;
/** "A359 D-AIXA". */
aircraft?: string;
/** When the feed last heard the aircraft, epoch seconds. */
seen?: number;
path: string;
}
/** How long after landing the widget keeps the flight before moving on to the next. */
const KEEP_AFTER_LANDING_S = 2 * 60 * 60;
/** How far ahead a flight is worth the widget's attention. */
const LOOK_AHEAD_S = 7 * 24 * 60 * 60;
const sec = (ms: number | null): number | undefined => (ms == null ? undefined : Math.round(ms / 1000));
/**
* The leg to show: one in the air or rolling first, else the earliest whose
* arrival (with two hours' grace) is still ahead and that departs within a week.
*/
export function pickWidgetLeg(legs: FlightLiveLeg[], nowMs: number): FlightLiveLeg | null {
const now = nowMs / 1000;
const arrivalOf = (l: FlightLiveLeg) =>
sec(toMs(l.landed_at) ?? toMs(l.estimated_arrival) ?? toMs(l.scheduled_arrival));
// A leg the feed lost in the air still counts only until two hours after its arrival.
const moving = legs.find((l) => {
const arr = arrivalOf(l);
return (l.phase === 'airborne' || l.phase === 'taxiing') && (arr == null || arr + KEEP_AFTER_LANDING_S > now);
});
if (moving) return moving;
return (
legs
.filter((l) => {
const dep = sec(toMs(l.scheduled_departure));
const arr = sec(toMs(l.landed_at) ?? toMs(l.scheduled_arrival));
return dep != null && arr != null && arr + KEEP_AFTER_LANDING_S > now && dep - now < LOOK_AHEAD_S;
})
.sort((a, b) => (toMs(a.scheduled_departure) ?? 0) - (toMs(b.scheduled_departure) ?? 0))[0] ?? null
);
}
/** What the feed observed about a leg, for the widget: each part only when it is known. */
function observed(
leg: FlightLiveLeg,
depSeconds: number,
time: (ms: number, tz: string | null) => string
): Partial<WidgetFlight> {
const landed = toMs(leg.landed_at);
const expected = landed ?? (leg.phase === 'airborne' ? toMs(leg.estimated_arrival) : null);
const lateBy = leg.departure_delay_min ?? 0;
const delay = leg.arrival_delay_min ?? leg.departure_delay_min;
const aircraft = [leg.aircraft?.type, leg.aircraft?.registration].filter(Boolean).join(' ');
return {
...(leg.departed_at
? { depActual: sec(toMs(leg.departed_at)) }
: lateBy > 0
? { depExpected: depSeconds + lateBy * 60 }
: {}),
...(leg.phase === 'airborne' ? { airborne: true } : {}),
...(expected != null
? { arrExpected: sec(expected), arrTimeExpected: time(expected, leg.destination.timezone) }
: {}),
...(landed != null ? { landed: true } : {}),
...(delay != null ? { delay } : {}),
...(aircraft ? { aircraft } : {}),
...(leg.last_seen_at ? { seen: sec(toMs(leg.last_seen_at)) } : {}),
};
}
export function widgetFlight(
legs: FlightLiveLeg[],
nowMs: number,
tripPath: string,
clock: { locale: string; timeFormat: string }
): WidgetFlight | undefined {
const leg = pickWidgetLeg(legs, nowMs);
const dep = leg ? toMs(leg.scheduled_departure) : null;
const arr = leg ? toMs(leg.scheduled_arrival) : null;
if (!leg || dep == null || arr == null) return undefined;
const time = (ms: number, tz: string | null) => airportTime(ms, tz, clock.locale, clock.timeFormat);
return {
number: leg.flight_number,
from: leg.origin.code,
to: leg.destination.code,
...(leg.origin.name ? { fromCity: leg.origin.name } : {}),
...(leg.destination.name ? { toCity: leg.destination.name } : {}),
dep: Math.round(dep / 1000),
arr: Math.round(arr / 1000),
depTime: time(dep, leg.origin.timezone),
arrTime: time(arr, leg.destination.timezone),
...observed(leg, Math.round(dep / 1000), time),
path: `${tripPath}?booking=${leg.reservation_id}`,
};
}