feat(flight-live): follow booked flights live from keyless ADS-B feeds

A new flight-live domain polls the open feeds adsb.lol and adsb.fi (no key,
no cost; adsb.fi only on self-hosted installs, its terms are non-commercial)
for every booked flight leg in its watch window, derives phase, take-off,
landing, delay and an arrival estimate, and pushes changes as flight:live.
GET /api/trips/:tripId/flights/live, the MCP tool get_trip_flight_status,
notifications for take-off, landing and delays, and the admin switch
flight_live_enabled (FLIGHT_LIVE_ENABLED wins) come with it.
This commit is contained in:
Maurice
2026-11-13 15:29:03 +01:00
parent 3365511b10
commit 154563f34d
143 changed files with 5048 additions and 14 deletions
+1
View File
@@ -71,6 +71,7 @@ DEMO_MODE=false # Demo mode: resets data hourly
# TREK_PLACES_ENABLED=false # Whether place search asks the TREK Places API at https://places.liketrek.com: suggestions, the full search, the category buttons on the map, the Road trip search along the route, import geocoding, offline downloads, and the OpenStreetMap ways and horse routes Tours reads. It is on by default and has no switch in the admin panel, because whether searches leave the instance is a deployment decision. Set false to search the way it did before 4.3.0 (OpenStreetMap, or your Google/Amap key) and send nothing to that host. (default: true)
# TREK_PLACES_URL=https://places.liketrek.com # Base URL of a copy of the Places API you run yourself; it has to answer the same /v1 API. Read from configuration rather than typed by a user, so an address on your LAN or Docker network works without ALLOW_INTERNAL_NETWORK. (default: https://places.liketrek.com)
# TREK_PLACES_CONTRIBUTE=admin # Whether an admin may let this installation send place details its users typed in (website, phone, e-mail, opening hours, description, picture) to the TREK Places API, so every installation gets better details. admin shows the choice in the admin panel, where it stays off until an admin agrees; off takes the choice away and nothing is ever sent. Nothing is sent outside NODE_ENV=production, in demo mode, with a custom TREK_PLACES_URL or with TREK_SERVICES=off. (default: admin)
# FLIGHT_LIVE_ENABLED=false # Whether booked flights show their live status (position, departure, landing, estimated arrival) from the free, keyless ADS-B feeds adsb.lol and, on a self-hosted install, adsb.fi. Only the flight's callsign is sent, never who is flying or where to. When set it overrides the switch in the admin panel: false turns it off, true keeps it on. Unset, the admin panel decides. (default: true)
# TREK_SERVICES=auto # How this installation identifies itself to the TREK services. On first use it registers itself in the background and keeps a key in the database; nothing to click, no account. off sends no key, and searches use the shared pool. (default: auto)
# TREK_SERVICES_KEY= # A key issued to you for this installation. Wins over the stored one and turns self-registration off; meant for hosted and pre-provisioned instances. (default: none)
# TREK_SERVICES_HOSTS=places.liketrek.com,photon.liketrek.com,valhalla.liketrek.com # Comma-separated hosts that receive the installation key over https. No other host ever sees it. (default: places.liketrek.com,photon.liketrek.com,valhalla.liketrek.com)
File diff suppressed because one or more lines are too long
+115
View File
@@ -0,0 +1,115 @@
#!/usr/bin/env node
// Build server/assets/airlines-icao.json, the IATA airline code -> ICAO callsign prefix table the
// live flight status uses to turn a booked flight number ("LH 716") into the callsign an aircraft
// transmits ("DLH716").
//
// Source: VRS standing data (github.com/vradarserver/standing-data, CC0): airlines/schema-01 for
// the codes and the file list of routes/schema-01 for which airline still flies.
//
// Only airlines with both codes are kept. IATA reuses its two-character codes, so one IATA code
// often names several ICAO airlines (LH: Lufthansa and Lufthansa Cargo; U2: easyJet and United
// Feeder Service). The airline list carries no "active" flag, so the rule is: the ICAO airline
// with the most route data in the standing data wins (the summed size of its routes files), since
// routes are only recorded for callsigns seen flying. A shared code where no candidate has any
// route data is left out rather than guessed; a booking with it is then simply not tracked.
import fs from 'node:fs';
import https from 'node:https';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const OUT = path.join(__dirname, '..', 'assets', 'airlines-icao.json');
const AIRLINES = 'https://raw.githubusercontent.com/vradarserver/standing-data/main/airlines/schema-01/airlines.csv';
const TREE = 'https://api.github.com/repos/vradarserver/standing-data/git/trees/main?recursive=1';
function fetchText(url) {
return new Promise((resolve, reject) => {
https
.get(url, { headers: { 'User-Agent': 'trek-build-airlines', Accept: '*/*' } }, (res) => {
if (res.statusCode !== 200) return reject(new Error(`HTTP ${res.statusCode} for ${url}`));
let data = '';
res.setEncoding('utf8');
res.on('data', (chunk) => {
data += chunk;
});
res.on('end', () => resolve(data));
})
.on('error', reject);
});
}
/** One CSV line into its fields: optional double quotes, "" as an escaped quote. */
function parseLine(line) {
const fields = [];
let cur = '';
let inQuotes = false;
for (let i = 0; i < line.length; i++) {
const ch = line[i];
if (inQuotes) {
if (ch === '"' && line[i + 1] === '"') {
cur += '"';
i++;
} else if (ch === '"') inQuotes = false;
else cur += ch;
} else if (ch === '"') inQuotes = true;
else if (ch === ',') {
fields.push(cur);
cur = '';
} else cur += ch;
}
fields.push(cur);
return fields;
}
const csv = (await fetchText(AIRLINES)).replace(/^/, '');
const tree = JSON.parse(await fetchText(TREE));
if (tree.truncated) throw new Error('The standing-data tree came back truncated; the route sizes would be incomplete');
// Bytes of route data per ICAO code: routes/schema-01/<letter>/<CODE>-all.csv or <CODE>-<digit>.csv.
const routeBytes = new Map();
for (const entry of tree.tree) {
const match = /^routes\/schema-01\/[A-Z0-9]\/([A-Z]{3})-(?:all|\d)\.csv$/.exec(entry.path);
if (!match) continue;
routeBytes.set(match[1], (routeBytes.get(match[1]) ?? 0) + (entry.size ?? 0));
}
const rows = csv.split(/\r?\n/);
const header = parseLine(rows[0]);
const ICAO = header.indexOf('ICAO');
const IATA = header.indexOf('IATA');
const candidates = new Map();
for (const line of rows.slice(1)) {
if (!line.trim()) continue;
const fields = parseLine(line);
const icao = fields[ICAO]?.trim().toUpperCase();
const iata = fields[IATA]?.trim().toUpperCase();
if (!icao || !iata || !/^[A-Z]{3}$/.test(icao) || !/^(?=.*[A-Z])[A-Z0-9]{2}$/.test(iata)) continue;
const list = candidates.get(iata) ?? [];
if (!list.includes(icao)) list.push(icao);
candidates.set(iata, list);
}
const table = {};
let shared = 0;
let dropped = 0;
for (const iata of [...candidates.keys()].sort()) {
const list = candidates.get(iata);
if (list.length === 1) {
table[iata] = list[0];
continue;
}
shared++;
const ranked = list
.map((icao) => ({ icao, bytes: routeBytes.get(icao) ?? 0 }))
.sort((a, b) => b.bytes - a.bytes || a.icao.localeCompare(b.icao));
if (ranked[0].bytes === 0) {
dropped++;
continue;
}
table[iata] = ranked[0].icao;
}
fs.writeFileSync(OUT, JSON.stringify(table));
console.log(
`Wrote ${Object.keys(table).length} airline codes to ${OUT}; ${shared} shared IATA codes resolved by route data, ${dropped} left out without any`,
);
+1
View File
@@ -40,6 +40,7 @@
"DocumentSyncItems": "doc-sync",
"FerrataTiles": "ferratas",
"FileLinks": "files",
"FlightLiveStates": "flight-live",
"GoogleApiUsage": "google-quota",
"GooglePlacePhotoMeta": "place-photos",
"HiddenCountries": "atlas",
+12
View File
@@ -111,6 +111,12 @@ export function deriveSession(raw: RawEnv) {
};
}
/** A boolean variable as the stored settings spell it, or undefined when unset or not a boolean. */
function flagText(raw: string | undefined): 'true' | 'false' | undefined {
const value = parseBool(raw);
return value === undefined ? undefined : value ? 'true' : 'false';
}
/**
* Whether this instance is centrally administered: somebody other than its
* admin user owns the configuration, the credentials and the upgrade schedule.
@@ -312,6 +318,12 @@ export function deriveIntegrations(raw: RawEnv) {
* request whose prompt plus cap overruns its context (#2677).
*/
llmMaxOutputTokens: positiveIntOr(raw.LLM_MAX_OUTPUT_TOKENS, 0) || undefined,
/**
* The operator's word on live flight status, as the admin setting spells it
* ('true'/'false'), or undefined when unset or not a boolean: then the admin
* switch (`flight_live_enabled`) decides.
*/
flightLiveEnabled: flagText(raw.FLIGHT_LIVE_ENABLED),
// TRANSIT_API_URL, KITINERARY_EXTRACTOR_PATH and PATH are boot-stable and
// read only by Nest providers, so the transitConfig and kitineraryConfig
// tokens own them (boot-derive.ts).
+1
View File
@@ -185,6 +185,7 @@ export const envSchema = z.object({
GOOGLE_LIST_DATA_BASE: url,
TREK_PLACES_URL: url,
TREK_PLACES_ENABLED: boolStr,
FLIGHT_LIVE_ENABLED: boolStr,
TREK_PLACES_CONTRIBUTE: oneOf(['off', 'admin']),
TREK_SERVICES: oneOf(['auto', 'off']),
TREK_SERVICES_KEY: optionalWith(isServiceKeyShape, 'must be a TREK services key (tk1.<kid>.<payload>.<signature>)'),
@@ -0,0 +1,65 @@
import { EntityRepositoryType, type Opt, PrimaryKeyProp, defineEntity, p } from '@mikro-orm/core';
import { FlightLiveStatesRepository } from '../repositories/FlightLiveStates.repository';
import { DbTimestampType } from '../types';
export class FlightLiveStates {
[EntityRepositoryType]?: FlightLiveStatesRepository;
[PrimaryKeyProp]?: ['callsign', 'service_date', 'origin', 'destination'];
callsign!: string;
service_date!: string;
origin!: string;
destination!: string;
phase: string & Opt = 'scheduled';
departed_at?: string | null;
landed_at?: string | null;
lat?: number | null;
lng?: number | null;
altitude_ft?: number | null;
ground_speed_kt?: number | null;
track_deg?: number | null;
vertical_rate_fpm?: number | null;
on_ground?: number | null;
registration?: string | null;
aircraft_type?: string | null;
source?: string | null;
first_seen_at?: string | null;
last_seen_at?: string | null;
fetched_at?: string | null;
next_poll_at?: string | null;
fail_count: number & Opt = 0;
departed_notified: number & Opt = 0;
landed_notified: number & Opt = 0;
delay_notified: number & Opt = 0;
}
export const FlightLiveStatesSchema = defineEntity({
class: FlightLiveStates,
repository: () => FlightLiveStatesRepository,
properties: {
callsign: p.text().primary(),
service_date: p.text().primary(),
origin: p.text().primary(),
destination: p.text().primary(),
phase: p.text().default('scheduled'),
departed_at: p.type(DbTimestampType).nullable(),
landed_at: p.type(DbTimestampType).nullable(),
lat: p.double().nullable(),
lng: p.double().nullable(),
altitude_ft: p.double().nullable(),
ground_speed_kt: p.double().nullable(),
track_deg: p.double().nullable(),
vertical_rate_fpm: p.double().nullable(),
on_ground: p.integer().nullable(),
registration: p.text().nullable(),
aircraft_type: p.text().nullable(),
source: p.text().nullable(),
first_seen_at: p.type(DbTimestampType).nullable(),
last_seen_at: p.type(DbTimestampType).nullable(),
fetched_at: p.type(DbTimestampType).nullable(),
next_poll_at: p.type(DbTimestampType).nullable(),
fail_count: p.integer().default(0),
departed_notified: p.integer().default(0),
landed_notified: p.integer().default(0),
delay_notified: p.integer().default(0),
},
});
+2
View File
@@ -40,6 +40,7 @@ import { DocumentProvidersSchema } from './DocumentProviders.entity';
import { DocumentSyncItemsSchema } from './DocumentSyncItems.entity';
import { FerrataTilesSchema } from './FerrataTiles.entity';
import { FileLinksSchema } from './FileLinks.entity';
import { FlightLiveStatesSchema } from './FlightLiveStates.entity';
import { GoogleApiUsageSchema } from './GoogleApiUsage.entity';
import { GooglePlacePhotoMetaSchema } from './GooglePlacePhotoMeta.entity';
import { HiddenCountriesSchema } from './HiddenCountries.entity';
@@ -209,6 +210,7 @@ export const ALL_ENTITIES: readonly EntitySchema[] = [
DocumentSyncItemsSchema,
FerrataTilesSchema,
FileLinksSchema,
FlightLiveStatesSchema,
GoogleApiUsageSchema,
GooglePlacePhotoMetaSchema,
HiddenCountriesSchema,
@@ -0,0 +1,31 @@
// Generated by scripts/generate-db-types.ts from the migrated schema. Do not edit by hand:
// change the migration, then run `npm run gen:db-types`.
import type { InsertOptional } from './columns';
export interface FlightLiveStatesTable {
callsign: string;
service_date: string;
origin: string;
destination: string;
phase: InsertOptional<string>;
departed_at: InsertOptional<string | null>;
landed_at: InsertOptional<string | null>;
lat: InsertOptional<number | null>;
lng: InsertOptional<number | null>;
altitude_ft: InsertOptional<number | null>;
ground_speed_kt: InsertOptional<number | null>;
track_deg: InsertOptional<number | null>;
vertical_rate_fpm: InsertOptional<number | null>;
on_ground: InsertOptional<number | null>;
registration: InsertOptional<string | null>;
aircraft_type: InsertOptional<string | null>;
source: InsertOptional<string | null>;
first_seen_at: InsertOptional<string | null>;
last_seen_at: InsertOptional<string | null>;
fetched_at: InsertOptional<string | null>;
next_poll_at: InsertOptional<string | null>;
fail_count: InsertOptional<number>;
departed_notified: InsertOptional<number>;
landed_notified: InsertOptional<number>;
delay_notified: InsertOptional<number>;
}
+2
View File
@@ -43,6 +43,7 @@ import type { DocumentProvidersTable } from './DocumentProviders.table';
import type { DocumentSyncItemsTable } from './DocumentSyncItems.table';
import type { FerrataTilesTable } from './FerrataTiles.table';
import type { FileLinksTable } from './FileLinks.table';
import type { FlightLiveStatesTable } from './FlightLiveStates.table';
import type { GoogleApiUsageTable } from './GoogleApiUsage.table';
import type { GooglePlacePhotoMetaTable } from './GooglePlacePhotoMeta.table';
import type { HiddenCountriesTable } from './HiddenCountries.table';
@@ -218,6 +219,7 @@ export interface DB {
document_sync_items: DocumentSyncItemsTable;
ferrata_tiles: FerrataTilesTable;
file_links: FileLinksTable;
flight_live_states: FlightLiveStatesTable;
google_api_usage: GoogleApiUsageTable;
google_place_photo_meta: GooglePlacePhotoMetaTable;
hidden_countries: HiddenCountriesTable;
@@ -0,0 +1,51 @@
import { Migration } from '@mikro-orm/migrations';
/**
* Live flight status: what the open ADS-B feeds last said about one flight on
* one day, keyed by the callsign it transmits, its local departure date and
* its two airports. A through flight keeps one flight number over two sectors
* (FRA to BOM to SIN as LH 754 on one day), and each sector has a state of its own.
* One row serves every trip that booked the same flight, so the feeds are asked
* once per flight, not once per traveller. Next to the derived state sit the
* poll's bookkeeping (when to ask next, how often it failed) and whether each
* notification already went out, so a departure is announced once.
*
* No foreign key: the row describes a flight in the air, not a booking, and is
* found again from the bookings by its key.
*/
export class Migration20200101046400_a_booked_flight_shows_where_it_is extends Migration {
override name = 'Migration20200101046400_a_booked_flight_shows_where_it_is';
override up(): void {
this.addSql(`
CREATE TABLE IF NOT EXISTS flight_live_states (
callsign TEXT NOT NULL,
service_date TEXT NOT NULL,
origin TEXT NOT NULL,
destination TEXT NOT NULL,
phase TEXT NOT NULL DEFAULT 'scheduled',
departed_at DATETIME,
landed_at DATETIME,
lat REAL,
lng REAL,
altitude_ft REAL,
ground_speed_kt REAL,
track_deg REAL,
vertical_rate_fpm REAL,
on_ground INTEGER,
registration TEXT,
aircraft_type TEXT,
source TEXT,
first_seen_at DATETIME,
last_seen_at DATETIME,
fetched_at DATETIME,
next_poll_at DATETIME,
fail_count INTEGER NOT NULL DEFAULT 0,
departed_notified INTEGER NOT NULL DEFAULT 0,
landed_notified INTEGER NOT NULL DEFAULT 0,
delay_notified INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (callsign, service_date, origin, destination)
)
`);
}
}
@@ -0,0 +1,210 @@
import type { FlightLiveStates } from '../entities/FlightLiveStates.entity';
import type { DB } from '../kysely/db';
import type { DbTimestamp } from '../types';
import { TrekRepository } from './_shared/trek-repository';
/** A flight booking as the live flight status reads it; the metadata is the stored text. */
export interface FlightBookingDbRow {
id: number;
trip_id: number;
trip_title: string;
metadata: string | null;
day_id: number | null;
end_day_id: number | null;
reservation_time: string | null;
reservation_end_time: string | null;
}
interface FlightEndpointDbRow {
reservation_id: number;
role: string;
sequence: number;
name: string;
code: string | null;
lat: number;
lng: number;
timezone: string | null;
local_date: string | null;
local_time: string | null;
}
/**
* One flight state, every column; timestamps in the canonical UTC text. `T`
* is `string` for a row read back and `DbTimestamp` for one being written.
*/
interface FlightLiveStateFields<T extends string> {
callsign: string;
service_date: string;
origin: string;
destination: string;
phase: string;
departed_at: T | null;
landed_at: T | null;
lat: number | null;
lng: number | null;
altitude_ft: number | null;
ground_speed_kt: number | null;
track_deg: number | null;
vertical_rate_fpm: number | null;
on_ground: number | null;
registration: string | null;
aircraft_type: string | null;
source: string | null;
first_seen_at: T | null;
last_seen_at: T | null;
fetched_at: T | null;
next_poll_at: T | null;
fail_count: number;
departed_notified: number;
landed_notified: number;
delay_notified: number;
}
export type FlightLiveStateRow = FlightLiveStateFields<string>;
export type FlightLiveStateWrite = FlightLiveStateFields<DbTimestamp>;
const BOOKING_COLUMNS = [
'reservations.id',
'reservations.trip_id',
'reservations.metadata',
'reservations.day_id',
'reservations.end_day_id',
'reservations.reservation_time',
'reservations.reservation_end_time',
] as const;
const ENDPOINT_COLUMNS = [
'reservation_id',
'role',
'sequence',
'name',
'code',
'lat',
'lng',
'timezone',
'local_date',
'local_time',
] as const;
/**
* `flight_live_states`: what the open ADS-B feeds last said about a flight on
* a day, one row per (callsign, service date, origin, destination) whichever
* trips booked it.
*
* The flight bookings, their endpoints, the days they name and their
* travellers are read here too, through Kysely: the live status only reads
* them, and the reservations domain has no read of flights across all trips.
*/
export class FlightLiveStatesRepository extends TrekRepository<FlightLiveStates> {
/** The flight bookings of one trip. */
async listFlightBookingsForTrip(trip_id: number): Promise<FlightBookingDbRow[]> {
return await this.kysely<Pick<DB, 'reservations' | 'trips'>>()
.selectFrom('reservations')
.innerJoin('trips', 'trips.id', 'reservations.trip_id')
.select([...BOOKING_COLUMNS, 'trips.title as trip_title'])
.where('reservations.trip_id', '=', trip_id)
.where('reservations.type', '=', 'flight')
.orderBy('reservations.id')
.execute();
}
/**
* The flight bookings of every trip that is not archived and whose dates
* (where it has any) reach into `from_date`..`to_date`: the candidates for a
* poll. Which leg is really in its window the caller works out from the
* schedule.
*/
async listFlightBookingsBetween(from_date: string, to_date: string): Promise<FlightBookingDbRow[]> {
return await this.kysely<Pick<DB, 'reservations' | 'trips'>>()
.selectFrom('reservations')
.innerJoin('trips', 'trips.id', 'reservations.trip_id')
.select([...BOOKING_COLUMNS, 'trips.title as trip_title'])
.where('reservations.type', '=', 'flight')
.where((eb) => eb.or([eb('trips.is_archived', 'is', null), eb('trips.is_archived', '=', 0)]))
.where((eb) => eb.or([eb('trips.end_date', 'is', null), eb('trips.end_date', '>=', from_date)]))
.where((eb) => eb.or([eb('trips.start_date', 'is', null), eb('trips.start_date', '<=', to_date)]))
.orderBy('reservations.id')
.execute();
}
/** The endpoints of the given bookings, in their order. */
async listEndpoints(reservation_ids: readonly number[]): Promise<FlightEndpointDbRow[]> {
if (reservation_ids.length === 0) return [];
return await this.kysely<Pick<DB, 'reservation_endpoints'>>()
.selectFrom('reservation_endpoints')
.select([...ENDPOINT_COLUMNS])
.where('reservation_id', 'in', [...reservation_ids])
.orderBy('reservation_id')
.orderBy('sequence')
.execute();
}
/** The date of each given trip day that has one. */
async listDayDates(day_ids: readonly number[]): Promise<Array<{ id: number; date: string }>> {
if (day_ids.length === 0) return [];
const rows = await this.kysely<Pick<DB, 'days'>>()
.selectFrom('days')
.select(['id', 'date'])
.where('id', 'in', [...day_ids])
.execute();
return rows.flatMap((row) => (row.date ? [{ id: row.id, date: row.date }] : []));
}
/**
* The users put on a booking as its travellers who still belong to its trip:
* the owner or a member. Someone removed from the trip stays on the
* traveller list until it is edited, and must hear nothing more of it.
*/
async listTravelerIds(reservation_id: number): Promise<number[]> {
const rows = await this.kysely<Pick<DB, 'reservation_travelers' | 'reservations' | 'trips' | 'trip_members'>>()
.selectFrom('reservation_travelers as rt')
.innerJoin('reservations as r', 'r.id', 'rt.reservation_id')
.innerJoin('trips as t', 't.id', 'r.trip_id')
.select('rt.user_id')
.where('rt.reservation_id', '=', reservation_id)
.where((eb) =>
eb.or([
eb('t.user_id', '=', eb.ref('rt.user_id')),
eb.exists(
eb
.selectFrom('trip_members as tm')
.select('tm.id')
.whereRef('tm.trip_id', '=', 'r.trip_id')
.whereRef('tm.user_id', '=', 'rt.user_id'),
),
]),
)
.orderBy('rt.user_id')
.execute();
return rows.map((row) => row.user_id);
}
/** Every stored state of the given callsigns, whatever their day. */
async findForCallsigns(callsigns: readonly string[]): Promise<FlightLiveStateRow[]> {
if (callsigns.length === 0) return [];
return await this.kysely<Pick<DB, 'flight_live_states'>>()
.selectFrom('flight_live_states')
.selectAll()
.where('callsign', 'in', [...callsigns])
.execute();
}
/** Writes a flight's whole state, replacing the one stored under its key. */
async saveState(row: FlightLiveStateWrite): Promise<void> {
const { callsign, service_date, origin, destination, ...changes } = row;
await this.kysely<Pick<DB, 'flight_live_states'>>()
.insertInto('flight_live_states')
.values({ callsign, service_date, origin, destination, ...changes })
.onConflict((oc) => oc.columns(['callsign', 'service_date', 'origin', 'destination']).doUpdateSet(changes))
.execute();
}
/** Drops the states of flights that departed before `service_date`; answers how many went. */
async deleteBefore(service_date: string): Promise<number> {
const result = await this.kysely<Pick<DB, 'flight_live_states'>>()
.deleteFrom('flight_live_states')
.where('service_date', '<', service_date)
.executeTakeFirst();
return Number(result.numDeletedRows);
}
}
+2
View File
@@ -46,6 +46,7 @@ import { DocSyncModule } from './doc-sync/doc-sync.module';
import { FeedsModule } from './feeds/feeds.module';
import { FerratasModule } from './ferratas/ferratas.module';
import { FilesModule } from './files/files.module';
import { FlightLiveModule } from './flight-live/flight-live.module';
import { GeoModule } from './geo/geo.module';
import { GoogleQuotaModule } from './google-quota/google-quota.module';
import { HealthModule } from './health/health.module';
@@ -178,6 +179,7 @@ import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR, APP_PIPE } from '@nestjs/core';
PhotosModule,
MemoriesModule,
AirtrailModule,
FlightLiveModule,
DawarichModule,
JourneyModule,
JourneyRecordingsModule,
+3 -3
View File
@@ -44,7 +44,7 @@ import { EphemeralTokenService } from '../auth-core/ephemeral-token.service';
// every consumer of this module — a nest→mcp→nest module cycle.
import { revokeUserSessions } from '../../mcp/sessionManager';
import { UserCleanupService } from './user-cleanup.service';
import { splitManagedKeys } from '../common/managed';
import { envLockedKeys, splitManagedKeys, withEnvLocks } from '../common/managed';
import { emitUserDeleted } from '../../plugin-user-lifecycle';
import { verifyJwtAndLoadUser } from '../auth-core/jwt-verify';
import { User } from '../../types';
@@ -711,7 +711,7 @@ export class AuthService {
const value = await readAppSetting(this.appSettings, key);
if (value !== null) result[key] = MASKED_ADMIN_SETTING_KEYS.has(key) ? '••••••••' : value;
}
return { data: result };
return { data: withEnvLocks(result) };
}
async updateAppSettings(
@@ -754,7 +754,7 @@ export class AuthService {
// the admin does own, so a managed install skips those names and keeps the
// rest of the save working. Reported, never thrown: the settings tab sends
// the whole form in one request.
const { blocked } = splitManagedKeys(body as Record<string, unknown>, readEnv().managed.enabled);
const blocked = [...splitManagedKeys(body, readEnv().managed.enabled).blocked, ...envLockedKeys(body)];
await this.uow.transactional(async () => { // the whole form lands, or none of it
for (const key of ADMIN_FORM_SETTING_KEYS) {
@@ -93,6 +93,8 @@ export const APP_SETTINGS = {
// 'auto' (what every install had before Amap existed), 'google', 'amap' or
// 'openstreetmap'; the maps domain validates it (isPlacesProviderChoice).
places_provider: { schema: text, default: 'auto', adminForm: true, publicConfig: true, managedLocked: true },
// Live flight status from the keyless ADS-B feeds (flight-live). On by default; FLIGHT_LIVE_ENABLED wins over it.
flight_live_enabled: { schema: flag, default: 'true', env: (e) => e.integrations.flightLiveEnabled, adminForm: true },
// Read by name elsewhere.
oidc_issuer: { schema: text, default: null, env: (e) => e.oidc.issuer, publicConfig: true },
+37
View File
@@ -1,4 +1,6 @@
import { readEnv } from '../../app-config';
import { RuntimeEnvService } from '../app-config/runtime-env.service';
import { APP_SETTINGS, type AppSettingDef, type AppSettingKey } from './app-settings.registry';
import { byCodeUnit } from './compare';
import { SetMetadata } from '@nestjs/common';
@@ -144,6 +146,7 @@ export const MANAGED_CUSTOMER_KEYS = [
'dark_mode',
'default_currency',
'distance_unit',
'flight_live_enabled',
'map_provider',
'map_tile_url',
'mapbox_3d_enabled',
@@ -192,3 +195,37 @@ export function splitManagedKeys<T extends Record<string, unknown>>(
}
return { allowed, blocked: blocked.sort(byCodeUnit) };
}
/**
* Admin form switches an environment variable holds once it is set. The
* variable wins over the stored value anyway (resolveAppSetting), so the form
* shows the variable's value and a save leaves the stored one alone instead of
* pretending to change what still applies. SMTP keeps its old behaviour.
*/
const ENV_LOCKED_ADMIN_KEYS: readonly AppSettingKey[] = ['flight_live_enabled'];
/** The value the environment pins a switch to, or null while it is unset. */
function envValue(key: AppSettingKey): string | null {
return (APP_SETTINGS[key] as AppSettingDef).env?.(readEnv()) || null;
}
/** The env-held switches the write body sends, which the save skips and reports like the operator's keys. */
export function envLockedKeys(body: Record<string, unknown>): string[] {
return ENV_LOCKED_ADMIN_KEYS.filter((key) => body[key] !== undefined && envValue(key) !== null);
}
/**
* The admin form's values with every env-held switch set to the value that
* applies, and `env_override_<key>` beside it so the form can show the switch
* locked (the shape of `env_override_oidc_only` in the app config).
*/
export function withEnvLocks(values: Record<string, string>): Record<string, string> {
const out = { ...values };
for (const key of ENV_LOCKED_ADMIN_KEYS) {
const value = envValue(key);
if (value === null) continue;
out[key] = value;
out[`env_override_${key}`] = value;
}
return out;
}
@@ -0,0 +1,328 @@
import { FlightLiveStates } from '../../db/entities/FlightLiveStates.entity';
import type {
FlightLiveStatesRepository,
FlightLiveStateWrite,
} from '../../db/repositories/FlightLiveStates.repository';
import { dbNow, type DbTimestamp } from '../../db/types';
import { logDebug, logError } from '../audit/audit-log.logger';
import { NotificationsService, type NotificationPayload } from '../notifications/notifications.service';
import { RealtimeService } from '../realtime/realtime.service';
import {
advance,
closeIfOver,
departureDelay,
emptyState,
failed,
inWatchWindow,
nextPollAt,
pickAircraft,
toLiveLeg,
watchWindow,
type LiveState,
} from './flight-live.derive';
import { FlightLiveFeedService, type FeedLookup } from './flight-live.feeds';
import { FlightLiveService, legKey, type TripLeg } from './flight-live.service';
import { InjectRepository } from '@mikro-orm/nestjs';
import { Injectable } from '@nestjs/common';
type NotifEventType = NotificationPayload['event'];
const DAY_MS = 24 * 60 * 60 * 1000;
/** A delay worth telling the travellers about, in minutes. */
const DELAY_NOTICE_MIN = 15;
/** A client's refresh brings a flight forward only when it is due within this anyway. */
const REFRESH_AHEAD_MS = 2 * 60 * 1000;
/** At most this many feed requests in one tick (a minute); the hourly budget is the real limit. */
const MAX_REQUESTS_PER_TICK = 20;
/** States of flights that departed longer ago are dropped. */
const KEEP_DAYS = 30;
interface PollResult {
asked: number;
changed: number;
skipped: 'disabled' | 'running' | null;
}
function ts(ms: number | null): DbTimestamp | null {
return ms === null ? null : dbNow(new Date(ms));
}
function toWrite(state: LiveState): FlightLiveStateWrite {
const p = state.position;
return {
callsign: state.callsign,
service_date: state.service_date,
origin: state.origin,
destination: state.destination,
phase: state.phase,
departed_at: ts(state.departed_at),
landed_at: ts(state.landed_at),
lat: p?.lat ?? null,
lng: p?.lng ?? null,
altitude_ft: p?.altitude_ft ?? null,
ground_speed_kt: p?.ground_speed_kt ?? null,
track_deg: p?.track_deg ?? null,
vertical_rate_fpm: p?.vertical_rate_fpm ?? null,
on_ground: p ? (p.on_ground ? 1 : 0) : null,
registration: state.registration,
aircraft_type: state.aircraft_type,
source: state.source,
first_seen_at: ts(state.first_seen_at),
last_seen_at: ts(state.last_seen_at),
fetched_at: ts(state.fetched_at),
next_poll_at: ts(state.next_poll_at),
fail_count: state.fail_count,
departed_notified: state.departed_notified ? 1 : 0,
landed_notified: state.landed_notified ? 1 : 0,
delay_notified: state.delay_notified ? 1 : 0,
};
}
/** What a client sees of a state; a poll that changed none of it is not broadcast. */
function visible(state: LiveState): string {
return JSON.stringify([
state.phase,
state.departed_at,
state.landed_at,
state.position,
state.registration,
state.aircraft_type,
state.last_seen_at,
]);
}
/** The local date `days` away from `now`'s UTC date. */
function utcDate(now: number, days: number): string {
return new Date(now + days * DAY_MS).toISOString().slice(0, 10);
}
/**
* The poll behind live flight status, one tick a minute (FlightLiveJob).
*
* It reads every flight leg in its watch window across all trips, asks the
* feed once per flight (callsign and departure day) that is due by its
* cadence or that a client asked to refresh, within the instance's budget,
* stores what changed, sends it to every trip holding the flight and tells
* the travellers when the flight departs, lands or runs late.
*/
@Injectable()
export class FlightLivePollService {
private running = false;
private lastPurgeDay: string | null = null;
/** Waits between two requests; a test hands in one that does not. */
sleep: (ms: number) => Promise<void> = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
constructor(
@InjectRepository(FlightLiveStates) private readonly states: FlightLiveStatesRepository,
private readonly live: FlightLiveService,
private readonly feeds: FlightLiveFeedService,
private readonly notifications: NotificationsService,
private readonly realtime: RealtimeService,
) {}
/**
* One tick. A second call while one runs returns at once.
*
* @txIndependent every flight's state is one write of its own, with network
* I/O between them; a tick cut short keeps what it stored.
*/
async poll(clock: () => number = Date.now): Promise<PollResult> {
if (this.running) return { asked: 0, changed: 0, skipped: 'running' };
this.running = true;
try {
if (!(await this.live.isEnabled())) return { asked: 0, changed: 0, skipped: 'disabled' };
await this.purge(clock());
return await this.pollDue(clock);
} finally {
this.running = false;
}
}
private async purge(now: number): Promise<void> {
const today = utcDate(now, 0);
if (this.lastPurgeDay === today) return;
this.lastPurgeDay = today;
const dropped = await this.states.deleteBefore(utcDate(now, -KEEP_DAYS));
if (dropped > 0) logDebug(`[flight-live] dropped ${dropped} old flight states`);
}
/**
* @txIndependent one write per flight, with a feed request and the notices
* between them; a flight stored stays stored if a later one fails.
*/
private async pollDue(clock: () => number): Promise<PollResult> {
const now = clock();
const bookings = await this.states.listFlightBookingsBetween(utcDate(now, -2), utcDate(now, 2));
const loaded = await this.live.loadLegs(bookings);
const stored = await this.live.statesFor(loaded);
await this.closeEnded(loaded, stored, now);
const groups = new Map<string, TripLeg[]>();
for (const leg of loaded.filter((l) => inWatchWindow(l, now))) {
groups.set(legKey(leg), [...(groups.get(legKey(leg)) ?? []), leg]);
}
const requested = this.live.takeRefreshRequests(now);
const due = [...groups.keys()]
.map((key) => ({ key, state: stored.get(key) ?? null }))
.filter(({ key, state }) => {
if (state?.phase === 'landed') return false;
if (!state || state.next_poll_at === null) return true;
// A client's ask may bring a flight forward a little, never past its cadence or a back-off.
const ahead = requested.has(key) ? REFRESH_AHEAD_MS : 0;
return state.next_poll_at - ahead <= now;
})
.sort(
(a, b) =>
Number(requested.has(b.key)) - Number(requested.has(a.key)) ||
(a.state?.next_poll_at ?? 0) - (b.state?.next_poll_at ?? 0),
);
const queue = due.slice(0, MAX_REQUESTS_PER_TICK).map(({ key, state }) => ({ legs: groups.get(key) ?? [], state }));
const result: PollResult = { asked: 0, changed: 0, skipped: null };
await this.pollEach(queue, clock, result);
return result;
}
/**
* Closes every flight whose window ended while it was still taxiing or in
* the air: stored as unknown with no next poll, and sent to its trips.
*
* @txIndependent one write per closed flight; each stands on its own.
*/
private async closeEnded(legs: readonly TripLeg[], stored: Map<string, LiveState>, now: number): Promise<void> {
const ended = new Map<string, TripLeg[]>();
for (const leg of legs) {
const span = watchWindow(leg);
if (span !== null && now > span.end) ended.set(legKey(leg), [...(ended.get(legKey(leg)) ?? []), leg]);
}
await [...ended].reduce(async (previous, [key, flightLegs]) => {
await previous;
const state = stored.get(key);
const route = flightLegs[0];
if (!state || !route) return;
const closed = closeIfOver(state, route, now);
if (closed === state) return;
stored.set(key, closed);
await this.states.saveState(toWrite(closed));
this.broadcast(flightLegs, closed, now);
}, Promise.resolve());
}
/**
* Asks about the flights one after the other, waiting out the feed spacing
* between them, until the queue or the budget runs out.
*
* @txIndependent one write per flight (pollDue).
*/
private async pollEach(
queue: ReadonlyArray<{ legs: TripLeg[]; state: LiveState | null }>,
clock: () => number,
result: PollResult,
): Promise<void> {
const [head, ...rest] = queue;
const route = head?.legs[0];
if (!head || !route) return;
const wait = this.feeds.waitMs(clock());
if (wait > 0) await this.sleep(wait);
if (!this.feeds.canAsk(clock())) return;
const prev =
head.state ?? emptyState(route.callsign, route.service_date, route.origin.code, route.destination.code);
const answer = await this.feeds.lookup(route.callsign, clock);
// Spelled out: without strictNullChecks `ok` alone does not narrow the union.
if (!answer.ok && (answer as Extract<FeedLookup, { ok: false }>).reason === 'budget') return;
result.asked++;
const at = clock();
let next = answer.ok ? advance(prev, pickAircraft(answer.aircraft, route), route, at, answer.source) : failed(prev);
next = closeIfOver({ ...next, next_poll_at: nextPollAt(next, route, at) }, route, at);
const notices = this.dueNotices(prev, next, head.legs, at);
next = { ...next, ...notices.flags };
await this.states.saveState(toWrite(next));
if (visible(prev) !== visible(next)) {
result.changed++;
this.broadcast(head.legs, next, at);
}
await this.announce(notices.events, head.legs, next, at);
await this.pollEach(rest, clock, result);
}
/** The notices this poll owes the travellers, each at most once per flight, and the flags that record them. */
private dueNotices(
prev: LiveState,
next: LiveState,
legs: readonly TripLeg[],
now: number,
): { events: NotifEventType[]; flags: Pick<LiveState, 'departed_notified' | 'landed_notified' | 'delay_notified'> } {
const events: NotifEventType[] = [];
const flags = {
departed_notified: prev.departed_notified,
landed_notified: prev.landed_notified,
delay_notified: prev.delay_notified,
};
const delays = legs.map((leg) => departureDelay(leg, next, now)).filter((d): d is number => d !== null);
if (!flags.delay_notified && delays.some((d) => d >= DELAY_NOTICE_MIN)) {
events.push('flight_delayed');
flags.delay_notified = true;
}
if (!flags.departed_notified && next.phase === 'airborne') {
events.push('flight_departed');
flags.departed_notified = true;
}
if (!flags.landed_notified && next.phase === 'landed') {
events.push('flight_landed');
flags.landed_notified = true;
// Seen only once it was down: departing now would be news from the past.
flags.departed_notified = true;
}
return { events, flags };
}
/** The changed flight to every trip holding one of its legs, as the client reads it. */
private broadcast(legs: readonly TripLeg[], state: LiveState, now: number): void {
const byTrip = new Map<number, TripLeg[]>();
for (const leg of legs) byTrip.set(leg.trip_id, [...(byTrip.get(leg.trip_id) ?? []), leg]);
for (const [tripId, tripLegs] of byTrip) {
this.realtime.broadcast(tripId, 'flight:live', { flights: tripLegs.map((leg) => toLiveLeg(leg, state, now)) });
}
}
/**
* Each notice to the travellers of each booking holding the flight, or to
* the whole trip when nobody is put on the booking.
*
* @txStandalone a notice stands on its own; the state that records it is already stored.
*/
private async announce(
events: readonly NotifEventType[],
legs: readonly TripLeg[],
state: LiveState,
now: number,
): Promise<void> {
if (events.length === 0) return;
// One leg after the other, as every other sender in the codebase does: a notice writes its rows.
await legs.reduce(async (previous, leg) => {
await previous;
const params: Record<string, string> = {
flight: leg.flight_number,
from: leg.origin.code,
to: leg.destination.code,
trip: leg.trip_title,
tripId: String(leg.trip_id),
delay: String(Math.max(0, departureDelay(leg, state, now) ?? 0)),
};
try {
const travelers = await this.states.listTravelerIds(leg.reservation_id);
const targets: Array<{ scope: 'trip' | 'user'; targetId: number }> =
travelers.length === 0
? [{ scope: 'trip', targetId: leg.trip_id }]
: travelers.map((userId) => ({ scope: 'user', targetId: userId }));
const sends = events.flatMap((event) => targets.map((target) => ({ event, ...target })));
await sends.reduce(async (sent, payload) => {
await sent;
await this.notifications.send({ ...payload, actorId: null, params });
}, Promise.resolve());
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
logError(`[flight-live] notice for ${leg.callsign}: ${reason}`);
}
}, Promise.resolve());
}
}
@@ -0,0 +1,50 @@
/**
* How often this instance may ask the open ADS-B feeds: at most one request a
* second and 200 an hour across every flight, whichever trip asked. The feeds
* are run by volunteers on donated capacity; a planner instance has no reason
* to come near what they allow. A 429 or a run of failures pauses a source for
* a quarter of an hour.
*
* Process state on purpose: TREK runs as one process, and the job that spends
* the budget holds a scheduler lease, so a second process does not tick at the
* same time.
*/
export const BUDGET_PER_HOUR = 200;
const MIN_SPACING_MS = 1000;
const BACKOFF_MS = 15 * 60 * 1000;
const HOUR_MS = 60 * 60 * 1000;
export class FeedBudget {
private readonly sent: number[] = [];
private readonly pausedUntil = new Map<string, number>();
/** Takes one request from the budget when there is one left now. */
tryTake(now: number): boolean {
while (this.sent.length > 0 && now - this.sent[0] >= HOUR_MS) this.sent.shift();
const last = this.sent[this.sent.length - 1];
if (last !== undefined && now - last < MIN_SPACING_MS) return false;
if (this.sent.length >= BUDGET_PER_HOUR) return false;
this.sent.push(now);
return true;
}
/** How long until the spacing lets the next request go, in ms; 0 when it may go now. */
waitMs(now: number): number {
const last = this.sent[this.sent.length - 1];
return last === undefined ? 0 : Math.max(0, MIN_SPACING_MS - (now - last));
}
/** Whether the hourly budget is used up. */
exhausted(now: number): boolean {
return this.sent.filter((at) => now - at < HOUR_MS).length >= BUDGET_PER_HOUR;
}
pause(source: string, now: number): void {
this.pausedUntil.set(source, now + BACKOFF_MS);
}
isPaused(source: string, now: number): boolean {
const until = this.pausedUntil.get(source);
return until !== undefined && now < until;
}
}
@@ -0,0 +1,79 @@
import { logWarn } from '../audit/audit-log.logger';
import fs from 'node:fs';
import path from 'node:path';
/**
* From the flight number a traveller booked to the callsign the aircraft
* transmits: "LH 716", "LH0716" and "lh716" all fly as DLH716.
*
* The IATA to ICAO table is server/assets/airlines-icao.json, built by
* scripts/build-airlines.mjs from the VRS standing data (CC0); where IATA hands
* one code to several airlines the one with the most recorded routes wins (the
* script says why). Read once and kept, like the airports.
*/
let table: ReadonlyMap<string, string> | null = null;
/** The IATA airline code to ICAO callsign prefix table, empty when the asset is missing. */
export function airlineTable(): ReadonlyMap<string, string> {
if (table) return table;
const file = path.join(__dirname, '..', '..', '..', 'assets', 'airlines-icao.json');
if (!fs.existsSync(file)) {
logWarn('[flight-live] airlines-icao.json missing: run `node scripts/build-airlines.mjs`');
table = new Map();
return table;
}
const raw = JSON.parse(fs.readFileSync(file, 'utf8')) as Record<string, unknown>;
table = new Map(Object.entries(raw).filter((entry): entry is [string, string] => typeof entry[1] === 'string'));
return table;
}
const ICAO_FORM = /^([A-Z]{3})(\d{1,4}[A-Z]?)$/;
/** Two characters with at least one letter, then the number: LH716, U21234, 4U9520. */
const IATA_FORM = /^([A-Z][A-Z0-9]|[0-9][A-Z])(\d{1,4}[A-Z]?)$/;
const NUMBER_ONLY = /^(\d{1,4}[A-Z]?)$/;
/** "0716" -> "716", "0007A" -> "7A", "0" stays "0". */
function trimNumber(number: string): string {
return number.replace(/^0+(?=\d)/, '');
}
/** The ICAO prefix an airline field names, when it is a code at all ("LH", "DLH"); a name is not looked up. */
function prefixOfAirline(airline: string, airlines: ReadonlyMap<string, string>): string | null {
const code = airline.trim().toUpperCase();
if (/^[A-Z]{3}$/.test(code)) return code;
if (/^(?:[A-Z][A-Z0-9]|[0-9][A-Z])$/.test(code)) return airlines.get(code) ?? null;
return null;
}
/**
* The ICAO callsign of a booked flight, or null when it cannot be told.
*
* A number already in ICAO form (three letters and digits) is taken as it is.
* A number without an airline code borrows one from `airline` when that holds
* a code. Spaces and dashes are dropped and leading zeros of the number go,
* as the transponder sends them.
*/
export function toCallsign(
flightNumber: string | null | undefined,
airline?: string | null,
airlines: ReadonlyMap<string, string> = airlineTable(),
): string | null {
if (typeof flightNumber !== 'string') return null;
const compact = flightNumber.toUpperCase().replace(/[\s-]/g, '');
if (!compact) return null;
const icao = ICAO_FORM.exec(compact);
if (icao) return icao[1] + trimNumber(icao[2]);
const iata = IATA_FORM.exec(compact);
if (iata) {
const prefix = airlines.get(iata[1]);
return prefix ? prefix + trimNumber(iata[2]) : null;
}
const bare = NUMBER_ONLY.exec(compact);
if (bare && typeof airline === 'string') {
const prefix = prefixOfAirline(airline, airlines);
return prefix ? prefix + trimNumber(bare[1]) : null;
}
return null;
}
@@ -0,0 +1,30 @@
import type { TripAccess } from '../../db/repositories/Trips.repository';
import { JwtAuthGuard } from '../auth-core/jwt-auth.guard';
import { ResponseContract } from '../common/response-contract';
import { TripAccessGuard } from '../permissions/trip-access.guard';
import { Trip } from '../permissions/trip.decorator';
import { FlightLiveService } from './flight-live.service';
import { Controller, Get, UseGuards } from '@nestjs/common';
import { tripFlightLiveResponseSchema, type TripFlightLiveResponse } from '@trek/shared';
/**
* GET /api/trips/:tripId/flights/live: every flight leg of the trip with its
* schedule in UTC and what the open ADS-B feeds last said about it.
*
* Trip-scoped like reading the bookings: TripAccessGuard answers 404 for a trip
* the caller cannot reach, and reading needs no permission beyond access. The
* read never asks a feed; an in-window leg whose answer is older than two
* minutes is put up for the poll's next tick. With the admin switch off it
* answers `{ enabled: false, attribution: null, flights: [] }`.
*/
@Controller('api/trips/:tripId/flights')
@UseGuards(JwtAuthGuard, TripAccessGuard)
export class FlightLiveController {
constructor(private readonly live: FlightLiveService) {}
@Get('live')
@ResponseContract(tripFlightLiveResponseSchema)
async list(@Trip() trip: TripAccess): Promise<TripFlightLiveResponse> {
return await this.live.tripFlights(trip.id);
}
}
@@ -0,0 +1,389 @@
import { haversineKm } from '../common/geo';
import type { ScheduledLeg } from './flight-live.schedule';
import type { FlightLiveLeg, FlightLivePhase, FlightLivePosition, FlightLiveSource } from '@trek/shared';
/**
* What the positions of an aircraft say about a booked flight, worked out
* without the airline: the phase, wheels-up and touchdown, the delays and the
* arrival estimate, and when to ask the feed again. Pure, every clock passed
* in as epoch milliseconds.
*/
const MIN = 60 * 1000;
const HOUR = 60 * MIN;
const KT_TO_KMH = 1.852;
/** The watch window around a leg: from three hours before departure to two after arrival. */
const WATCH_BEFORE_MS = 3 * HOUR;
const WATCH_AFTER_MS = 2 * HOUR;
/** Without a scheduled arrival the window closes this long after departure. */
const ASSUMED_FLIGHT_MS = 16 * HOUR;
/** A callsign not heard this long after the scheduled departure is reported unknown. */
const UNKNOWN_AFTER_MS = 2 * HOUR;
/** An airborne aircraft not heard for longer is shown as stale. */
const STALE_AFTER_MS = 10 * MIN;
/** Approach, landing and taxi on top of the great-circle flight time. */
const APPROACH_BUFFER_MS = 8 * MIN;
/** One aircraft as the feed reported it, already validated. */
export interface FeedAircraft {
hex: string;
flight: string | null;
registration: string | null;
type: string | null;
/** Feet; 'ground' when the transponder says it is on the ground. */
alt_baro: number | 'ground' | null;
alt_geom: number | null;
gs: number | null;
track: number | null;
baro_rate: number | null;
geom_rate: number | null;
lat: number | null;
lon: number | null;
/** Seconds since the feed last heard the aircraft, and since its last position. */
seen: number | null;
seen_pos: number | null;
}
/** The state of one flight (callsign, service date) between polls; times are epoch ms. */
export interface LiveState {
callsign: string;
service_date: string;
/** IATA codes of the sector: one flight number can fly two sectors on one day. */
origin: string;
destination: string;
phase: FlightLivePhase;
departed_at: number | null;
landed_at: number | null;
position: FlightLivePosition | null;
registration: string | null;
aircraft_type: string | null;
source: FlightLiveSource | null;
first_seen_at: number | null;
last_seen_at: number | null;
fetched_at: number | null;
next_poll_at: number | null;
fail_count: number;
departed_notified: boolean;
landed_notified: boolean;
delay_notified: boolean;
}
export function emptyState(callsign: string, service_date: string, origin: string, destination: string): LiveState {
return {
callsign,
service_date,
origin,
destination,
phase: 'scheduled',
departed_at: null,
landed_at: null,
position: null,
registration: null,
aircraft_type: null,
source: null,
first_seen_at: null,
last_seen_at: null,
fetched_at: null,
next_poll_at: null,
fail_count: 0,
departed_notified: false,
landed_notified: false,
delay_notified: false,
};
}
type Route = Pick<ScheduledLeg, 'origin' | 'destination' | 'scheduled_departure' | 'scheduled_arrival'>;
type Schedule = Pick<Route, 'scheduled_departure' | 'scheduled_arrival'>;
type WatchWindow = { start: number; end: number };
function kmTo(lat: number, lng: number, airport: { lat: number; lng: number }): number {
return haversineKm(lat, lng, airport.lat, airport.lng);
}
function finite(value: number | null | undefined): number | null {
return typeof value === 'number' && Number.isFinite(value) ? value : null;
}
/** The leg's watch window, or null without a scheduled departure. */
export function watchWindow(leg: Schedule): WatchWindow | null {
if (leg.scheduled_departure === null) return null;
const arrival = leg.scheduled_arrival ?? leg.scheduled_departure + ASSUMED_FLIGHT_MS;
return { start: leg.scheduled_departure - WATCH_BEFORE_MS, end: arrival + WATCH_AFTER_MS };
}
export function inWatchWindow(leg: Schedule, now: number): boolean {
const span = watchWindow(leg);
return span !== null && now >= span.start && now <= span.end;
}
/**
* The aircraft that flies this leg among those the feed answered for the
* callsign: the one with a position nearest the great circle between the two
* airports (least detour over the direct distance). A callsign is reused by
* the next leg of a rotation, so two can be in the air at once.
*/
export function pickAircraft(aircraft: readonly FeedAircraft[], route: Route): FeedAircraft | null {
const total = kmTo(route.origin.lat, route.origin.lng, route.destination);
let best: FeedAircraft | null = null;
let bestScore = Number.POSITIVE_INFINITY;
for (const ac of aircraft) {
const lat = finite(ac.lat);
const lng = finite(ac.lon);
const score =
lat === null || lng === null
? Number.MAX_VALUE
: kmTo(lat, lng, route.origin) + kmTo(lat, lng, route.destination) - total;
if (best === null || score < bestScore) {
best = ac;
bestScore = score;
}
}
return best;
}
/** The aircraft's report as a position at its own time; null without coordinates. */
export function observe(
ac: FeedAircraft,
now: number,
): { position: FlightLivePosition; positionAt: number; heardAt: number } | null {
const lat = finite(ac.lat);
const lng = finite(ac.lon);
if (lat === null || lng === null) return null;
const onGround = ac.alt_baro === 'ground';
const baro = typeof ac.alt_baro === 'number' ? finite(ac.alt_baro) : null;
const altitude = onGround ? 0 : (baro ?? finite(ac.alt_geom));
return {
position: {
lat,
lng,
altitude_ft: altitude,
ground_speed_kt: finite(ac.gs),
track_deg: finite(ac.track),
vertical_rate_fpm: finite(ac.baro_rate) ?? finite(ac.geom_rate),
on_ground: onGround,
},
positionAt: now - Math.max(0, finite(ac.seen_pos) ?? finite(ac.seen) ?? 0) * 1000,
heardAt: now - Math.max(0, finite(ac.seen) ?? 0) * 1000,
};
}
/**
* The next state of a flight after one answer from the feed. `aircraft` is the
* picked one, or null when the feed knew nothing of the callsign.
*
* - On the ground within 30 km of the destination after having flown, or
* after the scheduled departure: landed.
* - On the ground at the origin: taxiing.
* - In the air: airborne; wheels-up is the first airborne report while still
* below 10,000 ft within 60 km of the origin, else it stays unknown.
* - Lost below 5,000 ft within 40 km of the destination and not heard for ten
* minutes: landed at its last report.
* - Never heard two hours after the scheduled departure: unknown.
*/
export function advance(
prev: LiveState,
aircraft: FeedAircraft | null,
route: Route,
now: number,
source: FlightLiveSource,
): LiveState {
const next: LiveState = { ...prev, fetched_at: now, source, fail_count: 0 };
if (prev.phase === 'landed') return next;
const seen = aircraft ? observe(aircraft, now) : null;
if (seen && aircraft) {
const { position, positionAt, heardAt } = seen;
const fromOrigin = kmTo(position.lat, position.lng, route.origin);
const toDestination = kmTo(position.lat, position.lng, route.destination);
const hasFlown = prev.phase === 'airborne' || prev.departed_at !== null;
next.position = position;
next.registration = aircraft.registration ?? prev.registration;
next.aircraft_type = aircraft.type ?? prev.aircraft_type;
next.first_seen_at = prev.first_seen_at ?? heardAt;
next.last_seen_at = heardAt;
if (position.on_ground) {
// Down at the destination after the scheduled departure counts even unseen in the air (no coverage at
// the origin); down anywhere else after flying is a diversion.
const departureDue = route.scheduled_departure !== null && now >= route.scheduled_departure;
const atDestination = toDestination <= 30 && (hasFlown || departureDue);
if (atDestination || (hasFlown && fromOrigin > 30)) {
next.phase = 'landed';
next.landed_at = prev.landed_at ?? positionAt;
} else {
next.phase = 'taxiing';
}
} else {
if (prev.phase !== 'airborne' && prev.departed_at === null) {
const low = position.altitude_ft !== null && position.altitude_ft < 10000;
next.departed_at = low && fromOrigin <= 60 ? positionAt : null;
}
next.phase = 'airborne';
}
return next;
}
if (prev.phase === 'airborne' && prev.position && prev.last_seen_at !== null) {
const { lat, lng, altitude_ft } = prev.position;
const lostLow = altitude_ft !== null && altitude_ft < 5000 && kmTo(lat, lng, route.destination) <= 40;
if (lostLow && now - prev.last_seen_at >= STALE_AFTER_MS) {
next.phase = 'landed';
next.landed_at = prev.last_seen_at;
return next;
}
}
if (
prev.first_seen_at === null &&
route.scheduled_departure !== null &&
now >= route.scheduled_departure + UNKNOWN_AFTER_MS
) {
next.phase = 'unknown';
}
return next;
}
/** Whether the leg's watch window is over. */
function windowOver(leg: Schedule, now: number): boolean {
const span = watchWindow(leg);
return span !== null && now > span.end;
}
/**
* A flight still taxiing or in the air when its window closes went quiet
* without a landing anyone saw (out of coverage, switched off): it is closed
* as unknown, never left in the air, and asked about no more.
*/
export function closeIfOver(state: LiveState, route: Schedule, now: number): LiveState {
if (!windowOver(route, now) || (state.phase !== 'airborne' && state.phase !== 'taxiing')) return state;
return { ...state, phase: 'unknown', next_poll_at: null };
}
/** A failed ask: the state stays, the failure is counted. */
export function failed(prev: LiveState): LiveState {
return { ...prev, fail_count: prev.fail_count + 1 };
}
/**
* When to ask the feed next about this flight, or null to stop: landed, or
* past its window.
*
* - unknown: every 15 minutes until the window closes;
* - airborne: every 3 minutes, every 6 when more than two hours of flying
* from both airports at its ground speed;
* - more than 40 minutes before departure: every 10 minutes;
* - around departure until airborne: every 2 minutes;
* - three failures in a row: 15 minutes.
*/
export function nextPollAt(state: LiveState, route: Route, now: number): number | null {
if (state.phase === 'landed') return null;
const span = watchWindow(route);
if (!span || now > span.end) return null;
if (state.fail_count >= 3 || state.phase === 'unknown') return now + 15 * MIN;
if (state.phase === 'airborne') {
const at = state.position;
const gs = at?.ground_speed_kt ?? null;
if (at && gs !== null && gs > 0) {
const kmh = gs * KT_TO_KMH;
const hoursFrom = (airport: { lat: number; lng: number }) => kmTo(at.lat, at.lng, airport) / kmh;
if (hoursFrom(route.origin) > 2 && hoursFrom(route.destination) > 2) return now + 6 * MIN;
}
return now + 3 * MIN;
}
if (route.scheduled_departure !== null && now < route.scheduled_departure - 40 * MIN) {
return Math.min(now + 10 * MIN, Math.max(now + 2 * MIN, route.scheduled_departure - 40 * MIN));
}
return now + 2 * MIN;
}
function iso(ms: number | null): string | null {
return ms === null ? null : new Date(ms).toISOString();
}
function minutes(ms: number): number {
return Math.round(ms / MIN);
}
/**
* The leg as the client reads it: the schedule, and what the stored state
* says on top of it. Without a state the schedule alone answers: scheduled
* (the client tells a past one from its times), unknown late in the window.
* After the window nothing is in the air or taxiing any more: such a state
* reads as unknown.
*/
export function toLiveLeg(leg: ScheduledLeg, state: LiveState | null, now: number): FlightLiveLeg {
const total = kmTo(leg.origin.lat, leg.origin.lng, leg.destination);
const span = watchWindow(leg);
let phase: FlightLivePhase = state?.phase ?? 'scheduled';
if (!state && leg.scheduled_departure !== null && span && now <= span.end) {
if (now >= leg.scheduled_departure + UNKNOWN_AFTER_MS) phase = 'unknown';
}
if ((phase === 'airborne' || phase === 'taxiing') && windowOver(leg, now)) phase = 'unknown';
const position = state?.position ?? null;
const remaining = position
? kmTo(position.lat, position.lng, leg.destination)
: phase === 'landed'
? 0
: phase === 'scheduled' || phase === 'taxiing'
? total
: null;
const progress = remaining === null || total <= 0 ? null : Math.min(1, Math.max(0, 1 - remaining / total));
const gs = position?.ground_speed_kt ?? null;
const estimate =
phase === 'airborne' && position && remaining !== null && gs !== null && gs > 150
? (state?.last_seen_at ?? now) + (remaining / (gs * KT_TO_KMH)) * HOUR + APPROACH_BUFFER_MS
: null;
const departedAt = state?.departed_at ?? null;
const landedAt = state?.landed_at ?? null;
const arrivalBasis = landedAt ?? estimate;
const lastSeen = state?.last_seen_at ?? null;
return {
reservation_id: leg.reservation_id,
leg_index: leg.leg_index,
callsign: leg.callsign,
flight_number: leg.flight_number,
service_date: leg.service_date,
origin: leg.origin,
destination: leg.destination,
phase,
scheduled_departure: iso(leg.scheduled_departure),
scheduled_arrival: iso(leg.scheduled_arrival),
departed_at: iso(departedAt),
landed_at: iso(landedAt),
estimated_arrival: iso(estimate === null ? null : Math.round(estimate)),
departure_delay_min:
departedAt !== null && leg.scheduled_departure !== null ? minutes(departedAt - leg.scheduled_departure) : null,
arrival_delay_min:
arrivalBasis !== null && leg.scheduled_arrival !== null ? minutes(arrivalBasis - leg.scheduled_arrival) : null,
progress,
distance_total_km: Math.round(total),
distance_remaining_km: remaining === null ? null : Math.round(remaining),
position,
aircraft:
state && (state.registration || state.aircraft_type)
? { registration: state.registration, type: state.aircraft_type }
: null,
last_seen_at: iso(lastSeen),
fetched_at: iso(state?.fetched_at ?? null),
stale: phase === 'airborne' && lastSeen !== null && now - lastSeen > STALE_AFTER_MS,
source: state?.source ?? null,
};
}
/**
* How late the leg is running at departure, in minutes, when there is
* evidence of it; else null. Wheels-up says it outright. An aircraft still
* heard on the ground after the scheduled time is late by at least the time it
* was last heard, but only while that report is recent: one gone quiet at the
* gate says nothing about now.
*/
export function departureDelay(leg: ScheduledLeg, state: LiveState, now: number): number | null {
const scheduled = leg.scheduled_departure;
if (scheduled === null) return null;
if (state.departed_at !== null) return minutes(state.departed_at - scheduled);
const heard = state.last_seen_at;
if (state.phase !== 'taxiing' || heard === null || heard <= scheduled) return null;
return now - heard < STALE_AFTER_MS ? minutes(heard - scheduled) : null;
}
@@ -0,0 +1,186 @@
import { readCappedJson } from '../../utils/cappedFetch';
import { RuntimeEnvService } from '../app-config/runtime-env.service';
import { logWarn } from '../audit/audit-log.logger';
import { FeedBudget } from './flight-live.budget';
import type { FeedAircraft } from './flight-live.derive';
import { Injectable } from '@nestjs/common';
import type { FlightLiveSource } from '@trek/shared';
import { z } from 'zod';
/**
* The two keyless ADS-B feeds TREK reads live flights from. adsb.lol first;
* adsb.fi only when this is not a managed install, because adsb.fi's terms
* rule out commercial use. Only the callsign goes out, never a user, a trip
* or a route. Both answer the readsb "v2" shape.
*/
const FEED_TIMEOUT_MS = 8000;
/** A callsign's answer is one or two aircraft, a few hundred bytes each. */
const MAX_FEED_BYTES = 256 * 1024;
/** Failures in a row before a source is paused like a 429. */
const FAILURES_BEFORE_PAUSE = 3;
export const FEED_ATTRIBUTION: Record<FlightLiveSource, string> = {
'adsb.lol': 'Live positions: adsb.lol (ODbL)',
'adsb.fi': 'Live positions: adsb.fi',
};
const FEED_URL: Record<FlightLiveSource, (callsign: string) => string> = {
'adsb.lol': (callsign) => `https://api.adsb.lol/v2/callsign/${callsign}`,
'adsb.fi': (callsign) => `https://opendata.adsb.fi/api/v2/callsign/${callsign}`,
};
const num = z.number().nullish();
const str = z.string().nullish();
/** The fields of one aircraft TREK reads; everything else in the answer is dropped. */
const aircraftSchema = z.object({
hex: z.string(),
flight: str,
r: str,
t: str,
alt_baro: z.union([z.number(), z.literal('ground')]).nullish(),
alt_geom: num,
gs: num,
track: num,
baro_rate: num,
geom_rate: num,
lat: num,
lon: num,
seen: num,
seen_pos: num,
});
const feedAnswerSchema = z.object({
ac: z.array(aircraftSchema).max(50).nullish(),
now: z.number(),
});
const CALLSIGN_RE = /^[A-Z0-9]{3,8}$/;
export type FeedLookup =
| { ok: true; source: FlightLiveSource; now: number; aircraft: FeedAircraft[] }
| { ok: false; reason: 'budget' | 'unavailable' };
type SourceAnswer =
{ ok: true; now: number; aircraft: FeedAircraft[] } | { ok: false; status: number | null; reason: string };
function toAircraft(ac: z.infer<typeof aircraftSchema>): FeedAircraft {
return {
hex: ac.hex,
flight: ac.flight?.trim() || null,
registration: ac.r?.trim() || null,
type: ac.t?.trim() || null,
alt_baro: ac.alt_baro ?? null,
alt_geom: ac.alt_geom ?? null,
gs: ac.gs ?? null,
track: ac.track ?? null,
baro_rate: ac.baro_rate ?? null,
geom_rate: ac.geom_rate ?? null,
lat: ac.lat ?? null,
lon: ac.lon ?? null,
seen: ac.seen ?? null,
seen_pos: ac.seen_pos ?? null,
};
}
@Injectable()
export class FlightLiveFeedService {
private readonly budget = new FeedBudget();
private readonly failures = new Map<FlightLiveSource, number>();
/** Waits out the spacing before a fallback request; a test hands in one that does not. */
sleep: (ms: number) => Promise<void> = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
constructor(private readonly env: RuntimeEnvService) {}
/** The sources this install may ask, in order. */
sources(): FlightLiveSource[] {
return this.env.isManaged() ? ['adsb.lol'] : ['adsb.lol', 'adsb.fi'];
}
/** The credit line of the first source this install asks. */
attribution(): string {
return FEED_ATTRIBUTION[this.sources()[0]];
}
/** Whether one more request fits in the budget right now. */
canAsk(now: number = Date.now()): boolean {
return this.budget.waitMs(now) === 0 && !this.budget.exhausted(now);
}
/** How long until the spacing lets the next request go. */
waitMs(now: number = Date.now()): number {
return this.budget.waitMs(now);
}
/**
* The aircraft flying under a callsign, from the first source that answers.
* Each request spends the budget; a source that answered 429, or failed
* three times in a row, is paused for a quarter of an hour.
*/
async lookup(callsign: string, now: () => number = Date.now): Promise<FeedLookup> {
if (!CALLSIGN_RE.test(callsign)) return { ok: false, reason: 'unavailable' };
return await this.lookupFrom(this.sources(), callsign, now, false);
}
/** The first of the sources that answers, one after the other; `asked` says whether one was asked already. */
private async lookupFrom(
sources: readonly FlightLiveSource[],
callsign: string,
now: () => number,
asked: boolean,
): Promise<FeedLookup> {
const [source, ...rest] = sources;
if (source === undefined) return { ok: false, reason: asked ? 'unavailable' : 'budget' };
if (this.budget.isPaused(source, now())) return await this.lookupFrom(rest, callsign, now, asked);
const wait = this.budget.waitMs(now());
if (wait > 0) await this.sleep(wait);
if (!this.budget.tryTake(now())) return { ok: false, reason: asked ? 'unavailable' : 'budget' };
const answer = await this.ask(source, callsign);
if (answer.ok) {
this.failures.set(source, 0);
return { ok: true, source, now: answer.now, aircraft: answer.aircraft };
}
// Spelled out: the server builds without strictNullChecks, where `ok` alone does not narrow the union.
const failure = answer as Extract<SourceAnswer, { ok: false }>;
const count = (this.failures.get(source) ?? 0) + 1;
this.failures.set(source, count);
if (failure.status === 429 || count >= FAILURES_BEFORE_PAUSE) {
this.budget.pause(source, now());
this.failures.set(source, 0);
}
logWarn(`[flight-live] ${source} for ${callsign}: ${failure.reason}`);
return await this.lookupFrom(rest, callsign, now, true);
}
private async ask(source: FlightLiveSource, callsign: string): Promise<SourceAnswer> {
let response: Response;
try {
response = await fetch(FEED_URL[source](encodeURIComponent(callsign)), {
headers: { Accept: 'application/json' },
signal: AbortSignal.timeout(FEED_TIMEOUT_MS),
});
} catch (err) {
return { ok: false, status: null, reason: err instanceof Error ? err.message : String(err) };
}
if (!response.ok) {
await response.body?.cancel().catch(() => undefined);
return { ok: false, status: response.status, reason: `HTTP ${response.status}` };
}
let body: unknown;
try {
body = await readCappedJson<unknown>(response, MAX_FEED_BYTES);
} catch (err) {
return { ok: false, status: null, reason: err instanceof Error ? err.message : String(err) };
}
if (body === undefined) return { ok: false, status: null, reason: 'unreadable or oversized answer' };
const parsed = feedAnswerSchema.safeParse(body);
if (!parsed.success) return { ok: false, status: null, reason: 'unexpected answer shape' };
const wanted = callsign.toUpperCase();
const aircraft = (parsed.data.ac ?? [])
.map(toAircraft)
.filter((ac) => ac.flight === null || ac.flight.toUpperCase() === wanted);
return { ok: true, now: parsed.data.now, aircraft };
}
}
@@ -0,0 +1,33 @@
import { logDebug, logError } from '../audit/audit-log.logger';
import { CronRegistrarService } from '../scheduling/cron-registrar.service';
import { FlightLivePollService } from './flight-live-poll.service';
import { Injectable, type OnApplicationBootstrap } from '@nestjs/common';
/**
* Every minute, the flights in their watch window asked of the ADS-B feeds
* by their cadence (FlightLivePollService). The registrar runs the tick in a
* request context under the job's lease and never starts it twice; the poll
* keeps a guard of its own for a tick that outlasts its minute.
*/
@Injectable()
export class FlightLiveJob implements OnApplicationBootstrap {
constructor(
private readonly registrar: CronRegistrarService,
private readonly poll: FlightLivePollService,
) {}
onApplicationBootstrap(): void {
if (!this.registrar.isEnabled()) return;
this.registrar.register('flight-live', '* * * * *', () => this.tick());
}
/** @txIndependent each flight's state is a write of its own (FlightLivePollService.poll). */
async tick(): Promise<void> {
try {
const result = await this.poll.poll();
if (result.asked > 0) logDebug(`[flight-live] asked ${result.asked}, ${result.changed} changed`);
} catch (err: unknown) {
logError(`[flight-live] poll failed: ${err instanceof Error ? err.message : String(err)}`);
}
}
}
@@ -0,0 +1,32 @@
import { noAccess } from '../../mcp/tools/_shared';
import { McpController, ok, Tool, TOOL_ANNOTATIONS_READONLY, type McpContext } from '../../nest-mcp';
import { TripAccessService } from '../trip-membership/trip-access.service';
import { FlightLiveService } from './flight-live.service';
import { idSchema, tripFlightLiveResponseSchema } from '@trek/shared';
/**
* get_trip_flight_status, the twin of GET /api/trips/:tripId/flights/live:
* the same service method behind the same check (the caller must see the
* trip; reading the bookings needs nothing more). The answer is parsed
* against the REST route's response contract, so both surfaces say the same.
*/
@McpController()
export class FlightLiveMcp {
constructor(
private readonly live: FlightLiveService,
private readonly access: TripAccessService,
) {}
@Tool({
name: 'get_trip_flight_status',
description:
"Get the live status of a trip's booked flights, one entry per flight leg with a known flight number and both airports: the schedule in UTC with each airport's time zone, the phase (scheduled, taxiing, airborne, landed, unknown), observed wheels-up and touchdown, an estimated arrival with the delays worked out from it, progress and distances along the great circle, the last position with altitude and speed, and the aircraft's registration and type. Positions come from free open ADS-B feeds, so gates, terminals and cancellations are not known, and every delay and arrival time is an estimate. `enabled` is false when the admin switched live flight status off; `attribution` is the credit line to show with the data.",
inputSchema: { tripId: idSchema },
annotations: TOOL_ANNOTATIONS_READONLY,
access: { group: 'reservations', mode: 'read' },
})
async getTripFlightStatus({ tripId }: { tripId: number }, ctx: McpContext) {
if (!(await this.access.findAccessible(tripId, ctx.userId))) return noAccess();
return ok(tripFlightLiveResponseSchema.parse(await this.live.tripFlights(tripId)));
}
}
@@ -0,0 +1,38 @@
import { FlightLiveStates } from '../../db/entities/FlightLiveStates.entity';
import { AirportsModule } from '../airports/airports.module';
import { AppConfigModule } from '../app-config/app-config.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { PermissionsModule } from '../permissions/permissions.module';
import { SchedulingModule } from '../scheduling/scheduling.module';
import { SettingsModule } from '../settings/settings.module';
import { TripMembershipModule } from '../trip-membership/trip-membership.module';
import { FlightLivePollService } from './flight-live-poll.service';
import { FlightLiveController } from './flight-live.controller';
import { FlightLiveFeedService } from './flight-live.feeds';
import { FlightLiveJob } from './flight-live.job';
import { FlightLiveMcp } from './flight-live.mcp';
import { FlightLiveService } from './flight-live.service';
import { MikroOrmModule } from '@mikro-orm/nestjs';
import { Module } from '@nestjs/common';
/**
* Live flight status (flight-live): where a booked flight is, from the keyless
* ADS-B feeds. The REST read and its MCP twin, the minute poll on the
* scheduler, and the notices when a flight departs, lands or runs late.
* Registered in AppModule.
*/
@Module({
imports: [
MikroOrmModule.forFeature([FlightLiveStates]),
AppConfigModule,
AirportsModule,
NotificationsModule,
PermissionsModule,
SchedulingModule,
SettingsModule,
TripMembershipModule,
],
controllers: [FlightLiveController],
providers: [FlightLiveService, FlightLivePollService, FlightLiveFeedService, FlightLiveJob, FlightLiveMcp],
})
export class FlightLiveModule {}
@@ -0,0 +1,249 @@
import { wallClockInstant } from '../common/timezoneService';
import { toCallsign } from './flight-live.callsign';
import type { FlightLiveAirport } from '@trek/shared';
/**
* The legs of a booked flight with their schedule in UTC, out of what the
* planner stores: the leg chain in `metadata.legs` (client/src/utils/flightLegs.ts
* is the model, mirrored here rule for rule), the endpoints with their local
* date, time and zone, and the trip days a leg names. Pure: the caller reads
* the rows and hands in the airport lookup.
*/
/** A flight booking as the repository reads it. */
export interface FlightBookingRow {
id: number;
trip_id: number;
metadata: Record<string, unknown>;
day_id: number | null;
end_day_id: number | null;
/** Local "YYYY-MM-DDTHH:MM" of the first departure, when the form stored one. */
reservation_time: string | null;
reservation_end_time: string | null;
}
export interface FlightEndpointRow {
reservation_id: number;
role: string;
sequence: number;
name: string;
code: string | null;
lat: number;
lng: number;
timezone: string | null;
local_date: string | null;
local_time: string | null;
}
/** What the bundled airport dataset knows about a code. */
export interface AirportFacts {
name: string;
city: string;
lat: number;
lng: number;
tz: string;
}
type AirportLookup = (iata: string) => AirportFacts | null;
/** One leg that can be tracked: a callsign, both airports and the local day it departs. */
export interface ScheduledLeg {
reservation_id: number;
trip_id: number;
leg_index: number;
callsign: string;
flight_number: string;
service_date: string;
origin: FlightLiveAirport;
destination: FlightLiveAirport;
/** Epoch ms, null when the booking holds no time for that end. */
scheduled_departure: number | null;
scheduled_arrival: number | null;
}
interface RawLeg {
from: string | null;
to: string | null;
airline: string | null;
flight_number: string | null;
dep_day_id: number | null;
dep_time: string | null;
arr_day_id: number | null;
arr_time: string | null;
}
const DAY_MS = 24 * 60 * 60 * 1000;
function str(value: unknown): string | null {
return typeof value === 'string' && value.trim() !== '' ? value.trim() : null;
}
function int(value: unknown): number | null {
return typeof value === 'number' && Number.isInteger(value) ? value : null;
}
/** "10:30", "10:30:00" -> "10:30"; anything else is no time. */
function hhmm(value: string | null): string | null {
return value && /^\d{2}:\d{2}/.test(value) ? value.slice(0, 5) : null;
}
/** The date and the time of a stored "YYYY-MM-DDTHH:MM". */
function splitLocal(iso: string | null): { date: string | null; time: string | null } {
if (!iso) return { date: null, time: null };
const [date, time] = iso.split('T');
return { date: /^\d{4}-\d{2}-\d{2}$/.test(date ?? '') ? date : null, time: hhmm(time ?? null) };
}
/** "Paris Charles de Gaulle (CDG)" -> "Paris Charles de Gaulle", as the client strips it. */
function stripCode(name: string): string {
const trimmed = name.trimEnd();
return /\([A-Z]{3}\)$/.test(trimmed) ? trimmed.slice(0, -5).trimEnd() : name;
}
function ordered(endpoints: readonly FlightEndpointRow[]): FlightEndpointRow[] {
return endpoints.slice().sort((a, b) => a.sequence - b.sequence);
}
/** The legs as the client's getFlightLegs reads them: `metadata.legs`, else one leg from the endpoints. */
function rawLegs(row: FlightBookingRow, endpoints: readonly FlightEndpointRow[]): RawLeg[] {
const meta = row.metadata;
if (Array.isArray(meta.legs) && meta.legs.length > 0) {
return meta.legs.map((value): RawLeg => {
const leg = value && typeof value === 'object' ? (value as Record<string, unknown>) : {};
return {
from: str(leg.from),
to: str(leg.to),
airline: str(leg.airline),
flight_number: str(leg.flight_number),
dep_day_id: int(leg.dep_day_id),
dep_time: hhmm(str(leg.dep_time)),
arr_day_id: int(leg.arr_day_id),
arr_time: hhmm(str(leg.arr_time)),
};
});
}
const first = endpoints[0];
const last = endpoints[endpoints.length - 1];
const from = first?.code ?? str(meta.departure_airport);
const to = last?.code ?? str(meta.arrival_airport);
if (!from && !to) return [];
return [
{
from,
to,
airline: str(meta.airline),
flight_number: str(meta.flight_number),
dep_day_id: row.day_id,
dep_time: hhmm(first?.local_time ?? null),
arr_day_id: row.end_day_id ?? row.day_id,
arr_time: hhmm(last?.local_time ?? null),
},
];
}
/** An airport of the leg from its endpoint, else from the dataset; null when its place is unknown. */
function airportOf(
code: string | null,
endpoint: FlightEndpointRow | undefined,
lookup: AirportLookup,
): FlightLiveAirport | null {
const iata = code ? code.slice(0, 3).toUpperCase() : null;
if (!iata || !/^[A-Z0-9]{3}$/.test(iata)) return null;
const known = lookup(iata);
const usable = endpoint && Number.isFinite(endpoint.lat) && Number.isFinite(endpoint.lng) ? endpoint : undefined;
if (!usable && !known) return null;
return {
code: iata,
name: usable ? stripCode(usable.name) : (known?.name ?? null),
lat: usable ? usable.lat : (known as AirportFacts).lat,
lng: usable ? usable.lng : (known as AirportFacts).lng,
timezone: usable?.timezone ?? known?.tz ?? null,
};
}
/** The endpoint at the leg's end of the chain when it names that airport, else the first one that does. */
function endpointFor(
endpoints: readonly FlightEndpointRow[],
index: number,
code: string | null,
): FlightEndpointRow | undefined {
if (!code) return undefined;
const iata = code.slice(0, 3).toUpperCase();
const atIndex = endpoints[index];
if (atIndex && atIndex.code?.toUpperCase() === iata) return atIndex;
return endpoints.find((e) => e.code?.toUpperCase() === iata);
}
/** The instant a local date and time read in a zone; null when any of the three is missing. */
export function localToUtc(date: string | null, time: string | null, zone: string | null): number | null {
if (!date || !time || !zone) return null;
return wallClockInstant(date, time, zone);
}
/**
* Every leg of one flight booking that can be tracked: it has a flight number
* that maps to a callsign, both airports are known and its departure day is
* known. The schedule is null where the booking holds no time; an arrival
* without its own date falls on the departure day, or the next one when that
* would put it before the departure.
*/
export function legsOfBooking(
row: FlightBookingRow,
endpointRows: readonly FlightEndpointRow[],
dayDates: ReadonlyMap<number, string>,
lookup: AirportLookup,
airlines?: ReadonlyMap<string, string>,
): ScheduledLeg[] {
const endpoints = ordered(endpointRows);
const legs = rawLegs(row, endpoints);
const firstEnd = endpoints[0];
const lastEnd = endpoints[endpoints.length - 1];
const start = splitLocal(row.reservation_time);
const end = splitLocal(row.reservation_end_time);
const single = !(Array.isArray(row.metadata.legs) && row.metadata.legs.length > 0);
const out: ScheduledLeg[] = [];
legs.forEach((leg, index) => {
const callsign = toCallsign(leg.flight_number, leg.airline, airlines);
if (!callsign || !leg.flight_number) return;
const fromEnd = endpointFor(endpoints, index, leg.from);
const toEnd = endpointFor(endpoints, index + 1, leg.to);
const origin = airportOf(leg.from, fromEnd, lookup);
const destination = airportOf(leg.to, toEnd, lookup);
if (!origin || !destination) return;
const isFirst = index === 0;
const isLast = index === legs.length - 1;
const depDayDate = leg.dep_day_id !== null ? (dayDates.get(leg.dep_day_id) ?? null) : null;
const arrDayDate = leg.arr_day_id !== null ? (dayDates.get(leg.arr_day_id) ?? null) : null;
const firstDate = isFirst ? (firstEnd?.local_date ?? start.date) : null;
const lastDate = isLast ? (lastEnd?.local_date ?? end.date) : null;
// A single leg's endpoints carry its own local dates; a chain's legs name their days.
const depDate = (single && isFirst ? firstEnd?.local_date : null) ?? depDayDate ?? firstDate;
const depTime = leg.dep_time ?? (isFirst ? start.time : null);
if (!depDate) return;
const arrDateGiven = (single && isLast ? lastEnd?.local_date : null) ?? arrDayDate ?? lastDate;
const arrTime = leg.arr_time ?? (isLast ? end.time : null);
const departure = localToUtc(depDate, depTime, origin.timezone);
let arrival = localToUtc(arrDateGiven ?? depDate, arrTime, destination.timezone);
if (arrival !== null && departure !== null && arrival <= departure) {
arrival = arrDateGiven ? null : arrival + DAY_MS;
if (arrival !== null && arrival <= departure) arrival = null;
}
out.push({
reservation_id: row.id,
trip_id: row.trip_id,
leg_index: index,
callsign,
flight_number: leg.flight_number,
service_date: depDate,
origin,
destination,
scheduled_departure: departure,
scheduled_arrival: arrival,
});
});
return out;
}
@@ -0,0 +1,206 @@
import { FlightLiveStates } from '../../db/entities/FlightLiveStates.entity';
import { RESERVATION_METADATA } from '../../db/json-columns';
import type {
FlightBookingDbRow,
FlightLiveStateRow,
FlightLiveStatesRepository,
} from '../../db/repositories/FlightLiveStates.repository';
import { parseDbTimestamp } from '../../db/types';
import { decodeJson } from '../../utils/json-column';
import { AirportsService } from '../airports/airports.service';
import { SettingsService } from '../settings/settings.service';
import { emptyState, inWatchWindow, toLiveLeg, type LiveState } from './flight-live.derive';
import { FEED_ATTRIBUTION, FlightLiveFeedService } from './flight-live.feeds';
import { legsOfBooking, type FlightEndpointRow, type ScheduledLeg } from './flight-live.schedule';
import { InjectRepository } from '@mikro-orm/nestjs';
import { Injectable } from '@nestjs/common';
import {
flightLivePhaseSchema,
flightLiveSourceSchema,
type FlightLiveLeg,
type FlightLivePosition,
type TripFlightLiveResponse,
} from '@trek/shared';
/** A stored answer older than this is refreshed when a client asks for an in-window leg. */
const REFRESH_AFTER_MS = 2 * 60 * 1000;
/** A callsign is put up for a refresh at most once a minute, however many clients ask. */
const REFRESH_SPACING_MS = 60 * 1000;
/** The key a flight state is stored under: one per callsign, local departure day and sector. */
export function stateKey(callsign: string, serviceDate: string, origin: string, destination: string): string {
return `${callsign}|${serviceDate}|${origin}|${destination}`;
}
/** The key of the flight a leg flies. */
export function legKey(leg: Pick<ScheduledLeg, 'callsign' | 'service_date' | 'origin' | 'destination'>): string {
return stateKey(leg.callsign, leg.service_date, leg.origin.code, leg.destination.code);
}
/** States the poll will not ask about soon: a client asking again must not get around that. */
function backingOff(state: LiveState | null): boolean {
return state !== null && (state.phase === 'unknown' || state.fail_count >= 3);
}
function ms(ts: string | null): number | null {
return parseDbTimestamp(ts)?.getTime() ?? null;
}
/** A stored row as the derivation reads it; a value the column should not hold falls back. */
export function rowToState(row: FlightLiveStateRow): LiveState {
const phase = flightLivePhaseSchema.safeParse(row.phase);
const source = flightLiveSourceSchema.safeParse(row.source);
const position: FlightLivePosition | null =
row.lat !== null && row.lng !== null
? {
lat: row.lat,
lng: row.lng,
altitude_ft: row.altitude_ft,
ground_speed_kt: row.ground_speed_kt,
track_deg: row.track_deg,
vertical_rate_fpm: row.vertical_rate_fpm,
on_ground: row.on_ground === 1,
}
: null;
return {
...emptyState(row.callsign, row.service_date, row.origin, row.destination),
phase: phase.success ? phase.data : 'scheduled',
departed_at: ms(row.departed_at),
landed_at: ms(row.landed_at),
position,
registration: row.registration,
aircraft_type: row.aircraft_type,
source: source.success ? source.data : null,
first_seen_at: ms(row.first_seen_at),
last_seen_at: ms(row.last_seen_at),
fetched_at: ms(row.fetched_at),
next_poll_at: ms(row.next_poll_at),
fail_count: row.fail_count,
departed_notified: row.departed_notified === 1,
landed_notified: row.landed_notified === 1,
delay_notified: row.delay_notified === 1,
};
}
/** A flight leg with the trip it belongs to, for the poll's notices. */
export interface TripLeg extends ScheduledLeg {
trip_title: string;
}
/**
* Live flight status for the trips' flight bookings: the read the planner and
* the MCP tool share, and the leg loading the poll job builds on.
*
* The positions come from the open ADS-B feeds (FlightLiveFeedService), asked
* only by the poll job (FlightLivePollService) within a leg's watch window and
* kept in `flight_live_states`; a client read never asks a feed itself. A read
* that finds an in-window leg's answer older than two minutes puts the
* callsign up for the job's next tick instead.
*/
@Injectable()
export class FlightLiveService {
/** When each flight was last put up for a refresh, for the once-a-minute throttle. */
private readonly lastRequested = new Map<string, number>();
/** The flights waiting for the poll's next tick. */
private readonly pending = new Set<string>();
constructor(
@InjectRepository(FlightLiveStates) private readonly states: FlightLiveStatesRepository,
private readonly airports: AirportsService,
private readonly settings: SettingsService,
private readonly feeds: FlightLiveFeedService,
) {}
/** The admin switch, `flight_live_enabled`, with FLIGHT_LIVE_ENABLED over it; anything but 'false' is on. */
async isEnabled(): Promise<boolean> {
return (await this.settings.getResolvedAppSetting('flight_live_enabled')) !== 'false';
}
/**
* Every flight leg of the trip that has a callsign and two known airports,
* at any date, with its schedule and what the feeds last said about it.
* The caller has checked that the user may see the trip.
*/
async tripFlights(tripId: number, now: number = Date.now()): Promise<TripFlightLiveResponse> {
if (!(await this.isEnabled())) return { enabled: false, attribution: null, flights: [] };
const legs = await this.loadLegs(await this.states.listFlightBookingsForTrip(tripId));
const states = await this.statesFor(legs);
const flights: FlightLiveLeg[] = legs.map((leg) => {
const state = states.get(legKey(leg)) ?? null;
if (inWatchWindow(leg, now) && state?.phase !== 'landed' && !backingOff(state)) {
const fetched = state?.fetched_at ?? null;
if (fetched === null || now - fetched > REFRESH_AFTER_MS) this.requestRefresh(legKey(leg), now);
}
return toLiveLeg(leg, state, now);
});
return { enabled: true, attribution: this.attributionFor(flights), flights };
}
/** The credit line for what the answer shows: the sources its legs were read from, else the first source. */
private attributionFor(flights: readonly FlightLiveLeg[]): string {
const sources = [...new Set(flights.flatMap((f) => (f.source ? [f.source] : [])))].sort();
if (sources.length === 0) return this.feeds.attribution();
return sources.map((source) => FEED_ATTRIBUTION[source]).join('; ');
}
/** Puts a flight (its {@link legKey}) up for the poll's next tick, at most once a minute. */
requestRefresh(key: string, now: number): void {
const last = this.lastRequested.get(key);
if (last !== undefined && now - last < REFRESH_SPACING_MS) return;
this.lastRequested.set(key, now);
this.pending.add(key);
}
/** The flights clients asked to refresh since the last call, handed over once. */
takeRefreshRequests(now: number): Set<string> {
const wanted = new Set(this.pending);
this.pending.clear();
for (const [key, at] of this.lastRequested) {
if (now - at >= REFRESH_SPACING_MS) this.lastRequested.delete(key);
}
return wanted;
}
/** The stored states of the legs' flights, by {@link stateKey}. */
async statesFor(legs: readonly ScheduledLeg[]): Promise<Map<string, LiveState>> {
const rows = await this.states.findForCallsigns([...new Set(legs.map((leg) => leg.callsign))]);
const key = (row: FlightLiveStateRow) => stateKey(row.callsign, row.service_date, row.origin, row.destination);
return new Map(rows.map((row) => [key(row), rowToState(row)]));
}
/** The trackable legs of the bookings, each with its trip's title. */
async loadLegs(bookings: readonly FlightBookingDbRow[]): Promise<TripLeg[]> {
if (bookings.length === 0) return [];
const endpoints = await this.states.listEndpoints(bookings.map((b) => b.id));
const byBooking = new Map<number, FlightEndpointRow[]>();
for (const endpoint of endpoints) {
const list = byBooking.get(endpoint.reservation_id) ?? [];
list.push(endpoint);
byBooking.set(endpoint.reservation_id, list);
}
const decoded = bookings.map((row) => ({
row,
metadata: decodeJson(RESERVATION_METADATA, row.metadata, `reservation ${row.id}`),
}));
const dayIds = new Set<number>();
for (const { row, metadata } of decoded) {
if (row.day_id !== null) dayIds.add(row.day_id);
if (row.end_day_id !== null) dayIds.add(row.end_day_id);
const legs = Array.isArray(metadata.legs) ? (metadata.legs as unknown[]) : [];
for (const leg of legs) {
if (!leg || typeof leg !== 'object') continue;
const { dep_day_id: dep, arr_day_id: arr } = leg as Record<string, unknown>;
if (typeof dep === 'number') dayIds.add(dep);
if (typeof arr === 'number') dayIds.add(arr);
}
}
const dayDates = new Map((await this.states.listDayDates([...dayIds])).map((d) => [d.id, d.date]));
const lookup = (code: string) => this.airports.findByIata(code);
return decoded.flatMap(({ row, metadata }) =>
legsOfBooking({ ...row, metadata }, byBooking.get(row.id) ?? [], dayDates, lookup).map((leg) => ({
...leg,
trip_title: row.trip_title,
})),
);
}
}
@@ -30,6 +30,9 @@ export const INAPP_CHANNEL = 'inapp';
export type NotifEventType =
| 'trip_invite'
| 'booking_change'
| 'flight_departed'
| 'flight_delayed'
| 'flight_landed'
| 'trip_reminder'
| 'todo_due'
| 'vacay_invite'
@@ -49,6 +52,9 @@ export type NotifEventType =
export const ALL_EVENT_TYPES: NotifEventType[] = [
'trip_invite',
'booking_change',
'flight_departed',
'flight_delayed',
'flight_landed',
'trip_reminder',
'todo_due',
'vacay_invite',
@@ -5,7 +5,7 @@ import { NotificationChannelPreferences } from '../../db/entities/NotificationCh
import type { AppSettingsRepository } from '../../db/repositories/AppSettings.repository';
import type { NotificationChannelPreferencesRepository } from '../../db/repositories/NotificationChannelPreferences.repository';
import { AddonsService } from '../addons/addons.service';
import { readAppSetting } from '../common/app-settings.registry';
import { readAppSetting, resolveAppSetting } from '../common/app-settings.registry';
import { UnitOfWork } from '../database/unit-of-work';
import { listChannels } from './channel-registry';
import { MailerService } from './mailer/mailer.service';
@@ -131,6 +131,10 @@ export class NotificationPreferencesService {
return photoProviders.includes('synologyphotos');
case 'wanderer_source_hint':
return this.addons.isWandererAvailable();
case 'flight_departed':
case 'flight_delayed':
case 'flight_landed':
return (await resolveAppSetting(this.appSettings, 'flight_live_enabled')) !== 'false';
default:
return true;
}
@@ -173,6 +173,28 @@ const EVENT_NOTIFICATION_CONFIG: Record<string, EventNotifConfig> = {
navigateTextKey: 'notif.action.view_trip',
navigateTarget: (p) => (p.tripId ? `/trips/${p.tripId}` : null),
},
// Live flight status (flight-live): the poll saw the flight leave, land or run late.
flight_departed: {
inAppType: 'navigate',
titleKey: 'notif.flight_departed.title',
textKey: 'notif.flight_departed.text',
navigateTextKey: 'notif.action.view_trip',
navigateTarget: (p) => (p.tripId ? `/trips/${p.tripId}` : null),
},
flight_delayed: {
inAppType: 'navigate',
titleKey: 'notif.flight_delayed.title',
textKey: 'notif.flight_delayed.text',
navigateTextKey: 'notif.action.view_trip',
navigateTarget: (p) => (p.tripId ? `/trips/${p.tripId}` : null),
},
flight_landed: {
inAppType: 'navigate',
titleKey: 'notif.flight_landed.title',
textKey: 'notif.flight_landed.text',
navigateTextKey: 'notif.action.view_trip',
navigateTarget: (p) => (p.tripId ? `/trips/${p.tripId}` : null),
},
trip_reminder: {
inAppType: 'navigate',
titleKey: 'notif.trip_reminder.title',
@@ -23,6 +23,9 @@ export interface NtfyConfig {
const NTFY_EVENT_META: Partial<Record<NotifEventType, { priority: 1 | 2 | 3 | 4 | 5; tags: string[] }>> = {
trip_invite: { priority: 4, tags: ['loudspeaker'] },
booking_change: { priority: 3, tags: ['calendar'] },
flight_departed: { priority: 3, tags: ['airplane'] },
flight_delayed: { priority: 4, tags: ['airplane', 'hourglass'] },
flight_landed: { priority: 3, tags: ['airplane'] },
trip_reminder: { priority: 4, tags: ['bell', 'alarm_clock'] },
vacay_invite: { priority: 4, tags: ['palm_tree'] },
todo_due: { priority: 4, tags: ['ballot_box_with_check'] },
+6 -1
View File
@@ -3,7 +3,7 @@ import { AppSettings } from '../../db/entities/AppSettings.entity';
import { Settings } from '../../db/entities/Settings.entity';
import type { AppSettingsRepository } from '../../db/repositories/AppSettings.repository';
import type { SettingsRepository } from '../../db/repositories/Settings.repository';
import { readAppSetting, type AppSettingKey } from '../common/app-settings.registry';
import { readAppSetting, resolveAppSetting, type AppSettingKey } from '../common/app-settings.registry';
import { decrypt_api_key, maybe_encrypt_api_key } from '../common/crypto/apiKeyCrypto';
import { UnitOfWork } from '../database/unit-of-work';
import { TripMembershipService } from '../trip-membership/trip-membership.service';
@@ -401,6 +401,11 @@ export class SettingsService {
return await readAppSetting(this.appSettings, key);
}
/** The environment value of a registered instance setting when one is set, else its stored text, else null. */
async getResolvedAppSetting(key: AppSettingKey): Promise<string | null> {
return await resolveAppSetting(this.appSettings, key);
}
/** Writes a registered instance setting only while it has no row; the first of several processes keeps it. */
async insertAppSettingIfAbsent(key: AppSettingKey, value: string): Promise<void> {
await this.appSettings.insertIfAbsent(key, value);
+154
View File
@@ -0,0 +1,154 @@
/**
* Live flight status e2e: GET /api/trips/:tripId/flights/live through the
* real JwtAuthGuard and TripAccessGuard over a migrated snapshot db. The
* read never asks a feed, so fetch is a spy that must stay unused; the
* response contract is checked on every 200 (NODE_ENV=test).
*/
import { AppSettings } from '../../src/db/entities/AppSettings.entity';
import { AppConfigModule } from '../../src/nest/app-config/app-config.module';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
import { FlightLiveModule } from '../../src/nest/flight-live/flight-live.module';
import { RealtimeModule } from '../../src/nest/realtime/realtime.module';
import { createSnapshotTestDb } from '../helpers/db-mock';
import { makeReservation, makeReservationEndpoint } from '../helpers/factories/reservations';
import { deleteRows, insertRow } from '../helpers/factories/rows';
import { makeTrip } from '../helpers/factories/trips';
import { makeUser } from '../helpers/factories/users';
import { createTestMikroOrmModule } from '../helpers/test-orm';
import { TestUnitOfWorkModule } from '../helpers/test-uow';
import { sessionCookie } from './harness';
import { MikroORM } from '@mikro-orm/core';
import { Test } from '@nestjs/testing';
import { tripFlightLiveResponseSchema } from '@trek/shared';
import cookieParser from 'cookie-parser';
import type { Server } from 'http';
import request from 'supertest';
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
const db = createSnapshotTestDb();
describe('Flight live e2e (real guards + snapshot SQLite)', () => {
let server: Server;
let app: Awaited<ReturnType<typeof build>>;
let ownerId: number;
let strangerId: number;
let tripId: number;
let flightId: number;
async function build() {
const moduleRef = await Test.createTestingModule({
imports: [
await TestUnitOfWorkModule.forRoot(db),
await createTestMikroOrmModule(db),
AppConfigModule,
RealtimeModule,
FlightLiveModule,
],
}).compile();
const nest = moduleRef.createNestApplication();
nest.use(cookieParser());
nest.useGlobalFilters(new TrekExceptionFilter());
await nest.init();
return nest;
}
beforeAll(async () => {
app = await build();
server = app.getHttpServer();
const orm = app.get(MikroORM);
const { user: owner } = await makeUser(orm);
const { user: stranger } = await makeUser(orm);
ownerId = owner.id;
strangerId = stranger.id;
tripId = (await makeTrip(orm, owner.id, { title: 'Japan' })).id;
const flight = await makeReservation(orm, tripId, {
title: 'FRA to HND',
type: 'flight',
metadata: JSON.stringify({ flight_number: 'LH 716', airline: 'Lufthansa' }),
});
flightId = flight.id;
await makeReservationEndpoint(orm, flight.id, {
role: 'from',
sequence: 0,
name: 'Frankfurt (FRA)',
code: 'FRA',
lat: 50.0333,
lng: 8.5706,
timezone: 'Europe/Berlin',
local_date: '2030-07-01',
local_time: '13:40',
});
await makeReservationEndpoint(orm, flight.id, {
role: 'to',
sequence: 1,
name: 'Tokyo Haneda (HND)',
code: 'HND',
lat: 35.5523,
lng: 139.7798,
timezone: 'Asia/Tokyo',
local_date: '2030-07-02',
local_time: '08:15',
});
});
afterEach(() => {
vi.unstubAllGlobals();
});
afterAll(async () => {
await app.close();
db.close();
});
const url = (id: number = tripId) => `/api/trips/${id}/flights/live`;
it('FLE2E-001: 401 without a session cookie', async () => {
const res = await request(server).get(url());
expect(res.status).toBe(401);
});
it('FLE2E-002: 404 "Trip not found" for a trip the caller cannot reach', async () => {
const res = await request(server).get(url()).set('Cookie', sessionCookie(strangerId));
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'Trip not found' });
});
it('FLE2E-003: 200 with every flight leg and its schedule in UTC, without asking a feed', async () => {
const fetcher = vi.fn();
vi.stubGlobal('fetch', fetcher);
const res = await request(server).get(url()).set('Cookie', sessionCookie(ownerId));
expect(res.status).toBe(200);
expect(tripFlightLiveResponseSchema.parse(res.body)).toEqual(res.body);
expect(res.body).toMatchObject({ enabled: true, attribution: 'Live positions: adsb.lol (ODbL)' });
expect(res.body.flights).toEqual([
expect.objectContaining({
reservation_id: flightId,
leg_index: 0,
callsign: 'DLH716',
flight_number: 'LH 716',
service_date: '2030-07-01',
origin: { code: 'FRA', name: 'Frankfurt', lat: 50.0333, lng: 8.5706, timezone: 'Europe/Berlin' },
destination: { code: 'HND', name: 'Tokyo Haneda', lat: 35.5523, lng: 139.7798, timezone: 'Asia/Tokyo' },
phase: 'scheduled',
scheduled_departure: '2030-07-01T11:40:00.000Z',
scheduled_arrival: '2030-07-01T23:15:00.000Z',
position: null,
source: null,
}),
]);
expect(fetcher).not.toHaveBeenCalled();
});
it('FLE2E-004: switched off by the admin it answers enabled false and no flights', async () => {
const orm = app.get(MikroORM);
await insertRow(orm, AppSettings, { key: 'flight_live_enabled', value: 'false' });
try {
const res = await request(server).get(url()).set('Cookie', sessionCookie(ownerId));
expect(res.status).toBe(200);
expect(res.body).toEqual({ enabled: false, attribution: null, flights: [] });
} finally {
await deleteRows(orm, AppSettings, { key: 'flight_live_enabled' });
}
});
});
@@ -234,6 +234,13 @@ describe('derivePlugins', () => {
});
describe('deriveIntegrations', () => {
it('reads FLIGHT_LIVE_ENABLED as the setting spells it, unset or unparseable as no word at all', () => {
expect(deriveIntegrations({}).flightLiveEnabled).toBeUndefined();
expect(deriveIntegrations({ FLIGHT_LIVE_ENABLED: 'off' }).flightLiveEnabled).toBe('false');
expect(deriveIntegrations({ FLIGHT_LIVE_ENABLED: ' YES ' }).flightLiveEnabled).toBe('true');
expect(deriveIntegrations({ FLIGHT_LIVE_ENABLED: 'maybe' }).flightLiveEnabled).toBeUndefined();
});
it('pins unsplash trim, nominatim trim + default, overpass timeout', () => {
expect(deriveIntegrations({ UNSPLASH_ACCESS_KEY: ' key ' }).unsplashAccessKey).toBe('key');
expect(deriveIntegrations({}).nominatimUrl).toBe('https://nominatim.openstreetmap.org');
+2 -2
View File
@@ -63,8 +63,8 @@ describe('ALL_ENTITIES', () => {
* `entity-schema-parity.test.ts` can't see — it iterates the metadata map,
* which de-duplicates by class name).
*/
it('ENT-003: exactly 167 entities, every class name unique', () => {
expect(ALL_ENTITIES.length).toBe(167);
it('ENT-003: exactly 168 entities, every class name unique', () => {
expect(ALL_ENTITIES.length).toBe(168);
const names = ALL_ENTITIES.map((s) => s.meta.className);
expect(new Set(names).size).toBe(names.length);
});
@@ -61,7 +61,9 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
// (an attached file is only ever written whole, by the turn it was attached in).
// + storage_reclaim_marks: category, key, first_seen_at, size (a mark is only ever written whole, by the reclaim pass).
// + storage_release_queue: category, key, due_at (a queued release is only ever written whole, by the release queue).
const KNOWN_UNINITIALISED_NOT_NULL_SCALARS = 611;
// + flight_live_states: callsign, service_date, origin, destination (a flight's state is always found and
// written by its key).
const KNOWN_UNINITIALISED_NOT_NULL_SCALARS = 615;
describe('entity class fields never carry a NaN initialiser (task-4-review-shape.md, Important 1)', () => {
it('CLASSFIELD-001: new X() has no own property whose value is NaN, across all 133 entities', () => {
@@ -107,6 +107,7 @@ const UNNUMBERED = [
'Migration20200101046370_a_deleted_chat_message_keeps_no_text',
'Migration20200101046380_ireland_gained_its_counties',
'Migration20200101046390_a_trip_can_keep_its_costs_as_totals_only',
'Migration20200101046400_a_booked_flight_shows_where_it_is',
];
// The last step the positional runner ever had; planLegacyBaseline refuses a
// schema_version past it.
@@ -0,0 +1,207 @@
/**
* FlightLiveStatesRepository: the stored state of a flight per callsign and
* day, and the reads of flight bookings, endpoints, days and travellers the
* live flight status builds its legs from. FLREPO-001 to FLREPO-007.
*/
import { FlightLiveStates } from '../../../../src/db/entities/FlightLiveStates.entity';
import type {
FlightLiveStatesRepository,
FlightLiveStateWrite,
} from '../../../../src/db/repositories/FlightLiveStates.repository';
import type { DbTimestamp } from '../../../../src/db/types';
import { createSnapshotTestDb } from '../../../helpers/db-mock';
import {
addReservationTraveler,
makeReservation,
makeReservationEndpoint,
} from '../../../helpers/factories/reservations';
import { findRows } from '../../../helpers/factories/rows';
import { addTripMember, makeDay, makeTrip } from '../../../helpers/factories/trips';
import { makeUser } from '../../../helpers/factories/users';
import { resetTestDb } from '../../../helpers/test-db';
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
const testDb = createSnapshotTestDb();
let t: TestOrm;
let repo: FlightLiveStatesRepository;
beforeAll(async () => {
t = await createTestOrm(testDb);
repo = t.repo(FlightLiveStates);
});
beforeEach(() => {
resetTestDb(testDb);
t.clear();
});
afterAll(async () => {
await t.close();
testDb.close();
});
function state(overrides: Partial<FlightLiveStateWrite> = {}): FlightLiveStateWrite {
return {
callsign: 'DLH716',
service_date: '2026-07-01',
origin: 'FRA',
destination: 'HND',
phase: 'airborne',
departed_at: '2026-07-01 11:52:00' as DbTimestamp,
landed_at: null,
lat: 55.1,
lng: 60.2,
altitude_ft: 36000,
ground_speed_kt: 480,
track_deg: 51,
vertical_rate_fpm: 0,
on_ground: 0,
registration: 'D-AIXA',
aircraft_type: 'A359',
source: 'adsb.lol',
first_seen_at: '2026-07-01 11:40:00' as DbTimestamp,
last_seen_at: '2026-07-01 14:00:00' as DbTimestamp,
fetched_at: '2026-07-01 14:00:01' as DbTimestamp,
next_poll_at: '2026-07-01 14:03:01' as DbTimestamp,
fail_count: 0,
departed_notified: 1,
landed_notified: 0,
delay_notified: 0,
...overrides,
};
}
describe('FlightLiveStatesRepository: states', () => {
it('FLREPO-001: writes a whole state and replaces it under the same key', async () => {
await repo.saveState(state());
await repo.saveState(state({ phase: 'landed', landed_at: '2026-07-01 23:10:00' as DbTimestamp, lat: 35.5 }));
await repo.saveState(state({ service_date: '2026-07-02', phase: 'scheduled' }));
await repo.saveState(state({ callsign: 'BAW12' }));
const rows = await repo.findForCallsigns(['DLH716']);
expect(rows.map((r) => [r.service_date, r.phase, r.landed_at, r.lat]).sort()).toEqual([
['2026-07-01', 'landed', '2026-07-01 23:10:00', 35.5],
['2026-07-02', 'scheduled', null, 55.1],
]);
expect(rows.find((r) => r.service_date === '2026-07-01')).toMatchObject({
registration: 'D-AIXA',
departed_notified: 1,
next_poll_at: '2026-07-01 14:03:01',
});
expect(await repo.findForCallsigns([])).toEqual([]);
});
it('FLREPO-001b: two sectors of one flight number on one day are two states', async () => {
await repo.saveState(state({ callsign: 'DLH754', origin: 'FRA', destination: 'BOM' }));
await repo.saveState(state({ callsign: 'DLH754', origin: 'BOM', destination: 'SIN', phase: 'scheduled' }));
const rows = await repo.findForCallsigns(['DLH754']);
expect(rows.map((r) => [r.origin, r.destination, r.phase]).sort()).toEqual([
['BOM', 'SIN', 'scheduled'],
['FRA', 'BOM', 'airborne'],
]);
});
it('FLREPO-002: drops the states of flights that departed before a day', async () => {
await repo.saveState(state({ service_date: '2026-05-30' }));
await repo.saveState(state({ service_date: '2026-06-01' }));
expect(await repo.deleteBefore('2026-06-01')).toBe(1);
expect((await findRows(t, FlightLiveStates)).map((r) => r.service_date)).toEqual(['2026-06-01']);
});
});
describe('FlightLiveStatesRepository: bookings', () => {
it('FLREPO-003: the flight bookings of one trip with the trip title, nothing of other types or trips', async () => {
const { user } = await makeUser(t);
const trip = await makeTrip(t, user.id, { title: 'Japan' });
const other = await makeTrip(t, user.id);
const flight = await makeReservation(t, trip.id, {
title: 'FRA to HND',
type: 'flight',
metadata: '{"flight_number":"LH 716"}',
reservation_time: '2026-07-01T13:40',
});
await makeReservation(t, trip.id, { title: 'Hotel', type: 'hotel' });
await makeReservation(t, other.id, { title: 'Elsewhere', type: 'flight' });
expect(await repo.listFlightBookingsForTrip(trip.id)).toEqual([
{
id: flight.id,
trip_id: trip.id,
trip_title: 'Japan',
metadata: '{"flight_number":"LH 716"}',
day_id: null,
end_day_id: null,
reservation_time: '2026-07-01T13:40',
reservation_end_time: null,
},
]);
});
it('FLREPO-004: the poll candidates skip archived trips and trips whose dates lie outside the span', async () => {
const { user } = await makeUser(t);
const current = await makeTrip(t, user.id, { start_date: '2026-06-28', end_date: '2026-07-05' });
const undated = await makeTrip(t, user.id);
const past = await makeTrip(t, user.id, { start_date: '2026-05-01', end_date: '2026-05-03' });
const future = await makeTrip(t, user.id, { start_date: '2026-09-01', end_date: '2026-09-03' });
const archived = await makeTrip(t, user.id, { start_date: '2026-06-28', end_date: '2026-07-05', is_archived: 1 });
await [current, undated, past, future, archived].reduce(async (previous, trip) => {
await previous;
await makeReservation(t, trip.id, { title: 'Flight', type: 'flight' });
}, Promise.resolve());
const rows = await repo.listFlightBookingsBetween('2026-06-29', '2026-07-03');
expect(rows.map((r) => r.trip_id).sort((a, b) => a - b)).toEqual([current.id, undated.id].sort((a, b) => a - b));
});
it('FLREPO-005: the endpoints of the bookings in their order, and none for no bookings', async () => {
const { user } = await makeUser(t);
const trip = await makeTrip(t, user.id);
const flight = await makeReservation(t, trip.id, { title: 'Flight', type: 'flight' });
await makeReservationEndpoint(t, flight.id, { role: 'to', sequence: 1, code: 'HND', name: 'Haneda (HND)' });
await makeReservationEndpoint(t, flight.id, {
role: 'from',
sequence: 0,
code: 'FRA',
name: 'Frankfurt (FRA)',
timezone: 'Europe/Berlin',
local_date: '2026-07-01',
local_time: '13:40',
});
const endpoints = await repo.listEndpoints([flight.id]);
expect(endpoints.map((e) => [e.sequence, e.code])).toEqual([
[0, 'FRA'],
[1, 'HND'],
]);
expect(endpoints[0]).toMatchObject({
reservation_id: flight.id,
role: 'from',
timezone: 'Europe/Berlin',
local_date: '2026-07-01',
local_time: '13:40',
});
expect(await repo.listEndpoints([])).toEqual([]);
});
it('FLREPO-006: the dates of trip days, leaving out a day without one', async () => {
const { user } = await makeUser(t);
const trip = await makeTrip(t, user.id);
const dated = await makeDay(t, trip.id, { day_number: 1, date: '2026-07-01' });
const undated = await makeDay(t, trip.id, { day_number: 2, date: null });
expect(await repo.listDayDates([dated.id, undated.id])).toEqual([{ id: dated.id, date: '2026-07-01' }]);
expect(await repo.listDayDates([])).toEqual([]);
});
it('FLREPO-007: the travellers put on a booking who still belong to the trip, never a removed member', async () => {
const { user } = await makeUser(t);
const { user: friend } = await makeUser(t);
const { user: removed } = await makeUser(t);
const trip = await makeTrip(t, user.id);
await addTripMember(t, trip.id, friend.id);
const flight = await makeReservation(t, trip.id, { title: 'Flight', type: 'flight' });
expect(await repo.listTravelerIds(flight.id)).toEqual([]);
await addReservationTraveler(t, flight.id, friend.id);
await addReservationTraveler(t, flight.id, user.id);
// On the traveller list from before, but no longer on the trip.
await addReservationTraveler(t, flight.id, removed.id);
expect(await repo.listTravelerIds(flight.id)).toEqual([user.id, friend.id].sort((a, b) => a - b));
});
});
@@ -50,6 +50,7 @@ describe('the derived key lists', () => {
'webauthn_rp_id',
'webauthn_origins',
'places_provider',
'flight_live_enabled',
]);
});
@@ -133,6 +134,7 @@ describe('reading', () => {
oidc_client_secret: ['OIDC_CLIENT_SECRET', 'oidc-env-secret'],
oidc_display_name: ['OIDC_DISPLAY_NAME', 'Company SSO'],
oidc_discovery_url: ['OIDC_DISCOVERY_URL', 'https://idp.env.example/.well-known/openid-configuration'],
flight_live_enabled: ['FLIGHT_LIVE_ENABLED', 'false'],
};
const withEnv = (Object.entries(APP_SETTINGS) as Array<[string, AppSettingDef]>)
.filter(([, def]) => def.env)
@@ -1063,6 +1063,23 @@ describe('deleteAccount', () => {
});
describe('updateAppSettings', () => {
it('AUTH-DB-070b: FLIGHT_LIVE_ENABLED holds the switch: the form shows it as env_override_*, a save skips it', async () => {
const { user } = createAdmin(testDb);
vi.stubEnv('FLIGHT_LIVE_ENABLED', 'false');
try {
await appSetting('flight_live_enabled', 'true');
const read = await asLegacyResult(svc.getAppSettings(user.id));
expect(read.data).toMatchObject({ flight_live_enabled: 'false', env_override_flight_live_enabled: 'false' });
await deleteRows(orm, AppSettings, { key: 'flight_live_enabled' });
const saved = await asLegacyResult(svc.updateAppSettings(user.id, { flight_live_enabled: 'true' }));
expect(saved).toMatchObject({ success: true, managedKeys: ['flight_live_enabled'] });
expect(await findRow(orm, AppSettings, { key: 'flight_live_enabled' })).toBeNull();
} finally {
vi.unstubAllEnvs();
}
});
it('AUTH-DB-071: 403 for non-admin', async () => {
const { user } = createUser(testDb);
expect((await asLegacyResult(svc.updateAppSettings(user.id, {}))).status).toBe(403);
@@ -0,0 +1,383 @@
/**
* FlightLivePollService: the minute tick behind live flight status. The poll
* comes out of FlightLiveModule over the migrated snapshot; the feed and the
* notifications are stubs, broadcasts land on the FakeRealtimeService.
*/
import { AppSettings } from '../../../../src/db/entities/AppSettings.entity';
import { FlightLiveStates } from '../../../../src/db/entities/FlightLiveStates.entity';
import { FlightLivePollService } from '../../../../src/nest/flight-live/flight-live-poll.service';
import type { FeedAircraft } from '../../../../src/nest/flight-live/flight-live.derive';
import { FlightLiveFeedService, type FeedLookup } from '../../../../src/nest/flight-live/flight-live.feeds';
import { FlightLiveJob } from '../../../../src/nest/flight-live/flight-live.job';
import { FlightLiveModule } from '../../../../src/nest/flight-live/flight-live.module';
import { FlightLiveService } from '../../../../src/nest/flight-live/flight-live.service';
import { NotificationsService } from '../../../../src/nest/notifications/notifications.service';
import { CronRegistrarService } from '../../../../src/nest/scheduling/cron-registrar.service';
import { createSnapshotTestDb } from '../../../helpers/db-mock';
import {
addReservationTraveler,
makeReservation,
makeReservationEndpoint,
} from '../../../helpers/factories/reservations';
import { findRow, findRows, insertRow } from '../../../helpers/factories/rows';
import { makeDay, makeTrip } from '../../../helpers/factories/trips';
import { makeUser } from '../../../helpers/factories/users';
import { resetTestDb } from '../../../helpers/test-db';
import { createTestModule, type TestModule } from '../../../helpers/test-module';
import { MikroORM } from '@mikro-orm/core';
import { flightLiveLegSchema } from '@trek/shared';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
const db = createSnapshotTestDb();
let t: TestModule;
let poll: FlightLivePollService;
let orm: MikroORM;
const feed = {
lookup: vi.fn<(callsign: string, clock?: () => number) => Promise<FeedLookup>>(),
attribution: vi.fn(() => 'Live positions: adsb.lol (ODbL)'),
sources: vi.fn(() => ['adsb.lol']),
canAsk: vi.fn(() => true),
waitMs: vi.fn(() => 0),
};
const notifications = { send: vi.fn(async () => ({ attempted: 1, delivered: 1 })) };
const MIN = 60_000;
/** LH 100 leaves Frankfurt at 12:00 local (10:00 UTC) on 1 July 2026 and lands in Berlin at 13:10. */
const DEP = Date.parse('2026-07-01T10:00:00Z');
const ARR = Date.parse('2026-07-01T11:10:00Z');
beforeAll(async () => {
t = await createTestModule({
db,
imports: [FlightLiveModule],
overrides: [
{ provide: FlightLiveFeedService, useValue: feed },
{ provide: NotificationsService, useValue: notifications },
],
});
poll = t.get(FlightLivePollService);
poll.sleep = async () => undefined;
orm = t.get(MikroORM);
});
beforeEach(() => {
resetTestDb(db);
vi.clearAllMocks();
t.realtime.broadcastMock.mockClear();
});
afterAll(async () => {
await t.close();
db.close();
});
async function seedFlight(title = 'Berlin') {
const { user } = await makeUser(orm);
const trip = await makeTrip(orm, user.id, { title });
const flight = await makeReservation(orm, trip.id, {
title: 'FRA to BER',
type: 'flight',
metadata: JSON.stringify({ flight_number: 'LH 100' }),
});
const end = { lat: 52.3667, lng: 13.5033, timezone: 'Europe/Berlin', local_date: '2026-07-01' };
await makeReservationEndpoint(orm, flight.id, {
role: 'from',
sequence: 0,
name: 'Frankfurt (FRA)',
code: 'FRA',
lat: 50.0333,
lng: 8.5706,
timezone: 'Europe/Berlin',
local_date: '2026-07-01',
local_time: '12:00',
});
await makeReservationEndpoint(orm, flight.id, {
role: 'to',
sequence: 1,
name: 'Berlin (BER)',
code: 'BER',
local_time: '13:10',
...end,
});
return { user, trip, flight };
}
function aircraft(overrides: Partial<FeedAircraft> = {}): FeedAircraft {
return {
hex: '3c6444',
flight: 'DLH100',
registration: 'D-AINA',
type: 'A20N',
alt_baro: 4000,
alt_geom: null,
gs: 220,
track: 60,
baro_rate: 2000,
geom_rate: null,
lat: 50.1,
lon: 8.7,
seen: 0,
seen_pos: 0,
...overrides,
};
}
const seen = (...list: FeedAircraft[]): FeedLookup => ({ ok: true, source: 'adsb.lol', now: 0, aircraft: list });
const at = (ms: number) => () => ms;
const events = () => notifications.send.mock.calls.map((call) => (call as unknown as [{ event: string }])[0].event);
describe('FlightLivePollService.poll', () => {
it('FLPOLL-001: switched off, it asks nothing', async () => {
await seedFlight();
await insertRow(orm, AppSettings, { key: 'flight_live_enabled', value: 'false' });
expect(await poll.poll(at(DEP))).toEqual({ asked: 0, changed: 0, skipped: 'disabled' });
expect(feed.lookup).not.toHaveBeenCalled();
});
it('FLPOLL-002: outside its window a flight is not asked about', async () => {
await seedFlight();
expect(await poll.poll(at(DEP - 4 * 60 * MIN))).toEqual({ asked: 0, changed: 0, skipped: null });
expect(feed.lookup).not.toHaveBeenCalled();
});
it('FLPOLL-003: wheels-up is stored, sent to the trip and told to the whole trip without travellers', async () => {
const { trip, flight } = await seedFlight();
feed.lookup.mockResolvedValueOnce(seen(aircraft()));
const now = DEP + 5 * MIN;
expect(await poll.poll(at(now))).toEqual({ asked: 1, changed: 1, skipped: null });
expect(feed.lookup).toHaveBeenCalledWith('DLH100', expect.any(Function));
const row = await findRow(orm, FlightLiveStates, { callsign: 'DLH100', service_date: '2026-07-01' });
expect(row).toMatchObject({
phase: 'airborne',
departed_at: '2026-07-01 10:05:00',
departed_notified: 1,
delay_notified: 0,
source: 'adsb.lol',
next_poll_at: '2026-07-01 10:08:00',
});
expect(t.realtime.broadcastMock).toHaveBeenCalledTimes(1);
const [tripId, event, payload] = t.realtime.broadcastMock.mock.calls[0];
expect([tripId, event]).toEqual([trip.id, 'flight:live']);
const legs = (payload as { flights: unknown[] }).flights;
expect(legs).toHaveLength(1);
expect(flightLiveLegSchema.parse(legs[0])).toMatchObject({ reservation_id: flight.id, phase: 'airborne' });
expect(notifications.send).toHaveBeenCalledTimes(1);
expect(notifications.send).toHaveBeenCalledWith({
event: 'flight_departed',
actorId: null,
scope: 'trip',
targetId: trip.id,
params: { flight: 'LH 100', from: 'FRA', to: 'BER', trip: 'Berlin', tripId: String(trip.id), delay: '5' },
});
});
it('FLPOLL-004: a late departure is told once, to the travellers of each booking, next to wheels-up', async () => {
const { user, flight } = await seedFlight();
const second = await seedFlight('Berlin too');
await addReservationTraveler(orm, flight.id, user.id);
feed.lookup.mockResolvedValueOnce(seen(aircraft()));
await poll.poll(at(DEP + 25 * MIN));
expect(events().sort()).toEqual(['flight_delayed', 'flight_delayed', 'flight_departed', 'flight_departed']);
expect(notifications.send).toHaveBeenCalledWith(
expect.objectContaining({ event: 'flight_delayed', scope: 'user', targetId: user.id }),
);
expect(notifications.send).toHaveBeenCalledWith(
expect.objectContaining({ event: 'flight_delayed', scope: 'trip', targetId: second.trip.id }),
);
expect(t.realtime.broadcastMock).toHaveBeenCalledTimes(2);
notifications.send.mockClear();
feed.lookup.mockResolvedValueOnce(seen(aircraft({ alt_baro: 30000, lat: 51.2, lon: 11, gs: 450 })));
expect(await poll.poll(at(DEP + 30 * MIN))).toMatchObject({ asked: 1 });
expect(notifications.send).not.toHaveBeenCalled();
});
it('FLPOLL-005: landed is told once and the flight is asked about no more', async () => {
const { trip } = await seedFlight();
feed.lookup.mockResolvedValueOnce(seen(aircraft()));
await poll.poll(at(DEP + 2 * MIN));
feed.lookup.mockResolvedValueOnce(seen(aircraft({ alt_baro: 'ground', lat: 52.37, lon: 13.5, gs: 15 })));
await poll.poll(at(ARR));
expect(events()).toEqual(['flight_departed', 'flight_landed']);
expect(await findRow(orm, FlightLiveStates, { callsign: 'DLH100' })).toMatchObject({
phase: 'landed',
landed_at: '2026-07-01 11:10:00',
landed_notified: 1,
next_poll_at: null,
});
feed.lookup.mockClear();
expect(await poll.poll(at(ARR + 10 * MIN))).toMatchObject({ asked: 0 });
expect(feed.lookup).not.toHaveBeenCalled();
expect(t.realtime.broadcastMock.mock.calls.every((call) => call[0] === trip.id)).toBe(true);
});
it('FLPOLL-006: first seen on the ground at the destination is a landing without a departure notice', async () => {
await seedFlight();
feed.lookup.mockResolvedValueOnce(seen(aircraft({ alt_baro: 'ground', lat: 52.37, lon: 13.5 })));
await poll.poll(at(ARR));
expect(events()).toEqual(['flight_landed']);
expect(await findRow(orm, FlightLiveStates, { callsign: 'DLH100' })).toMatchObject({
phase: 'landed',
departed_notified: 1,
landed_notified: 1,
delay_notified: 0,
});
});
it('FLPOLL-007: a failed ask counts and backs off, nothing is broadcast', async () => {
await seedFlight();
feed.lookup.mockResolvedValue({ ok: false, reason: 'unavailable' });
await poll.poll(at(DEP - 30 * MIN));
await poll.poll(at(DEP - 28 * MIN));
await poll.poll(at(DEP - 26 * MIN));
expect(await findRow(orm, FlightLiveStates, { callsign: 'DLH100' })).toMatchObject({
phase: 'scheduled',
fail_count: 3,
next_poll_at: '2026-07-01 09:49:00',
});
expect(t.realtime.broadcastMock).not.toHaveBeenCalled();
feed.lookup.mockReset();
});
it('FLPOLL-008: an empty budget ends the tick before anything is stored', async () => {
await seedFlight();
feed.lookup.mockResolvedValueOnce({ ok: false, reason: 'budget' });
expect(await poll.poll(at(DEP))).toEqual({ asked: 0, changed: 0, skipped: null });
expect(await findRows(orm, FlightLiveStates)).toEqual([]);
feed.canAsk.mockReturnValueOnce(false);
expect(await poll.poll(at(DEP))).toEqual({ asked: 0, changed: 0, skipped: null });
expect(feed.lookup).toHaveBeenCalledTimes(1);
});
it('FLPOLL-009: a flight not due waits, unless a client asked for it; the feed spacing is waited out', async () => {
const { trip } = await seedFlight();
feed.lookup.mockResolvedValue(seen(aircraft({ alt_baro: 'ground', lat: 50.035, lon: 8.56, gs: 5 })));
await poll.poll(at(DEP - 60 * MIN));
expect(feed.lookup).toHaveBeenCalledTimes(1);
await poll.poll(at(DEP - 58 * MIN));
expect(feed.lookup).toHaveBeenCalledTimes(1);
// Asked for seven minutes before its next poll: the cadence holds.
const live = t.get(FlightLiveService);
await live.tripFlights(trip.id, DEP - 57 * MIN);
await poll.poll(at(DEP - 57 * MIN));
expect(feed.lookup).toHaveBeenCalledTimes(1);
// Asked within two minutes of it: brought forward.
await live.tripFlights(trip.id, DEP - 51.5 * MIN);
const sleep = vi.fn(async () => undefined);
poll.sleep = sleep;
feed.waitMs.mockReturnValueOnce(400);
await poll.poll(at(DEP - 51.5 * MIN));
expect(feed.lookup).toHaveBeenCalledTimes(2);
expect(sleep).toHaveBeenCalledWith(400);
poll.sleep = async () => undefined;
feed.lookup.mockReset();
});
it('FLPOLL-010: a tick while one runs returns at once, and old states go once a day', async () => {
const old = { callsign: 'BAW1', service_date: '2026-01-01', origin: 'LHR', destination: 'JFK' };
await insertRow(orm, FlightLiveStates, old);
// An earlier case already purged today; the guard is per day, so forget it.
(poll as unknown as { lastPurgeDay: string | null }).lastPurgeDay = null;
let release: () => void = () => undefined;
feed.lookup.mockImplementationOnce(
() =>
new Promise((resolve) => {
release = () => resolve({ ok: false, reason: 'budget' });
}),
);
await seedFlight();
const first = poll.poll(at(DEP + 10 * MIN));
await vi.waitFor(() => expect(feed.lookup).toHaveBeenCalled());
expect(await poll.poll(at(DEP + 10 * MIN))).toEqual({ asked: 0, changed: 0, skipped: 'running' });
release();
await first;
expect(await findRows(orm, FlightLiveStates, { callsign: 'BAW1' })).toEqual([]);
});
});
describe('FlightLivePollService: closing and sectors', () => {
it('FLPOLL-011: a flight still in the air when its window ends is closed as unknown and asked about no more', async () => {
const { trip } = await seedFlight();
feed.lookup.mockResolvedValueOnce(seen(aircraft({ alt_baro: 30000, lat: 51.2, lon: 11, gs: 450 })));
await poll.poll(at(DEP + 20 * MIN));
t.realtime.broadcastMock.mockClear();
feed.lookup.mockClear();
await poll.poll(at(ARR + 121 * MIN));
expect(feed.lookup).not.toHaveBeenCalled();
expect(await findRow(orm, FlightLiveStates, { callsign: 'DLH100' })).toMatchObject({
phase: 'unknown',
next_poll_at: null,
});
expect(t.realtime.broadcastMock).toHaveBeenCalledTimes(1);
const [tripId, , payload] = t.realtime.broadcastMock.mock.calls[0];
expect(tripId).toBe(trip.id);
expect((payload as { flights: Array<{ phase: string }> }).flights[0].phase).toBe('unknown');
t.realtime.broadcastMock.mockClear();
await poll.poll(at(ARR + 125 * MIN));
expect(t.realtime.broadcastMock).not.toHaveBeenCalled();
});
it('FLPOLL-012: two sectors of one through flight keep a state each', async () => {
const { user } = await makeUser(orm);
const trip = await makeTrip(orm, user.id, { title: 'There and back' });
const day = await makeDay(orm, trip.id, { day_number: 1, date: '2026-07-01' });
const legs = [
{ from: 'FRA', to: 'BER', flight_number: 'LH 100', dep_day_id: day.id, dep_time: '12:00' },
{ from: 'BER', to: 'FRA', flight_number: 'LH 100', dep_day_id: day.id, dep_time: '15:00' },
];
const flight = await makeReservation(orm, trip.id, {
title: 'Through flight',
type: 'flight',
metadata: JSON.stringify({ legs }),
});
const fra = { name: 'Frankfurt (FRA)', code: 'FRA', lat: 50.0333, lng: 8.5706, timezone: 'Europe/Berlin' };
const ber = { name: 'Berlin (BER)', code: 'BER', lat: 52.3667, lng: 13.5033, timezone: 'Europe/Berlin' };
await makeReservationEndpoint(orm, flight.id, { role: 'from', sequence: 0, ...fra, local_date: '2026-07-01' });
await makeReservationEndpoint(orm, flight.id, { role: 'stop', sequence: 1, ...ber, local_date: '2026-07-01' });
await makeReservationEndpoint(orm, flight.id, { role: 'to', sequence: 2, ...fra, local_date: '2026-07-01' });
feed.lookup.mockResolvedValue(seen(aircraft()));
expect(await poll.poll(at(DEP + 5 * MIN))).toMatchObject({ asked: 2 });
const rows = await findRows(orm, FlightLiveStates, { callsign: 'DLH100' });
// Seen climbing out of Frankfurt: wheels-up for the first sector, nothing claimed for the second.
expect(rows.map((r) => [r.origin, r.destination, r.departed_at]).sort()).toEqual([
['BER', 'FRA', null],
['FRA', 'BER', '2026-07-01 10:05:00'],
]);
feed.lookup.mockReset();
});
});
describe('FlightLiveJob', () => {
it('FLJOB-001: registers nothing under test and runs one poll per tick, a failure logged not thrown', async () => {
const job = t.get(FlightLiveJob);
const registrar = t.get(CronRegistrarService);
const register = vi.spyOn(registrar, 'register');
job.onApplicationBootstrap();
expect(register).not.toHaveBeenCalled();
const run = vi.spyOn(poll, 'poll').mockResolvedValueOnce({ asked: 2, changed: 1, skipped: null });
await job.tick();
run.mockRejectedValueOnce(new Error('boom'));
await expect(job.tick()).resolves.toBeUndefined();
expect(run).toHaveBeenCalledTimes(2);
run.mockRestore();
});
it('FLJOB-002: registers the minute tick where crons run', () => {
const job = t.get(FlightLiveJob);
const registrar = t.get(CronRegistrarService);
vi.spyOn(registrar, 'isEnabled').mockReturnValueOnce(true);
const register = vi.spyOn(registrar, 'register').mockReturnValueOnce(true);
job.onApplicationBootstrap();
expect(register).toHaveBeenCalledWith('flight-live', '* * * * *', expect.any(Function));
register.mockRestore();
});
});
@@ -0,0 +1,61 @@
/**
* From a booked flight number to the callsign an aircraft transmits: the
* spellings a traveller types, the ICAO form taken as it is, an airline code
* borrowed from the airline field, and what cannot be told.
*/
import { airlineTable, toCallsign } from '../../../../src/nest/flight-live/flight-live.callsign';
import { describe, expect, it } from 'vitest';
const TABLE = new Map([
['LH', 'DLH'],
['U2', 'EZY'],
['4U', 'GWI'],
]);
describe('toCallsign', () => {
it('FLCS-001: "LH 716", "LH0716", "lh716" and "LH-716" all fly as DLH716', () => {
for (const booked of ['LH 716', 'LH0716', 'lh716', 'LH-716', ' LH 0716 ']) {
expect(toCallsign(booked, null, TABLE)).toBe('DLH716');
}
});
it('FLCS-002: codes with a digit map like any other, and a letter suffix stays', () => {
expect(toCallsign('U2 1234', null, TABLE)).toBe('EZY1234');
expect(toCallsign('4U9520', null, TABLE)).toBe('GWI9520');
expect(toCallsign('LH 7A', null, TABLE)).toBe('DLH7A');
});
it('FLCS-003: a number already in ICAO form is taken as it is, its zeros trimmed', () => {
expect(toCallsign('DLH716', null, TABLE)).toBe('DLH716');
expect(toCallsign('BAW0012', null, TABLE)).toBe('BAW12');
expect(toCallsign('EZY0', null, TABLE)).toBe('EZY0');
});
it('FLCS-004: a bare number borrows the airline code, never an airline name', () => {
expect(toCallsign('716', 'LH', TABLE)).toBe('DLH716');
expect(toCallsign('0716', 'dlh', TABLE)).toBe('DLH716');
expect(toCallsign('716', 'Lufthansa', TABLE)).toBeNull();
expect(toCallsign('716', 'XX', TABLE)).toBeNull();
expect(toCallsign('716', null, TABLE)).toBeNull();
});
it('FLCS-005: an unknown code, an empty or odd number and no number at all answer null', () => {
expect(toCallsign('XX 716', null, TABLE)).toBeNull();
expect(toCallsign('', null, TABLE)).toBeNull();
expect(toCallsign(' ', null, TABLE)).toBeNull();
expect(toCallsign('LH 12345', null, TABLE)).toBeNull();
expect(toCallsign('Lufthansa', null, TABLE)).toBeNull();
expect(toCallsign(null, 'LH', TABLE)).toBeNull();
expect(toCallsign(undefined, 'LH', TABLE)).toBeNull();
});
it('FLCS-006: the bundled table maps the big carriers, once read and then kept', () => {
const table = airlineTable();
expect(table.get('LH')).toBe('DLH');
expect(table.get('BA')).toBe('BAW');
expect(table.get('U2')).toBe('EZY');
expect(airlineTable()).toBe(table);
expect(toCallsign('LH 400')).toBe('DLH400');
});
});
@@ -0,0 +1,132 @@
/**
* GET /api/trips/:tripId/flights/live and its MCP twin get_trip_flight_status
* over a stub service: the route's guards and contract, the tool's scope and
* input, and parity: both ask the same service method for the same trip
* after the same access check, and answer the same body.
*/
import type { McpContext, McpTextResult, ToolOptions } from '../../../../src/nest-mcp';
import { getEntry, type ClassRef } from '../../../../src/nest-mcp/metadata';
import { JwtAuthGuard } from '../../../../src/nest/auth-core/jwt-auth.guard';
import { RESPONSE_CONTRACT_KEY } from '../../../../src/nest/common/response-contract';
import { FlightLiveController } from '../../../../src/nest/flight-live/flight-live.controller';
import { FlightLiveMcp } from '../../../../src/nest/flight-live/flight-live.mcp';
import type { FlightLiveService } from '../../../../src/nest/flight-live/flight-live.service';
import { TripAccessGuard } from '../../../../src/nest/permissions/trip-access.guard';
import type { TripAccessService } from '../../../../src/nest/trip-membership/trip-access.service';
import { RequestMethod } from '@nestjs/common';
import { GUARDS_METADATA, METHOD_METADATA, PATH_METADATA } from '@nestjs/common/constants';
import { tripFlightLiveResponseSchema, type TripFlightLiveResponse } from '@trek/shared';
import { describe, expect, it, vi } from 'vitest';
import { z } from 'zod';
const ANSWER: TripFlightLiveResponse = {
enabled: true,
attribution: 'Live positions: adsb.lol (ODbL)',
flights: [
{
reservation_id: 10,
leg_index: 0,
callsign: 'DLH100',
flight_number: 'LH 100',
service_date: '2026-07-01',
origin: { code: 'FRA', name: 'Frankfurt', lat: 50.03, lng: 8.57, timezone: 'Europe/Berlin' },
destination: { code: 'BER', name: 'Berlin', lat: 52.36, lng: 13.5, timezone: 'Europe/Berlin' },
phase: 'scheduled',
scheduled_departure: '2026-07-01T10:00:00.000Z',
scheduled_arrival: '2026-07-01T11:10:00.000Z',
departed_at: null,
landed_at: null,
estimated_arrival: null,
departure_delay_min: null,
arrival_delay_min: null,
progress: 0,
distance_total_km: 431,
distance_remaining_km: 431,
position: null,
aircraft: null,
last_seen_at: null,
fetched_at: null,
stale: false,
source: null,
},
],
};
function service() {
return { tripFlights: vi.fn<FlightLiveService['tripFlights']>().mockResolvedValue(ANSWER) };
}
describe('FlightLiveController', () => {
it('FLCTL-001: GET api/trips/:tripId/flights/live sits behind the session and the trip, on the response contract', () => {
expect(Reflect.getMetadata(PATH_METADATA, FlightLiveController)).toBe('api/trips/:tripId/flights');
expect(Reflect.getMetadata(GUARDS_METADATA, FlightLiveController)).toEqual([JwtAuthGuard, TripAccessGuard]);
const handler = FlightLiveController.prototype.list;
expect(Reflect.getMetadata(METHOD_METADATA, handler)).toBe(RequestMethod.GET);
expect(Reflect.getMetadata(PATH_METADATA, handler)).toBe('live');
expect(Reflect.getMetadata(RESPONSE_CONTRACT_KEY, handler)).toBe(tripFlightLiveResponseSchema);
});
it('FLCTL-002: asks the service for the trip the guard resolved and answers what it said', async () => {
const live = service();
const controller = new FlightLiveController(live as unknown as FlightLiveService);
await expect(controller.list({ id: 3, user_id: 1, currency: null })).resolves.toBe(ANSWER);
expect(live.tripFlights).toHaveBeenCalledWith(3);
});
});
const ctx = { userId: 7, scopes: null, isStaticToken: false } as McpContext;
function toolOptions(): ToolOptions {
const entry = getEntry(FlightLiveMcp as unknown as ClassRef, 'getTripFlightStatus');
if (!entry || entry.kind !== 'tool') throw new Error('get_trip_flight_status is not registered');
return entry.options;
}
function makeMcp(accessible: boolean) {
const live = service();
const trip = accessible ? { id: 3, user_id: 1, currency: null } : undefined;
const access = { findAccessible: vi.fn().mockResolvedValue(trip) };
const mcp = new FlightLiveMcp(live as unknown as FlightLiveService, access as unknown as TripAccessService);
return { mcp, live, access };
}
describe('FlightLiveMcp', () => {
it('FLMCP-001: get_trip_flight_status is read only, in the bookings scope like reading them', () => {
const options = toolOptions();
expect(options.name).toBe('get_trip_flight_status');
expect(options.annotations?.readOnlyHint).toBe(true);
expect(options.access).toEqual({ group: 'reservations', mode: 'read' });
const input = z.object(options.inputSchema as z.ZodRawShape);
expect(input.safeParse({ tripId: 3 }).success).toBe(true);
expect(input.safeParse({ tripId: 0 }).success).toBe(false);
expect(input.safeParse({}).success).toBe(false);
});
it('FLMCP-002: parity: the same service method for the same trip answers the same body as the route', async () => {
const { mcp, live, access } = makeMcp(true);
const result = (await mcp.getTripFlightStatus({ tripId: 3 }, ctx)) as McpTextResult;
expect(access.findAccessible).toHaveBeenCalledWith(3, 7);
expect(live.tripFlights).toHaveBeenCalledWith(3);
expect(result.isError).toBeUndefined();
const restBody = await new FlightLiveController(live as unknown as FlightLiveService).list({
id: 3,
user_id: 1,
currency: null,
});
expect(JSON.parse(result.content[0].text)).toEqual(restBody);
});
it('FLMCP-003: a trip the caller cannot see is refused before anything is read', async () => {
const { mcp, live } = makeMcp(false);
const result = (await mcp.getTripFlightStatus({ tripId: 9 }, ctx)) as McpTextResult;
expect(result).toEqual({ content: [{ type: 'text', text: 'Trip not found or access denied.' }], isError: true });
expect(live.tripFlights).not.toHaveBeenCalled();
});
it('FLMCP-004: an answer off the contract is not passed on', async () => {
const { mcp, live } = makeMcp(true);
live.tripFlights.mockResolvedValueOnce({ enabled: 'yes' } as unknown as TripFlightLiveResponse);
await expect(mcp.getTripFlightStatus({ tripId: 3 }, ctx)).rejects.toThrow();
});
});
@@ -0,0 +1,339 @@
/**
* What positions say about a booked flight, without the airline: the phase,
* wheels-up and touchdown, the delays and the arrival estimate, the watch
* window and how often the feed is asked. Every clock is passed in.
*/
import {
advance,
closeIfOver,
departureDelay,
emptyState,
failed,
inWatchWindow,
nextPollAt,
observe,
pickAircraft,
toLiveLeg,
watchWindow,
type FeedAircraft,
type LiveState,
} from '../../../../src/nest/flight-live/flight-live.derive';
import type { ScheduledLeg } from '../../../../src/nest/flight-live/flight-live.schedule';
import { flightLiveLegSchema } from '@trek/shared';
import { describe, expect, it } from 'vitest';
const MIN = 60_000;
const DEP = Date.parse('2026-07-01T10:00:00Z');
const ARR = Date.parse('2026-07-01T11:10:00Z');
const LEG: ScheduledLeg = {
reservation_id: 10,
trip_id: 3,
leg_index: 0,
callsign: 'DLH100',
flight_number: 'LH 100',
service_date: '2026-07-01',
origin: { code: 'FRA', name: 'Frankfurt', lat: 50.0333, lng: 8.5706, timezone: 'Europe/Berlin' },
destination: { code: 'TXL', name: 'Berlin', lat: 52.3667, lng: 13.5033, timezone: 'Europe/Berlin' },
scheduled_departure: DEP,
scheduled_arrival: ARR,
};
function ac(overrides: Partial<FeedAircraft> = {}): FeedAircraft {
return {
hex: '3c6444',
flight: 'DLH100',
registration: 'D-AINA',
type: 'A20N',
alt_baro: 'ground',
alt_geom: null,
gs: 12,
track: 90,
baro_rate: null,
geom_rate: null,
lat: 50.035,
lon: 8.56,
seen: 1,
seen_pos: 2,
...overrides,
};
}
const start = emptyState('DLH100', '2026-07-01', 'FRA', 'TXL');
describe('the watch window', () => {
it('FLDER-001: runs from three hours before departure to two after arrival, or 16 h of flight without one', () => {
expect(watchWindow(LEG)).toEqual({ start: DEP - 180 * MIN, end: ARR + 120 * MIN });
expect(watchWindow({ scheduled_departure: DEP, scheduled_arrival: null })).toEqual({
start: DEP - 180 * MIN,
end: DEP + 18 * 60 * MIN,
});
expect(watchWindow({ scheduled_departure: null, scheduled_arrival: ARR })).toBeNull();
expect(inWatchWindow(LEG, DEP - 181 * MIN)).toBe(false);
expect(inWatchWindow(LEG, DEP - 179 * MIN)).toBe(true);
expect(inWatchWindow(LEG, ARR + 121 * MIN)).toBe(false);
expect(inWatchWindow({ scheduled_departure: null, scheduled_arrival: null }, DEP)).toBe(false);
});
});
describe('pickAircraft and observe', () => {
it('FLDER-002: picks the aircraft nearest the route, one without a position last', () => {
const near = ac({ hex: 'near', lat: 51.2, lon: 11 });
const far = ac({ hex: 'far', lat: 40, lon: -3 });
const blind = ac({ hex: 'blind', lat: null, lon: null });
expect(pickAircraft([blind, far, near], LEG)?.hex).toBe('near');
expect(pickAircraft([blind], LEG)?.hex).toBe('blind');
expect(pickAircraft([], LEG)).toBeNull();
});
it('FLDER-003: reads a report at its own time, on the ground at zero feet, the geometric height as a fallback', () => {
const now = DEP;
expect(observe(ac(), now)).toEqual({
position: {
lat: 50.035,
lng: 8.56,
altitude_ft: 0,
ground_speed_kt: 12,
track_deg: 90,
vertical_rate_fpm: null,
on_ground: true,
},
positionAt: now - 2000,
heardAt: now - 1000,
});
const air = observe(ac({ alt_baro: null, alt_geom: 3200, geom_rate: 1500, seen_pos: null, seen: null }), now);
expect(air?.position.altitude_ft).toBe(3200);
expect(air?.position.vertical_rate_fpm).toBe(1500);
expect(air?.positionAt).toBe(now);
expect(observe(ac({ lat: null }), now)).toBeNull();
});
});
describe('advance', () => {
it('FLDER-004: on the ground at the origin is taxiing, first and last heard set', () => {
const next = advance(start, ac(), LEG, DEP - 10 * MIN, 'adsb.lol');
expect(next).toMatchObject({
phase: 'taxiing',
departed_at: null,
first_seen_at: DEP - 10 * MIN - 1000,
last_seen_at: DEP - 10 * MIN - 1000,
registration: 'D-AINA',
aircraft_type: 'A20N',
source: 'adsb.lol',
fetched_at: DEP - 10 * MIN,
fail_count: 0,
});
});
it('FLDER-005: the first airborne report low near the origin is wheels-up; a later one keeps it', () => {
const now = DEP + 12 * MIN;
const climbing = advance(start, ac({ alt_baro: 4000, lat: 50.1, lon: 8.7, gs: 220 }), LEG, now, 'adsb.lol');
expect(climbing.phase).toBe('airborne');
expect(climbing.departed_at).toBe(now - 2000);
const high = ac({ alt_baro: 36000, lat: 51.2, lon: 11, gs: 450 });
const cruising = advance(climbing, high, LEG, now + 10 * MIN, 'adsb.fi');
expect(cruising.departed_at).toBe(now - 2000);
expect(cruising.source).toBe('adsb.fi');
});
it('FLDER-006: first seen high or far from the origin leaves wheels-up unknown', () => {
const high = advance(start, ac({ alt_baro: 30000, lat: 50.1, lon: 8.7 }), LEG, DEP + 30 * MIN, 'adsb.lol');
expect(high).toMatchObject({ phase: 'airborne', departed_at: null });
const far = advance(start, ac({ alt_baro: 8000, lat: 51.5, lon: 12 }), LEG, DEP + 30 * MIN, 'adsb.lol');
expect(far.departed_at).toBeNull();
});
it('FLDER-007: back on the ground near the destination after flying is landed, once', () => {
const flying = advance(start, ac({ alt_baro: 5000, lat: 50.1, lon: 8.7 }), LEG, DEP + 10 * MIN, 'adsb.lol');
const down = advance(flying, ac({ lat: 52.37, lon: 13.5 }), LEG, ARR, 'adsb.lol');
expect(down.phase).toBe('landed');
expect(down.landed_at).toBe(ARR - 2000);
const later = advance(down, ac({ lat: 52.37, lon: 13.5 }), LEG, ARR + 10 * MIN, 'adsb.lol');
expect(later.landed_at).toBe(ARR - 2000);
expect(later.position).toEqual(down.position);
});
it('FLDER-008: on the ground somewhere else after flying is a landing too, a diversion', () => {
const flying = { ...start, phase: 'airborne' as const };
expect(advance(flying, ac({ lat: 48.35, lon: 11.79 }), LEG, ARR, 'adsb.lol').phase).toBe('landed');
expect(advance(start, ac({ lat: 48.35, lon: 11.79 }), LEG, ARR, 'adsb.lol').phase).toBe('taxiing');
});
it('FLDER-008b: first seen down at the destination is landed after the departure time, not before', () => {
const atBerlin = ac({ lat: 52.37, lon: 13.5 });
expect(advance(start, atBerlin, LEG, ARR, 'adsb.lol')).toMatchObject({ phase: 'landed', landed_at: ARR - 2000 });
expect(advance(start, atBerlin, LEG, DEP - 30 * MIN, 'adsb.lol').phase).toBe('taxiing');
});
it('FLDER-009: lost low near the destination and silent ten minutes is landed at its last report', () => {
const lastSeen = ARR - 15 * MIN;
const position = {
lat: 52.3,
lng: 13.4,
altitude_ft: 2500,
ground_speed_kt: 160,
track_deg: 80,
vertical_rate_fpm: -700,
on_ground: false,
};
const flying: LiveState = { ...start, phase: 'airborne', first_seen_at: DEP, last_seen_at: lastSeen, position };
expect(advance(flying, null, LEG, lastSeen + 5 * MIN, 'adsb.lol').phase).toBe('airborne');
const down = advance(flying, null, LEG, lastSeen + 10 * MIN, 'adsb.lol');
expect(down).toMatchObject({ phase: 'landed', landed_at: lastSeen });
const high = { ...flying, position: { ...position, altitude_ft: 20000 } };
expect(advance(high, null, LEG, lastSeen + 30 * MIN, 'adsb.lol').phase).toBe('airborne');
});
it('FLDER-010: never heard two hours after departure is unknown; earlier it stays scheduled', () => {
expect(advance(start, null, LEG, DEP + 119 * MIN, 'adsb.lol').phase).toBe('scheduled');
expect(advance(start, null, LEG, DEP + 120 * MIN, 'adsb.lol').phase).toBe('unknown');
const seen = { ...start, first_seen_at: DEP - 30 * MIN, phase: 'taxiing' as const };
expect(advance(seen, null, LEG, DEP + 180 * MIN, 'adsb.lol').phase).toBe('taxiing');
});
it('FLDER-011: a failure counts and keeps the state', () => {
const once = failed({ ...start, phase: 'taxiing' });
expect(once).toMatchObject({ phase: 'taxiing', fail_count: 1 });
expect(failed(once).fail_count).toBe(2);
});
});
describe('nextPollAt', () => {
it('FLDER-012: every 10 minutes until 40 before departure, then every 2 until airborne', () => {
expect(nextPollAt(start, LEG, DEP - 170 * MIN)).toBe(DEP - 160 * MIN);
expect(nextPollAt(start, LEG, DEP - 45 * MIN)).toBe(DEP - 40 * MIN);
expect(nextPollAt(start, LEG, DEP - 41 * MIN)).toBe(DEP - 39 * MIN);
expect(nextPollAt({ ...start, phase: 'taxiing' }, LEG, DEP + 5 * MIN)).toBe(DEP + 7 * MIN);
});
it('FLDER-013: airborne every 3 minutes, every 6 when more than two hours from both airports', () => {
const position = {
lat: 51.2,
lng: 11,
altitude_ft: 36000,
ground_speed_kt: 450,
track_deg: 60,
vertical_rate_fpm: 0,
on_ground: false,
};
const flying: LiveState = { ...start, phase: 'airborne', position };
expect(nextPollAt(flying, LEG, DEP + 30 * MIN)).toBe(DEP + 33 * MIN);
const longHaul = { ...LEG, destination: { ...LEG.destination, lat: 35.55, lng: 139.78 }, scheduled_arrival: null };
const midway = { ...flying, position: { ...position, lat: 60, lng: 70 } };
expect(nextPollAt(midway, longHaul, DEP + 300 * MIN)).toBe(DEP + 306 * MIN);
expect(nextPollAt({ ...flying, position: null }, LEG, DEP + 30 * MIN)).toBe(DEP + 33 * MIN);
});
it('FLDER-014: unknown or failing every 15 minutes; landed or past the window stops', () => {
expect(nextPollAt({ ...start, phase: 'unknown' }, LEG, DEP + 130 * MIN)).toBe(DEP + 145 * MIN);
expect(nextPollAt({ ...start, fail_count: 3 }, LEG, DEP)).toBe(DEP + 15 * MIN);
expect(nextPollAt({ ...start, phase: 'landed' }, LEG, ARR)).toBeNull();
expect(nextPollAt(start, LEG, ARR + 121 * MIN)).toBeNull();
expect(nextPollAt(start, { ...LEG, scheduled_departure: null }, DEP)).toBeNull();
});
});
describe('toLiveLeg and departureDelay', () => {
it('FLDER-015: without a state the schedule answers: scheduled, unknown late in the window, scheduled after it', () => {
const before = toLiveLeg(LEG, null, DEP - 24 * 60 * MIN);
expect(flightLiveLegSchema.parse(before)).toEqual(before);
expect(before).toMatchObject({
phase: 'scheduled',
scheduled_departure: '2026-07-01T10:00:00.000Z',
scheduled_arrival: '2026-07-01T11:10:00.000Z',
progress: 0,
distance_remaining_km: before.distance_total_km,
position: null,
aircraft: null,
source: null,
stale: false,
});
expect(before.distance_total_km).toBeGreaterThan(400);
expect(toLiveLeg(LEG, null, DEP + 130 * MIN).phase).toBe('unknown');
// Past its window the client tells a past flight from its times; nothing claims a landing nobody saw.
const after = toLiveLeg(LEG, null, ARR + 3 * 60 * MIN);
expect(after).toMatchObject({ phase: 'scheduled', progress: 0, landed_at: null });
expect(after.distance_remaining_km).toBe(after.distance_total_km);
});
it('FLDER-016: airborne answers its estimate from ground speed with the approach buffer and the delays', () => {
const now = DEP + 40 * MIN;
const position = {
lat: 52.3667,
lng: 10.5,
altitude_ft: 36000,
ground_speed_kt: 405,
track_deg: 90,
vertical_rate_fpm: 0,
on_ground: false,
};
const state: LiveState = {
...start,
phase: 'airborne',
departed_at: DEP + 20 * MIN,
first_seen_at: DEP,
last_seen_at: now,
fetched_at: now,
registration: 'D-AINA',
source: 'adsb.lol',
position,
};
const live = toLiveLeg(LEG, state, now);
expect(flightLiveLegSchema.parse(live)).toEqual(live);
expect(live.departure_delay_min).toBe(20);
expect(live.departed_at).toBe('2026-07-01T10:20:00.000Z');
expect(live.aircraft).toEqual({ registration: 'D-AINA', type: null });
expect(live.progress).toBeGreaterThan(0.3);
expect(live.progress).toBeLessThan(1);
// About 204 km left at 750 km/h is 16 minutes, plus 8 for the approach.
const eta = Date.parse(live.estimated_arrival ?? '');
expect(Math.round((eta - now) / MIN)).toBe(24);
expect(live.arrival_delay_min).toBe(Math.round((eta - ARR) / MIN));
expect(live.stale).toBe(false);
expect(toLiveLeg(LEG, state, now + 11 * MIN).stale).toBe(true);
const slow = { ...state, position: { ...position, ground_speed_kt: 120 } };
expect(toLiveLeg(LEG, slow, now).estimated_arrival).toBeNull();
});
it('FLDER-017: landed answers its touchdown and the arrival delay from it', () => {
const state: LiveState = { ...start, phase: 'landed', landed_at: ARR + 25 * MIN, fetched_at: ARR + 30 * MIN };
const live = toLiveLeg(LEG, state, ARR + 30 * MIN);
expect(live).toMatchObject({
phase: 'landed',
landed_at: '2026-07-01T11:35:00.000Z',
arrival_delay_min: 25,
distance_remaining_km: 0,
progress: 1,
estimated_arrival: null,
});
});
it('FLDER-018: the departure delay comes from wheels-up, or from a recent report still at the gate past the time', () => {
expect(departureDelay(LEG, { ...start, departed_at: DEP + 17 * MIN }, DEP + 30 * MIN)).toBe(17);
const atGate = { ...start, phase: 'taxiing' as const, last_seen_at: DEP + 16 * MIN };
expect(departureDelay(LEG, atGate, DEP + 17 * MIN)).toBe(16);
// Gone quiet on the ground: the last report says nothing about now.
expect(departureDelay(LEG, atGate, DEP + 26 * MIN)).toBeNull();
// Heard before the scheduled time: not late yet, however late it is now.
expect(departureDelay(LEG, { ...atGate, last_seen_at: DEP - 5 * MIN }, DEP + 2 * MIN)).toBeNull();
expect(departureDelay(LEG, { ...start, phase: 'taxiing' }, DEP + 16 * MIN)).toBeNull();
expect(departureDelay(LEG, start, DEP + 60 * MIN)).toBeNull();
expect(departureDelay({ ...LEG, scheduled_departure: null }, { ...start, departed_at: DEP }, DEP)).toBeNull();
});
it('FLDER-019: after its window nothing is shown taxiing or in the air, and the state closes as unknown', () => {
const after = ARR + 121 * MIN;
const flying: LiveState = { ...start, phase: 'airborne', last_seen_at: ARR - 30 * MIN, next_poll_at: ARR };
expect(toLiveLeg(LEG, flying, after)).toMatchObject({ phase: 'unknown', stale: false });
expect(toLiveLeg(LEG, { ...flying, phase: 'taxiing' }, after).phase).toBe('unknown');
expect(toLiveLeg(LEG, flying, ARR).phase).toBe('airborne');
expect(closeIfOver(flying, LEG, after)).toMatchObject({ phase: 'unknown', next_poll_at: null });
expect(closeIfOver(flying, LEG, ARR)).toBe(flying);
const landed: LiveState = { ...start, phase: 'landed', landed_at: ARR };
expect(closeIfOver(landed, LEG, after)).toBe(landed);
expect(closeIfOver(start, LEG, after)).toBe(start);
expect(closeIfOver(flying, { scheduled_departure: null, scheduled_arrival: null }, after)).toBe(flying);
});
});
@@ -0,0 +1,213 @@
/**
* The two keyless ADS-B feeds and the budget that spaces them: what goes out
* (the callsign, nothing else), what an answer has to look like, and how a
* bad, oversized, slow or refusing source is handled. fetch is stubbed.
*/
import { AppConfigModule } from '../../../../src/nest/app-config/app-config.module';
import { BUDGET_PER_HOUR, FeedBudget } from '../../../../src/nest/flight-live/flight-live.budget';
import { FlightLiveFeedService } from '../../../../src/nest/flight-live/flight-live.feeds';
import { createSnapshotTestDb } from '../../../helpers/db-mock';
import { createTestModule, type TestModule } from '../../../helpers/test-module';
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('../../../../src/nest/audit/audit-log.logger', async (importOriginal) => ({
...(await importOriginal<typeof import('../../../../src/nest/audit/audit-log.logger')>()),
logWarn: vi.fn(),
}));
const db = createSnapshotTestDb();
let t: TestModule;
let feeds: FlightLiveFeedService;
let clock = 1_000_000;
const now = () => clock;
const AIRCRAFT = {
hex: '3c6444',
flight: 'DLH716 ',
r: 'D-AIXA',
t: 'A359',
alt_baro: 36000,
alt_geom: 36500,
gs: 480.2,
track: 51.3,
baro_rate: 0,
geom_rate: 64,
lat: 55.1,
lon: 60.2,
seen: 0.4,
seen_pos: 1.1,
category: 'A5',
squawk: '1000',
};
function answer(body: unknown, status = 200) {
return new Response(typeof body === 'string' ? body : JSON.stringify(body), {
status,
headers: { 'content-type': 'application/json' },
});
}
beforeEach(async () => {
clock += 60 * 60 * 1000;
t = await createTestModule({ db, imports: [AppConfigModule], providers: [FlightLiveFeedService] });
feeds = t.get(FlightLiveFeedService);
feeds.sleep = async () => undefined;
});
afterEach(async () => {
vi.unstubAllGlobals();
vi.unstubAllEnvs();
vi.restoreAllMocks();
await t.close();
});
afterAll(() => db.close());
describe('FeedBudget', () => {
it('FLFEED-001: one request a second and 200 an hour, a paused source for a quarter of an hour', () => {
const budget = new FeedBudget();
expect(budget.tryTake(0)).toBe(true);
expect(budget.tryTake(500)).toBe(false);
expect(budget.waitMs(500)).toBe(500);
expect(budget.tryTake(1000)).toBe(true);
for (let i = 2; i < BUDGET_PER_HOUR; i++) expect(budget.tryTake(i * 1000)).toBe(true);
expect(budget.exhausted(BUDGET_PER_HOUR * 1000)).toBe(true);
expect(budget.tryTake(BUDGET_PER_HOUR * 1000)).toBe(false);
expect(budget.tryTake(60 * 60 * 1000 + 500)).toBe(true);
expect(new FeedBudget().waitMs(0)).toBe(0);
budget.pause('adsb.lol', 0);
expect(budget.isPaused('adsb.lol', 14 * 60 * 1000)).toBe(true);
expect(budget.isPaused('adsb.lol', 15 * 60 * 1000)).toBe(false);
expect(budget.isPaused('adsb.fi', 0)).toBe(false);
});
});
describe('FlightLiveFeedService', () => {
it('FLFEED-002: asks adsb.lol for the callsign alone and reads the aircraft it answers', async () => {
const fetcher = vi.fn(async () => answer({ ac: [AIRCRAFT], now: 1_700_000_000_000, total: 1 }));
vi.stubGlobal('fetch', fetcher);
const result = await feeds.lookup('DLH716', now);
expect(fetcher).toHaveBeenCalledTimes(1);
const [url, init] = fetcher.mock.calls[0] as unknown as [string, RequestInit];
expect(url).toBe('https://api.adsb.lol/v2/callsign/DLH716');
expect(init.signal).toBeInstanceOf(AbortSignal);
expect(init).not.toHaveProperty('body');
expect(result).toEqual({
ok: true,
source: 'adsb.lol',
now: 1_700_000_000_000,
aircraft: [
{
hex: '3c6444',
flight: 'DLH716',
registration: 'D-AIXA',
type: 'A359',
alt_baro: 36000,
alt_geom: 36500,
gs: 480.2,
track: 51.3,
baro_rate: 0,
geom_rate: 64,
lat: 55.1,
lon: 60.2,
seen: 0.4,
seen_pos: 1.1,
},
],
});
expect(feeds.attribution()).toBe('Live positions: adsb.lol (ODbL)');
});
it('FLFEED-003: keeps only the aircraft flying the callsign, and an answer without any is empty', async () => {
vi.stubGlobal(
'fetch',
vi
.fn()
.mockResolvedValueOnce(answer({ ac: [AIRCRAFT, { ...AIRCRAFT, hex: 'x', flight: 'DLH7160' }], now: 1 }))
.mockResolvedValueOnce(answer({ now: 2 })),
);
const first = await feeds.lookup('DLH716', now);
expect(first).toMatchObject({ ok: true, aircraft: [expect.objectContaining({ hex: '3c6444' })] });
expect((first as { aircraft: unknown[] }).aircraft).toHaveLength(1);
clock += 2000;
expect(await feeds.lookup('DLH716', now)).toEqual({ ok: true, source: 'adsb.lol', now: 2, aircraft: [] });
});
it('FLFEED-004: falls back to adsb.fi on a self-hosted install when adsb.lol fails', async () => {
const fetcher = vi
.fn()
.mockResolvedValueOnce(answer('not json at all'))
.mockResolvedValueOnce(answer({ ac: [AIRCRAFT], now: 5 }));
vi.stubGlobal('fetch', fetcher);
let calls = 0;
const result = await feeds.lookup('DLH716', () => clock + 1000 * calls++);
expect(result).toMatchObject({ ok: true, source: 'adsb.fi' });
expect(fetcher.mock.calls[1]?.[0]).toBe('https://opendata.adsb.fi/api/v2/callsign/DLH716');
});
it('FLFEED-005: a managed install never asks adsb.fi', async () => {
vi.stubEnv('TREK_MANAGED', 'true');
const fetcher = vi.fn(async () => answer({ unexpected: true }));
vi.stubGlobal('fetch', fetcher);
let calls = 0;
expect(await feeds.lookup('DLH716', () => clock + 1000 * calls++)).toEqual({ ok: false, reason: 'unavailable' });
expect(fetcher).toHaveBeenCalledTimes(1);
expect(feeds.sources()).toEqual(['adsb.lol']);
});
it('FLFEED-006: an oversized body, a timeout and a bad shape are failures, never data', async () => {
vi.stubEnv('TREK_MANAGED', 'true');
const huge = 'x'.repeat(300 * 1024);
vi.stubGlobal(
'fetch',
vi
.fn()
.mockResolvedValueOnce(answer(huge))
.mockRejectedValueOnce(new DOMException('The operation timed out.', 'TimeoutError'))
.mockResolvedValueOnce(answer({ ac: [{ hex: 1 }], now: 1 })),
);
const failure = { ok: false, reason: 'unavailable' };
clock += 2000;
expect(await feeds.lookup('DLH716', now)).toEqual(failure);
clock += 2000;
expect(await feeds.lookup('DLH716', now)).toEqual(failure);
clock += 2000;
expect(await feeds.lookup('DLH716', now)).toEqual(failure);
// Three failures in a row pause adsb.lol: nothing is asked and nothing is spent.
clock += 2000;
expect(await feeds.lookup('DLH716', now)).toEqual({ ok: false, reason: 'budget' });
});
it('FLFEED-007: a 429 pauses the source at once and its body is let go', async () => {
vi.stubEnv('TREK_MANAGED', 'true');
const cancel = vi.fn(async () => undefined);
const limited = { ok: false, status: 429, body: { cancel } } as unknown as Response;
const fetcher = vi.fn(async () => limited);
vi.stubGlobal('fetch', fetcher);
expect(await feeds.lookup('DLH716', now)).toEqual({ ok: false, reason: 'unavailable' });
expect(cancel).toHaveBeenCalled();
clock += 5 * 60 * 1000;
expect(await feeds.lookup('DLH716', now)).toEqual({ ok: false, reason: 'budget' });
expect(fetcher).toHaveBeenCalledTimes(1);
clock += 11 * 60 * 1000;
vi.stubGlobal(
'fetch',
vi.fn(async () => answer({ ac: [], now: 9 })),
);
expect(await feeds.lookup('DLH716', now)).toMatchObject({ ok: true, source: 'adsb.lol' });
});
it('FLFEED-008: the spacing between requests is held, and a malformed callsign never leaves', async () => {
const fetcher = vi.fn(async () => answer({ ac: [], now: 1 }));
vi.stubGlobal('fetch', fetcher);
expect(feeds.canAsk(clock)).toBe(true);
await feeds.lookup('DLH716', now);
expect(feeds.canAsk(clock)).toBe(false);
expect(feeds.waitMs(clock)).toBe(1000);
expect(await feeds.lookup('DLH716', now)).toEqual({ ok: false, reason: 'budget' });
clock += 1000;
expect(await feeds.lookup('DLH716/../x', now)).toEqual({ ok: false, reason: 'unavailable' });
expect(await feeds.lookup('dlh716', now)).toEqual({ ok: false, reason: 'unavailable' });
expect(fetcher).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,247 @@
/**
* The legs of a flight booking with their schedule in UTC, read the way the
* client's flightLegs.ts reads them: a single leg from the endpoints and the
* flat metadata, a chain from `metadata.legs` with the days it names, and
* the local clocks turned into instants with each airport's own zone.
*/
import {
legsOfBooking,
localToUtc,
type AirportFacts,
type FlightBookingRow,
type FlightEndpointRow,
} from '../../../../src/nest/flight-live/flight-live.schedule';
import { describe, expect, it } from 'vitest';
const AIRLINES = new Map([
['LH', 'DLH'],
['NH', 'ANA'],
]);
const AIRPORTS: Record<string, AirportFacts> = {
FRA: { name: 'Frankfurt am Main Airport', city: 'Frankfurt', lat: 50.0333, lng: 8.5706, tz: 'Europe/Berlin' },
MUC: { name: 'Munich Airport', city: 'Munich', lat: 48.3538, lng: 11.7861, tz: 'Europe/Berlin' },
HND: { name: 'Tokyo Haneda Airport', city: 'Tokyo', lat: 35.5523, lng: 139.7798, tz: 'Asia/Tokyo' },
JFK: {
name: 'John F Kennedy International Airport',
city: 'New York',
lat: 40.6398,
lng: -73.7789,
tz: 'America/New_York',
},
};
const lookup = (code: string) => AIRPORTS[code] ?? null;
function booking(overrides: Partial<FlightBookingRow> = {}): FlightBookingRow {
return {
id: 10,
trip_id: 3,
metadata: {},
day_id: null,
end_day_id: null,
reservation_time: null,
reservation_end_time: null,
...overrides,
};
}
function endpoint(overrides: Partial<FlightEndpointRow>): FlightEndpointRow {
return {
reservation_id: 10,
role: 'from',
sequence: 0,
name: 'Frankfurt (FRA)',
code: 'FRA',
lat: 50.0333,
lng: 8.5706,
timezone: 'Europe/Berlin',
local_date: null,
local_time: null,
...overrides,
};
}
const NO_DAYS = new Map<number, string>();
describe('localToUtc', () => {
it('FLSCH-001: reads a local clock in its zone, summer time included, and needs all three parts', () => {
expect(localToUtc('2026-07-01', '10:30', 'Europe/Berlin')).toBe(Date.parse('2026-07-01T08:30:00Z'));
expect(localToUtc('2026-01-15', '10:30', 'Europe/Berlin')).toBe(Date.parse('2026-01-15T09:30:00Z'));
expect(localToUtc('2026-07-01', '10:30', 'Asia/Tokyo')).toBe(Date.parse('2026-07-01T01:30:00Z'));
expect(localToUtc(null, '10:30', 'Europe/Berlin')).toBeNull();
expect(localToUtc('2026-07-01', null, 'Europe/Berlin')).toBeNull();
expect(localToUtc('2026-07-01', '10:30', null)).toBeNull();
expect(localToUtc('2026-07-01', '10:30', 'Not/AZone')).toBeNull();
});
});
describe('legsOfBooking', () => {
it('FLSCH-002: a single leg from its endpoints, with each end in its own zone', () => {
const legs = legsOfBooking(
booking({ metadata: { flight_number: 'LH 716', airline: 'Lufthansa' } }),
[
endpoint({ local_date: '2026-07-01', local_time: '13:40' }),
endpoint({
role: 'to',
sequence: 1,
name: 'Tokyo Haneda (HND)',
code: 'HND',
lat: 35.5523,
lng: 139.7798,
timezone: 'Asia/Tokyo',
local_date: '2026-07-02',
local_time: '08:15',
}),
],
NO_DAYS,
lookup,
AIRLINES,
);
expect(legs).toEqual([
{
reservation_id: 10,
trip_id: 3,
leg_index: 0,
callsign: 'DLH716',
flight_number: 'LH 716',
service_date: '2026-07-01',
origin: { code: 'FRA', name: 'Frankfurt', lat: 50.0333, lng: 8.5706, timezone: 'Europe/Berlin' },
destination: { code: 'HND', name: 'Tokyo Haneda', lat: 35.5523, lng: 139.7798, timezone: 'Asia/Tokyo' },
scheduled_departure: Date.parse('2026-07-01T11:40:00Z'),
scheduled_arrival: Date.parse('2026-07-01T23:15:00Z'),
},
]);
});
it('FLSCH-003: a chain of legs takes its days and times from metadata.legs and its airports from the dataset', () => {
const days = new Map([
[1, '2026-07-01'],
[2, '2026-07-02'],
]);
const legs = legsOfBooking(
booking({
metadata: {
legs: [
{
from: 'MUC',
to: 'FRA',
flight_number: 'LH 101',
dep_day_id: 1,
dep_time: '07:00',
arr_day_id: 1,
arr_time: '08:00',
},
{
from: 'FRA',
to: 'HND',
airline: 'NH',
flight_number: '204',
dep_day_id: 1,
dep_time: '21:00',
arr_time: '16:00',
},
],
},
}),
[],
days,
lookup,
AIRLINES,
);
expect(legs.map((l) => [l.leg_index, l.callsign, l.service_date, l.origin.code, l.destination.code])).toEqual([
[0, 'DLH101', '2026-07-01', 'MUC', 'FRA'],
[1, 'ANA204', '2026-07-01', 'FRA', 'HND'],
]);
expect(legs[0].origin).toEqual({
code: 'MUC',
name: 'Munich Airport',
lat: 48.3538,
lng: 11.7861,
timezone: 'Europe/Berlin',
});
expect(legs[0].scheduled_departure).toBe(Date.parse('2026-07-01T05:00:00Z'));
expect(legs[0].scheduled_arrival).toBe(Date.parse('2026-07-01T06:00:00Z'));
// No arrival day: 16:00 Tokyo on the departure day is before 21:00 Frankfurt, so it lands the next day.
expect(legs[1].scheduled_departure).toBe(Date.parse('2026-07-01T19:00:00Z'));
expect(legs[1].scheduled_arrival).toBe(Date.parse('2026-07-02T07:00:00Z'));
});
it('FLSCH-004: a leg without a mappable number, a known airport or a departure day is left out', () => {
const days = new Map([[1, '2026-07-01']]);
const legs = legsOfBooking(
booking({
metadata: {
legs: [
{ from: 'FRA', to: 'HND', flight_number: 'Lufthansa', dep_day_id: 1 },
{ from: 'FRA', to: 'XYZ', flight_number: 'LH 1', dep_day_id: 1 },
{ from: 'FRA', to: 'HND', flight_number: 'LH 2', dep_day_id: 99 },
{ from: 'FRA', to: 'HND', flight_number: 'LH 3', dep_day_id: 1 },
'not a leg',
],
},
}),
[],
days,
lookup,
AIRLINES,
);
expect(legs.map((l) => [l.leg_index, l.callsign, l.scheduled_departure, l.scheduled_arrival])).toEqual([
[3, 'DLH3', null, null],
]);
});
it('FLSCH-005: the flat metadata and the booking times stand in for missing endpoints', () => {
const legs = legsOfBooking(
booking({
metadata: { departure_airport: 'JFK', arrival_airport: 'FRA', flight_number: 'LH0401' },
reservation_time: '2026-03-10T17:30',
reservation_end_time: '2026-03-11T07:45',
}),
[],
NO_DAYS,
lookup,
AIRLINES,
);
expect(legs).toHaveLength(1);
expect(legs[0].callsign).toBe('DLH401');
expect(legs[0].service_date).toBe('2026-03-10');
expect(legs[0].origin.name).toBe('John F Kennedy International Airport');
expect(legs[0].scheduled_departure).toBe(Date.parse('2026-03-10T21:30:00Z'));
expect(legs[0].scheduled_arrival).toBe(Date.parse('2026-03-11T06:45:00Z'));
});
it('FLSCH-006: a booking day stands in for an endpoint without a date, and an arrival before departure on its own date is dropped', () => {
const days = new Map([
[5, '2026-07-01'],
[6, '2026-07-01'],
]);
const legs = legsOfBooking(
booking({ metadata: { flight_number: 'LH 716' }, day_id: 5, end_day_id: 6 }),
[
endpoint({ local_time: '13:40:00' }),
endpoint({
role: 'to',
sequence: 1,
code: 'HND',
name: 'Haneda',
lat: 35.55,
lng: 139.78,
timezone: null,
local_time: '08:15',
}),
],
days,
lookup,
AIRLINES,
);
expect(legs[0].service_date).toBe('2026-07-01');
expect(legs[0].destination.timezone).toBe('Asia/Tokyo');
expect(legs[0].scheduled_departure).toBe(Date.parse('2026-07-01T11:40:00Z'));
expect(legs[0].scheduled_arrival).toBeNull();
});
it('FLSCH-007: a booking with no airports at all has no legs', () => {
expect(legsOfBooking(booking({ metadata: { flight_number: 'LH 1' } }), [], NO_DAYS, lookup, AIRLINES)).toEqual([]);
});
});
@@ -0,0 +1,223 @@
/**
* FlightLiveService: the trip read the REST route and the MCP tool share.
* The service comes out of FlightLiveModule over the migrated snapshot, with
* the real airports, settings and repository; only the feed is a stub, and
* the read must never ask it.
*/
import { AppSettings } from '../../../../src/db/entities/AppSettings.entity';
import { FlightLiveStates } from '../../../../src/db/entities/FlightLiveStates.entity';
import type { FlightLiveStateRow } from '../../../../src/db/repositories/FlightLiveStates.repository';
import { FlightLiveFeedService } from '../../../../src/nest/flight-live/flight-live.feeds';
import { FlightLiveModule } from '../../../../src/nest/flight-live/flight-live.module';
import { FlightLiveService, rowToState, stateKey } from '../../../../src/nest/flight-live/flight-live.service';
import { createSnapshotTestDb } from '../../../helpers/db-mock';
import { makeReservation, makeReservationEndpoint } from '../../../helpers/factories/reservations';
import { deleteRows, insertRow } from '../../../helpers/factories/rows';
import { makeTrip } from '../../../helpers/factories/trips';
import { makeUser } from '../../../helpers/factories/users';
import { resetTestDb } from '../../../helpers/test-db';
import { createTestModule, type TestModule } from '../../../helpers/test-module';
import { MikroORM } from '@mikro-orm/core';
import { tripFlightLiveResponseSchema } from '@trek/shared';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
const db = createSnapshotTestDb();
let t: TestModule;
let service: FlightLiveService;
let orm: MikroORM;
const feed = {
lookup: vi.fn(),
attribution: vi.fn(() => 'Live positions: adsb.lol (ODbL)'),
sources: vi.fn(() => ['adsb.lol']),
canAsk: vi.fn(() => true),
waitMs: vi.fn(() => 0),
};
const MIN = 60_000;
/** 12:00 in Frankfurt on 1 July 2026; the flight lands in Berlin at 13:10. */
const DEP = Date.parse('2026-07-01T10:00:00Z');
beforeAll(async () => {
t = await createTestModule({
db,
imports: [FlightLiveModule],
overrides: [{ provide: FlightLiveFeedService, useValue: feed }],
});
service = t.get(FlightLiveService);
orm = t.get(MikroORM);
});
beforeEach(() => {
resetTestDb(db);
vi.clearAllMocks();
});
afterAll(async () => {
await t.close();
db.close();
});
/** A trip with one flight LH 100 from Frankfurt to Berlin on 1 July 2026. */
async function seedFlight(flightNumber = 'LH 100') {
const { user } = await makeUser(orm);
const trip = await makeTrip(orm, user.id, { title: 'Berlin' });
const flight = await makeReservation(orm, trip.id, {
title: 'FRA to BER',
type: 'flight',
metadata: JSON.stringify({ flight_number: flightNumber, airline: 'Lufthansa' }),
});
await makeReservationEndpoint(orm, flight.id, {
role: 'from',
sequence: 0,
name: 'Frankfurt (FRA)',
code: 'FRA',
lat: 50.0333,
lng: 8.5706,
timezone: 'Europe/Berlin',
local_date: '2026-07-01',
local_time: '12:00',
});
await makeReservationEndpoint(orm, flight.id, {
role: 'to',
sequence: 1,
name: 'Berlin Brandenburg (BER)',
code: 'BER',
lat: 52.3667,
lng: 13.5033,
timezone: 'Europe/Berlin',
local_date: '2026-07-01',
local_time: '13:10',
});
return { user, trip, flight };
}
function storedRow(overrides: Partial<FlightLiveStateRow> = {}): FlightLiveStateRow {
return {
callsign: 'DLH100',
service_date: '2026-07-01',
origin: 'FRA',
destination: 'BER',
phase: 'airborne',
departed_at: '2026-07-01 10:12:00',
landed_at: null,
lat: 51.2,
lng: 11,
altitude_ft: 36000,
ground_speed_kt: 420,
track_deg: 60,
vertical_rate_fpm: 0,
on_ground: 0,
registration: 'D-AINA',
aircraft_type: 'A20N',
source: 'adsb.fi',
first_seen_at: '2026-07-01 09:50:00',
last_seen_at: '2026-07-01 10:30:00',
fetched_at: '2026-07-01 10:30:00',
next_poll_at: '2026-07-01 10:33:00',
fail_count: 0,
departed_notified: 1,
landed_notified: 0,
delay_notified: 0,
...overrides,
};
}
describe('FlightLiveService.tripFlights', () => {
it('FLSVC-001: switched off, it answers nothing and reads no booking', async () => {
const { trip } = await seedFlight();
await insertRow(orm, AppSettings, { key: 'flight_live_enabled', value: 'false' });
expect(await service.isEnabled()).toBe(false);
expect(await service.tripFlights(trip.id, DEP)).toEqual({ enabled: false, attribution: null, flights: [] });
});
it('FLSVC-002: a flight days ahead comes back with its schedule in UTC, and nothing is put up for a poll', async () => {
const { trip, flight } = await seedFlight();
const answer = await service.tripFlights(trip.id, DEP - 3 * 24 * 60 * MIN);
expect(tripFlightLiveResponseSchema.parse(answer)).toEqual(answer);
expect(answer).toMatchObject({ enabled: true, attribution: 'Live positions: adsb.lol (ODbL)' });
expect(answer.flights).toHaveLength(1);
expect(answer.flights[0]).toMatchObject({
reservation_id: flight.id,
leg_index: 0,
callsign: 'DLH100',
flight_number: 'LH 100',
service_date: '2026-07-01',
origin: { code: 'FRA', name: 'Frankfurt', timezone: 'Europe/Berlin' },
destination: { code: 'BER', name: 'Berlin Brandenburg', timezone: 'Europe/Berlin' },
phase: 'scheduled',
scheduled_departure: '2026-07-01T10:00:00.000Z',
scheduled_arrival: '2026-07-01T11:10:00.000Z',
source: null,
});
expect(service.takeRefreshRequests(DEP)).toEqual(new Set());
expect(feed.lookup).not.toHaveBeenCalled();
});
it('FLSVC-003: an in-window flight without a fresh answer is put up for the next tick, once a minute', async () => {
const { trip } = await seedFlight();
const key = stateKey('DLH100', '2026-07-01', 'FRA', 'BER');
await service.tripFlights(trip.id, DEP - 30 * MIN);
await service.tripFlights(trip.id, DEP - 29.5 * MIN);
expect(service.takeRefreshRequests(DEP - 29.4 * MIN)).toEqual(new Set([key]));
// Within the minute of the first ask: nothing new to hand over.
await service.tripFlights(trip.id, DEP - 29.2 * MIN);
expect(service.takeRefreshRequests(DEP - 29.1 * MIN)).toEqual(new Set());
await service.tripFlights(trip.id, DEP - 28.5 * MIN);
expect(service.takeRefreshRequests(DEP - 28.5 * MIN)).toEqual(new Set([key]));
expect(feed.lookup).not.toHaveBeenCalled();
});
it('FLSVC-004: a stored state answers on top of the schedule, with the credit of its source', async () => {
const { trip } = await seedFlight();
await insertRow(orm, FlightLiveStates, storedRow());
const now = Date.parse('2026-07-01T10:31:00Z');
const answer = await service.tripFlights(trip.id, now);
expect(answer.attribution).toBe('Live positions: adsb.fi');
expect(answer.flights[0]).toMatchObject({
phase: 'airborne',
departed_at: '2026-07-01T10:12:00.000Z',
departure_delay_min: 12,
aircraft: { registration: 'D-AINA', type: 'A20N' },
last_seen_at: '2026-07-01T10:30:00.000Z',
source: 'adsb.fi',
stale: false,
});
// Fetched a minute ago: fresh enough, nothing to refresh.
expect(service.takeRefreshRequests(now)).toEqual(new Set());
});
it('FLSVC-004b: a flight the poll backs off from, or has given up on, is not put up by a client', async () => {
const { trip } = await seedFlight();
const now = Date.parse('2026-07-01T10:31:00Z');
await insertRow(orm, FlightLiveStates, storedRow({ phase: 'unknown', fetched_at: '2026-07-01 10:20:00' }));
await service.tripFlights(trip.id, now);
expect(service.takeRefreshRequests(now)).toEqual(new Set());
await deleteRows(orm, FlightLiveStates);
await insertRow(orm, FlightLiveStates, storedRow({ fail_count: 3, fetched_at: '2026-07-01 10:20:00' }));
await service.tripFlights(trip.id, now + 2 * MIN);
expect(service.takeRefreshRequests(now + 2 * MIN)).toEqual(new Set());
});
it('FLSVC-005: a flight number that maps to no callsign is not listed', async () => {
const { trip } = await seedFlight('Lufthansa');
expect((await service.tripFlights(trip.id, DEP)).flights).toEqual([]);
const { user } = await makeUser(orm);
const empty = await makeTrip(orm, user.id);
expect(await service.tripFlights(empty.id, DEP)).toMatchObject({ enabled: true, flights: [] });
});
});
describe('rowToState', () => {
it('FLSVC-006: reads the stored text back as instants, and an unknown phase or source falls back', () => {
const state = rowToState(storedRow());
expect(state).toMatchObject({
phase: 'airborne',
departed_at: Date.parse('2026-07-01T10:12:00Z'),
source: 'adsb.fi',
departed_notified: true,
landed_notified: false,
position: { lat: 51.2, lng: 11, on_ground: false },
});
const odd = rowToState(storedRow({ phase: 'cruising', source: 'radar', lat: null, on_ground: 1 }));
expect(odd).toMatchObject({ phase: 'scheduled', source: null, position: null });
});
});
+28 -1
View File
@@ -15,12 +15,14 @@ import {
MANAGED_LOCKED_SETTING_KEYS,
MANAGED_LOCKED_PROFILE_KEYS,
MANAGED_CUSTOMER_KEYS,
envLockedKeys,
isManagedLockedKey,
splitManagedKeys,
withEnvLocks,
} from '../../../src/nest/common/managed';
import { DEFAULTABLE_USER_SETTING_KEYS } from '../../../src/nest/settings/settings.service';
import { describe, it, expect } from 'vitest';
import { afterEach, describe, it, expect, vi } from 'vitest';
const SOURCE_KEYS: string[] = [...ADMIN_FORM_SETTING_KEYS, ...DEFAULTABLE_USER_SETTING_KEYS];
const locked = new Set<string>(MANAGED_LOCKED_SETTING_KEYS);
@@ -129,3 +131,28 @@ describe('splitManagedKeys', () => {
expect(blocked).toEqual([]);
});
});
describe('env-held admin switches', () => {
afterEach(() => vi.unstubAllEnvs());
it('MANAGED-ENV-001: unset, the form answers the stored value and a save writes it', () => {
vi.stubEnv('FLIGHT_LIVE_ENABLED', '');
expect(withEnvLocks({ flight_live_enabled: 'true', require_mfa: 'false' })).toEqual({
flight_live_enabled: 'true',
require_mfa: 'false',
});
expect(envLockedKeys({ flight_live_enabled: 'false' })).toEqual([]);
});
it('MANAGED-ENV-002: set, the form shows the variable and env_override_flight_live_enabled, and a save skips it', () => {
vi.stubEnv('FLIGHT_LIVE_ENABLED', 'off');
expect(withEnvLocks({ flight_live_enabled: 'true', require_mfa: 'false' })).toEqual({
flight_live_enabled: 'false',
env_override_flight_live_enabled: 'false',
require_mfa: 'false',
});
expect(withEnvLocks({})).toEqual({ flight_live_enabled: 'false', env_override_flight_live_enabled: 'false' });
expect(envLockedKeys({ flight_live_enabled: 'true', require_mfa: 'true' })).toEqual(['flight_live_enabled']);
expect(envLockedKeys({ require_mfa: 'true' })).toEqual([]);
});
});
@@ -156,14 +156,15 @@ describe('getPreferencesMatrix', () => {
expect(event_types).not.toContain('version_available');
// +1 for plugin_notification: users can mute host-mediated plugin notifications.
// +1 for wanderer_source_hint: the hints of the user's own wanderer watch (WAN-02).
expect(event_types.length).toBe(13);
// +3 for flight_departed, flight_delayed and flight_landed (live flight status, on by default).
expect(event_types.length).toBe(16);
});
it('NPREF-005 — user scope excludes version_available for everyone including admins', async () => {
const { user } = createAdmin(testDb);
const { event_types } = await getPreferencesMatrix(user.id, 'admin', 'user');
expect(event_types).not.toContain('version_available');
expect(event_types.length).toBe(13);
expect(event_types.length).toBe(16);
});
it('NPREF-005b — admin scope returns the admin-scoped events', async () => {
@@ -481,4 +482,13 @@ describe('listed events follow the features that are on', () => {
Object.assign(features, all);
}
});
it('NPREF-LIST-002: the flight events are listed only while live flight status is on', async () => {
const flights = ['flight_departed', 'flight_delayed', 'flight_landed'];
const { user } = createUser(testDb);
expect((await svc.getPreferencesMatrix(user.id, 'user')).event_types).toEqual(expect.arrayContaining(flights));
setAppSetting(testDb, 'flight_live_enabled', 'false');
const { event_types } = await svc.getPreferencesMatrix(user.id, 'user');
for (const hidden of flights) expect(event_types).not.toContain(hidden);
});
});
@@ -608,6 +608,30 @@ describe('send() — in-app notification content', () => {
);
expect(notifs.every((n) => n.title_key === 'notif.wanderer_source_hint.title')).toBe(true);
});
it('NOTIF-FLT-001 the three live flight notices lead to their trip, and nowhere without one', async () => {
const { user } = createUser(testDb);
setNotificationChannels(testDb, 'none');
const params = { flight: 'LH 716', from: 'FRA', to: 'HND', trip: 'Japan', delay: '25' };
const events = ['flight_departed', 'flight_delayed', 'flight_landed'] as const;
await events.reduce(async (previous, event) => {
await previous;
await send({ event, actorId: null, scope: 'user', targetId: user.id, params: { ...params, tripId: '7' } });
await send({ event, actorId: null, scope: 'user', targetId: user.id, params });
}, Promise.resolve());
const notifs = await getInAppNotifications(user.id);
expect(notifs.map((n) => [n.title_key, n.text_key, n.navigate_target])).toEqual(
expect.arrayContaining([
['notif.flight_departed.title', 'notif.flight_departed.text', '/trips/7'],
['notif.flight_delayed.title', 'notif.flight_delayed.text', '/trips/7'],
['notif.flight_landed.title', 'notif.flight_landed.text', '/trips/7'],
['notif.flight_departed.title', 'notif.flight_departed.text', null],
['notif.flight_delayed.title', 'notif.flight_delayed.text', null],
['notif.flight_landed.title', 'notif.flight_landed.text', null],
]),
);
});
});
// ─────────────────────────────────────────────────────────────────────────────
+2
View File
@@ -116,6 +116,8 @@ export default defineConfig({
// scripts/coverage-thresholds.mjs after the first full run.
'src/nest/ferratas/**/*.ts': { statements: 80, branches: 80, functions: 80, lines: 80 },
'src/nest/files/**/*.ts': { statements: 97, branches: 95, functions: 99, lines: 98 },
// New with live flight status; at the floor until the first full run regenerates it.
'src/nest/flight-live/**/*.ts': { statements: 80, branches: 80, functions: 80, lines: 80 },
'src/nest/geo/**/*.ts': { statements: 99, branches: 95, functions: 99, lines: 99 },
// google-quota, managed, mcp-shared, mcp-transport, public-api,
// receipt-scan, route-usage, scheduling, school-holidays, tokens and tours
+1 -1
View File
@@ -83,7 +83,7 @@ export const BRAND_NAMES = [
];
/** Unit symbols, short codes rather than words: "{count} km", "{minutes} min". */
export const UNITS = ['km', 'mi', 'ft', 'min'];
export const UNITS = ['km', 'mi', 'ft', 'min', 'mph'];
/**
* Key cap labels, printed on the keyboard in Latin letters whatever the
@@ -0,0 +1,51 @@
import { flightLiveLegSchema, tripFlightLiveResponseSchema } from './flight-live.schema';
import { describe, it, expect } from 'vitest';
const leg = {
reservation_id: 6,
leg_index: 0,
callsign: 'DLH400',
flight_number: 'LH 400',
service_date: '2026-06-11',
origin: { code: 'FRA', name: 'Frankfurt', lat: 50.03, lng: 8.57, timezone: 'Europe/Berlin' },
destination: { code: 'JFK', name: 'New York JFK', lat: 40.64, lng: -73.78, timezone: 'America/New_York' },
phase: 'scheduled',
scheduled_departure: '2026-06-11T11:50:00.000Z',
scheduled_arrival: '2026-06-11T20:35:00.000Z',
departed_at: null,
landed_at: null,
estimated_arrival: null,
departure_delay_min: null,
arrival_delay_min: null,
progress: null,
distance_total_km: 6200,
distance_remaining_km: null,
position: null,
aircraft: null,
last_seen_at: null,
fetched_at: null,
stale: false,
source: null,
};
describe('flightLiveLegSchema', () => {
it('accepts a leg that has not shown up yet', () => {
expect(flightLiveLegSchema.parse(leg).phase).toBe('scheduled');
});
it('rejects a progress outside 0 to 1', () => {
expect(flightLiveLegSchema.safeParse({ ...leg, progress: 1.2 }).success).toBe(false);
});
it('rejects an unknown phase', () => {
expect(flightLiveLegSchema.safeParse({ ...leg, phase: 'boarding' }).success).toBe(false);
});
});
describe('tripFlightLiveResponseSchema', () => {
it('carries the switch, the credit line and the legs', () => {
const parsed = tripFlightLiveResponseSchema.parse({ enabled: true, attribution: 'adsb.lol', flights: [leg] });
expect(parsed.flights).toHaveLength(1);
});
});
@@ -0,0 +1,105 @@
import { z } from 'zod';
/**
* Live state of a booked flight leg, read from open ADS-B feeds without any key
* (adsb.lol, adsb.fi). These feeds only know where an aircraft is, so everything
* an airline would publish (gate, terminal, belt, cancellation) is out of reach;
* the delays and the arrival estimate are worked out from the positions and are
* marked as estimates in the UI.
*
* Times cross the wire as ISO 8601 UTC strings. A leg is addressed by its
* reservation and its index in the leg chain (client/src/utils/flightLegs.ts),
* so a multi-leg booking gets one state per segment.
*/
/**
* - scheduled: departure is still ahead and the aircraft has not shown up.
* - taxiing: the aircraft is seen on the ground at the departure airport.
* - airborne: in the air.
* - landed: seen on the ground at, or lost low over, the destination.
* - unknown: inside the watch window but the callsign never showed up (an
* airline flying under another callsign, a codeshare, no coverage).
*/
export const flightLivePhaseSchema = z.enum(['scheduled', 'taxiing', 'airborne', 'landed', 'unknown']);
export type FlightLivePhase = z.infer<typeof flightLivePhaseSchema>;
export const flightLiveSourceSchema = z.enum(['adsb.lol', 'adsb.fi']);
export type FlightLiveSource = z.infer<typeof flightLiveSourceSchema>;
export const flightLivePositionSchema = z.object({
lat: z.number(),
lng: z.number(),
altitude_ft: z.number().nullable(),
ground_speed_kt: z.number().nullable(),
/** Degrees clockwise from north, for turning the aircraft icon. */
track_deg: z.number().nullable(),
vertical_rate_fpm: z.number().nullable(),
on_ground: z.boolean(),
});
export type FlightLivePosition = z.infer<typeof flightLivePositionSchema>;
export const flightLiveAirportSchema = z.object({
/** IATA code, e.g. "FRA". */
code: z.string(),
name: z.string().nullable(),
lat: z.number(),
lng: z.number(),
/** IANA zone the schedule is local to, e.g. "Europe/Berlin". */
timezone: z.string().nullable(),
});
export type FlightLiveAirport = z.infer<typeof flightLiveAirportSchema>;
export const flightLiveLegSchema = z.object({
reservation_id: z.number().int(),
leg_index: z.number().int().min(0),
/** ICAO callsign as transmitted, e.g. "DLH400". */
callsign: z.string(),
/** The booked flight number as the user knows it, e.g. "LH 400". */
flight_number: z.string(),
/** Local departure date of the leg, YYYY-MM-DD. */
service_date: z.string(),
/** Both ends of the leg, so the client can show local times and draw the arc. */
origin: flightLiveAirportSchema,
destination: flightLiveAirportSchema,
phase: flightLivePhaseSchema,
scheduled_departure: z.string().nullable(),
scheduled_arrival: z.string().nullable(),
/** Observed wheels-up, or null while unknown. */
departed_at: z.string().nullable(),
/** Observed touchdown, or null while unknown. */
landed_at: z.string().nullable(),
/** Arrival estimate from remaining distance and ground speed; null on the ground. */
estimated_arrival: z.string().nullable(),
/** Minutes late (negative when early) against the schedule; null until observed or estimated. */
departure_delay_min: z.number().int().nullable(),
arrival_delay_min: z.number().int().nullable(),
/** Share of the great-circle distance flown, 0 to 1. */
progress: z.number().min(0).max(1).nullable(),
distance_total_km: z.number().nullable(),
distance_remaining_km: z.number().nullable(),
position: flightLivePositionSchema.nullable(),
aircraft: z
.object({
registration: z.string().nullable(),
/** ICAO type designator, e.g. "A20N". */
type: z.string().nullable(),
})
.nullable(),
/** When the feed last heard the aircraft. */
last_seen_at: z.string().nullable(),
/** When TREK last asked the feed. */
fetched_at: z.string().nullable(),
/** True when the last position is too old to trust, e.g. over an ocean. */
stale: z.boolean(),
source: flightLiveSourceSchema.nullable(),
});
export type FlightLiveLeg = z.infer<typeof flightLiveLegSchema>;
export const tripFlightLiveResponseSchema = z.object({
/** False when the admin switched live flight data off; the client then hides it. */
enabled: z.boolean(),
/** Credit line the feed's terms ask for, shown next to live data. */
attribution: z.string().nullable(),
flights: z.array(flightLiveLegSchema),
});
export type TripFlightLiveResponse = z.infer<typeof tripFlightLiveResponseSchema>;
@@ -17,6 +17,18 @@ const ar: NotificationLocale = {
title: `حجز جديد: ${p.booking}`,
body: `${p.actor} أضاف حجز "${p.booking}" (${p.type}) إلى "${p.trip}".`,
}),
flight_departed: (p) => ({
title: `أقلعت الرحلة ${p.flight}`,
body: `الرحلة ${p.flight} من ${p.from} إلى ${p.to} في الجو.`,
}),
flight_delayed: (p) => ({
title: `الرحلة ${p.flight} متأخرة`,
body: `الرحلة ${p.flight} من ${p.from} إلى ${p.to} متأخرة نحو ${p.delay} دقيقة عن الموعد.`,
}),
flight_landed: (p) => ({
title: `هبطت الرحلة ${p.flight}`,
body: `هبطت الرحلة ${p.flight} القادمة من ${p.from} في ${p.to}.`,
}),
trip_reminder: (p) => ({
title: `تذكير: ${p.trip}`,
body: `رحلتك "${p.trip}" تقترب!`,
+6
View File
@@ -9,6 +9,12 @@ const notif: TranslationStrings = {
'notif.trip_invite.text': '{actor} دعاك إلى {trip}',
'notif.booking_change.title': 'تم تحديث الحجز',
'notif.booking_change.text': '{actor} حدّث حجزاً في {trip}',
'notif.flight_departed.title': 'أقلعت الرحلة',
'notif.flight_departed.text': 'الرحلة {flight} من {from} إلى {to} في الجو',
'notif.flight_delayed.title': 'الرحلة متأخرة',
'notif.flight_delayed.text': 'الرحلة {flight} من {from} إلى {to} متأخرة نحو {delay} دقيقة',
'notif.flight_landed.title': 'هبطت الرحلة',
'notif.flight_landed.text': 'هبطت الرحلة {flight} القادمة من {from} في {to}',
'notif.trip_reminder.title': 'تذكير بالرحلة',
'notif.trip_reminder.text': 'رحلتك {trip} تقترب!',
'notif.todo_due.title': 'مهمة مستحقة',
+3
View File
@@ -106,6 +106,9 @@ const settings: TranslationStrings = {
'settings.notifications': 'الإشعارات',
'settings.notifyTripInvite': 'دعوات الرحلات',
'settings.notifyBookingChange': 'تغييرات الحجز',
'settings.notifyFlightDeparted': 'إقلاع الرحلات',
'settings.notifyFlightDelayed': 'تأخر الرحلات',
'settings.notifyFlightLanded': 'هبوط الرحلات',
'settings.notifyTripReminder': 'تذكيرات الرحلات',
'settings.notifyTodoDue': 'مهمة مستحقة',
'settings.notifyVacayInvite': 'دعوات دمج الإجازات',
@@ -17,6 +17,18 @@ const az: NotificationLocale = {
title: `Yeni rezervasiya: ${p.booking}`,
body: `${p.actor} “${p.trip}” səyahətinə yeni ${p.type} rezervasiyası — “${p.booking}” əlavə etdi.`,
}),
flight_departed: (p) => ({
title: `${p.flight} havaya qalxdı`,
body: `${p.from} hava limanından ${p.to} hava limanına ${p.flight} reysi havadadır.`,
}),
flight_delayed: (p) => ({
title: `${p.flight} gecikir`,
body: `${p.from} hava limanından ${p.to} hava limanına ${p.flight} reysi təxminən ${p.delay} dəqiqə gecikir.`,
}),
flight_landed: (p) => ({
title: `${p.flight} endi`,
body: `${p.from} hava limanından gələn ${p.flight} reysi ${p.to} hava limanına endi.`,
}),
trip_reminder: (p) => ({
title: `Səyahət xatırlatması: ${p.trip}`,
body: `“${p.trip}” səyahətiniz yaxınlaşır!`,
+6
View File
@@ -9,6 +9,12 @@ const notif: TranslationStrings = {
'notif.trip_invite.text': '{actor} sizi {trip} səyahətinə dəvət etdi',
'notif.booking_change.title': 'Rezervasiya yeniləndi',
'notif.booking_change.text': '{actor} {trip} səyahətindəki rezervasiyanı yenilədi',
'notif.flight_departed.title': 'Reys havaya qalxdı',
'notif.flight_departed.text': '{from} hava limanından {to} hava limanına {flight} reysi havadadır',
'notif.flight_delayed.title': 'Reys gecikir',
'notif.flight_delayed.text': '{from} hava limanından {to} hava limanına {flight} reysi təxminən {delay} dəq gecikir',
'notif.flight_landed.title': 'Reys endi',
'notif.flight_landed.text': '{from} hava limanından gələn {flight} reysi {to} hava limanına endi',
'notif.trip_reminder.title': 'Səyahət xatırlatması',
'notif.trip_reminder.text': '{trip} səyahətiniz yaxınlaşır!',
'notif.todo_due.title': 'Tapşırığın son tarixi',
+3
View File
@@ -141,6 +141,9 @@ const settings: TranslationStrings = {
'settings.notifications': 'Bildirişlər',
'settings.notifyTripInvite': 'Səyahət dəvətləri',
'settings.notifyBookingChange': 'Rezervasiya dəyişiklikləri',
'settings.notifyFlightDeparted': 'Reyslərin uçuşu',
'settings.notifyFlightDelayed': 'Reyslərin gecikməsi',
'settings.notifyFlightLanded': 'Reyslərin enişi',
'settings.notifyTripReminder': 'Səyahət xatırlatmaları',
'settings.notifyTodoDue': 'Son tarixi yaxınlaşan tapşırıqlar',
'settings.notifyVacayInvite': 'Vacay birləşdirmə dəvətləri',
@@ -17,6 +17,18 @@ const br: NotificationLocale = {
title: `Nova reserva: ${p.booking}`,
body: `${p.actor} adicionou uma reserva "${p.booking}" (${p.type}) em "${p.trip}".`,
}),
flight_departed: (p) => ({
title: `${p.flight} decolou`,
body: `${p.flight} de ${p.from} para ${p.to} está no ar.`,
}),
flight_delayed: (p) => ({
title: `${p.flight} está atrasado`,
body: `${p.flight} de ${p.from} para ${p.to} está cerca de ${p.delay} minutos atrasado.`,
}),
flight_landed: (p) => ({
title: `${p.flight} pousou`,
body: `${p.flight} vindo de ${p.from} pousou em ${p.to}.`,
}),
trip_reminder: (p) => ({
title: `Lembrete: ${p.trip}`,
body: `Sua viagem "${p.trip}" está chegando!`,
+6
View File
@@ -9,6 +9,12 @@ const notif: TranslationStrings = {
'notif.trip_invite.text': '{actor} convidou você para {trip}',
'notif.booking_change.title': 'Reserva atualizada',
'notif.booking_change.text': '{actor} atualizou uma reserva em {trip}',
'notif.flight_departed.title': 'Voo decolou',
'notif.flight_departed.text': '{flight} de {from} para {to} está no ar',
'notif.flight_delayed.title': 'Voo atrasado',
'notif.flight_delayed.text': '{flight} de {from} para {to} está cerca de {delay} min atrasado',
'notif.flight_landed.title': 'Voo pousou',
'notif.flight_landed.text': '{flight} vindo de {from} pousou em {to}',
'notif.trip_reminder.title': 'Lembrete de viagem',
'notif.trip_reminder.text': 'Sua viagem {trip} está chegando!',
'notif.todo_due.title': 'Tarefa com vencimento',
+3
View File
@@ -113,6 +113,9 @@ const settings: TranslationStrings = {
'settings.notifications': 'Notificações',
'settings.notifyTripInvite': 'Convites de viagem',
'settings.notifyBookingChange': 'Alterações de reserva',
'settings.notifyFlightDeparted': 'Decolagens',
'settings.notifyFlightDelayed': 'Atrasos de voos',
'settings.notifyFlightLanded': 'Pousos',
'settings.notifyTripReminder': 'Lembretes de viagem',
'settings.notifyTodoDue': 'Tarefa com vencimento',
'settings.notifyVacayInvite': 'Convites de fusão Vacay',
@@ -17,6 +17,18 @@ const ca: NotificationLocale = {
title: `Reserva nova: ${p.booking}`,
body: `${p.actor} va afegir una reserva "${p.booking}" (${p.type}) a "${p.trip}".`,
}),
flight_departed: (p) => ({
title: `${p.flight} ha enlairat`,
body: `${p.flight} de ${p.from} a ${p.to} és a l'aire.`,
}),
flight_delayed: (p) => ({
title: `${p.flight} va amb retard`,
body: `${p.flight} de ${p.from} a ${p.to} porta uns ${p.delay} minuts de retard.`,
}),
flight_landed: (p) => ({
title: `${p.flight} ha aterrat`,
body: `${p.flight} des de ${p.from} ha aterrat a ${p.to}.`,
}),
trip_reminder: (p) => ({
title: `Recordatori: ${p.trip}`,
body: `El teu viatge "${p.trip}" s'apropa!`,
+6
View File
@@ -9,6 +9,12 @@ const notif: TranslationStrings = {
'notif.trip_invite.text': '{actor} et va convidar a {trip}',
'notif.booking_change.title': 'Reserva actualitzada',
'notif.booking_change.text': '{actor} va actualitzar una reserva a {trip}',
'notif.flight_departed.title': 'Vol enlairat',
'notif.flight_departed.text': "{flight} de {from} a {to} és a l'aire",
'notif.flight_delayed.title': 'Vol amb retard',
'notif.flight_delayed.text': '{flight} de {from} a {to} porta uns {delay} min de retard',
'notif.flight_landed.title': 'Vol aterrat',
'notif.flight_landed.text': '{flight} des de {from} ha aterrat a {to}',
'notif.trip_reminder.title': 'Recordatori del viatge',
'notif.trip_reminder.text': "El teu viatge {trip} s'apropa!",
'notif.todo_due.title': 'Tasca pendent',
+3
View File
@@ -94,6 +94,9 @@ const settings: TranslationStrings = {
'settings.notifications': 'Notificacions',
'settings.notifyTripInvite': 'Invitacions de viatge',
'settings.notifyBookingChange': 'Canvis en reserves',
'settings.notifyFlightDeparted': 'Enlairaments',
'settings.notifyFlightDelayed': 'Retards de vols',
'settings.notifyFlightLanded': 'Aterratges',
'settings.notifyTripReminder': 'Recordatoris de viatge',
'settings.notifyTodoDue': 'Tasca propera',
'settings.notifyVacayInvite': 'Invitacions de fusió Vacay',
@@ -17,6 +17,18 @@ const cs: NotificationLocale = {
title: `Nová rezervace: ${p.booking}`,
body: `${p.actor} přidal rezervaci "${p.booking}" (${p.type}) k "${p.trip}".`,
}),
flight_departed: (p) => ({
title: `${p.flight} odletěl`,
body: `${p.flight} z ${p.from} do ${p.to} je ve vzduchu.`,
}),
flight_delayed: (p) => ({
title: `${p.flight} má zpoždění`,
body: `${p.flight} z ${p.from} do ${p.to} má zhruba ${p.delay} minut zpoždění.`,
}),
flight_landed: (p) => ({
title: `${p.flight} přistál`,
body: `${p.flight} z ${p.from} přistál v ${p.to}.`,
}),
trip_reminder: (p) => ({
title: `Připomínka výletu: ${p.trip}`,
body: `Váš výlet "${p.trip}" se blíží!`,
+6
View File
@@ -9,6 +9,12 @@ const notif: TranslationStrings = {
'notif.trip_invite.text': '{actor} vás pozval na {trip}',
'notif.booking_change.title': 'Rezervace aktualizována',
'notif.booking_change.text': '{actor} aktualizoval rezervaci v {trip}',
'notif.flight_departed.title': 'Let odletěl',
'notif.flight_departed.text': '{flight} z {from} do {to} je ve vzduchu',
'notif.flight_delayed.title': 'Let má zpoždění',
'notif.flight_delayed.text': '{flight} z {from} do {to} má zhruba {delay} min zpoždění',
'notif.flight_landed.title': 'Let přistál',
'notif.flight_landed.text': '{flight} z {from} přistál v {to}',
'notif.trip_reminder.title': 'Připomínka výletu',
'notif.trip_reminder.text': 'Váš výlet {trip} se blíží!',
'notif.todo_due.title': 'Úkol se blíží',
+3
View File
@@ -108,6 +108,9 @@ const settings: TranslationStrings = {
'settings.notifications': 'Oznámení',
'settings.notifyTripInvite': 'Pozvánky na cesty',
'settings.notifyBookingChange': 'Změny rezervací',
'settings.notifyFlightDeparted': 'Odlety',
'settings.notifyFlightDelayed': 'Zpoždění letů',
'settings.notifyFlightLanded': 'Přistání',
'settings.notifyTripReminder': 'Připomínky cest',
'settings.notifyTodoDue': 'Úkol se blíží',
'settings.notifyVacayInvite': 'Pozvánky k propojení Vacay',
@@ -17,6 +17,18 @@ const de: NotificationLocale = {
title: `Neue Buchung: ${p.booking}`,
body: `${p.actor} hat eine neue Buchung "${p.booking}" (${p.type}) zu "${p.trip}" hinzugefügt.`,
}),
flight_departed: (p) => ({
title: `${p.flight} ist gestartet`,
body: `${p.flight} von ${p.from} nach ${p.to} ist in der Luft.`,
}),
flight_delayed: (p) => ({
title: `${p.flight} hat Verspätung`,
body: `${p.flight} von ${p.from} nach ${p.to} liegt etwa ${p.delay} Minuten hinter dem Flugplan.`,
}),
flight_landed: (p) => ({
title: `${p.flight} ist gelandet`,
body: `${p.flight} aus ${p.from} ist in ${p.to} gelandet.`,
}),
trip_reminder: (p) => ({
title: `Reiseerinnerung: ${p.trip}`,
body: `Deine Reise "${p.trip}" steht bald an!`,
+6
View File
@@ -9,6 +9,12 @@ const notif: TranslationStrings = {
'notif.trip_invite.text': '{actor} hat dich zu {trip} eingeladen',
'notif.booking_change.title': 'Buchung aktualisiert',
'notif.booking_change.text': '{actor} hat eine Buchung in {trip} aktualisiert',
'notif.flight_departed.title': 'Flug gestartet',
'notif.flight_departed.text': '{flight} von {from} nach {to} ist in der Luft',
'notif.flight_delayed.title': 'Flug verspätet',
'notif.flight_delayed.text': '{flight} von {from} nach {to} hat etwa {delay} Min. Verspätung',
'notif.flight_landed.title': 'Flug gelandet',
'notif.flight_landed.text': '{flight} aus {from} ist in {to} gelandet',
'notif.trip_reminder.title': 'Reiseerinnerung',
'notif.trip_reminder.text': 'Deine Reise {trip} steht bald an!',
'notif.todo_due.title': 'Aufgabe fällig',
+3
View File
@@ -113,6 +113,9 @@ const settings: TranslationStrings = {
'settings.notifications': 'Mitteilungen',
'settings.notifyTripInvite': 'Trip-Einladungen',
'settings.notifyBookingChange': 'Buchungsänderungen',
'settings.notifyFlightDeparted': 'Abflüge',
'settings.notifyFlightDelayed': 'Flugverspätungen',
'settings.notifyFlightLanded': 'Landungen',
'settings.notifyTripReminder': 'Trip-Erinnerungen',
'settings.notifyTodoDue': 'Aufgabe bald fällig',
'settings.notifyVacayInvite': 'Vacay Fusion-Einladungen',
@@ -17,6 +17,18 @@ const en: NotificationLocale = {
title: `New booking: ${p.booking}`,
body: `${p.actor} added a new ${p.type} "${p.booking}" to "${p.trip}".`,
}),
flight_departed: (p) => ({
title: `${p.flight} departed`,
body: `${p.flight} from ${p.from} to ${p.to} is in the air.`,
}),
flight_delayed: (p) => ({
title: `${p.flight} is running late`,
body: `${p.flight} from ${p.from} to ${p.to} is running about ${p.delay} minutes behind schedule.`,
}),
flight_landed: (p) => ({
title: `${p.flight} has landed`,
body: `${p.flight} from ${p.from} has landed in ${p.to}.`,
}),
trip_reminder: (p) => ({
title: `Trip reminder: ${p.trip}`,
body: `Your trip "${p.trip}" is coming up soon!`,
+6
View File
@@ -9,6 +9,12 @@ const notif: TranslationStrings = {
'notif.trip_invite.text': '{actor} invited you to {trip}',
'notif.booking_change.title': 'Booking Updated',
'notif.booking_change.text': '{actor} updated a booking in {trip}',
'notif.flight_departed.title': 'Flight departed',
'notif.flight_departed.text': '{flight} from {from} to {to} is in the air',
'notif.flight_delayed.title': 'Flight running late',
'notif.flight_delayed.text': '{flight} from {from} to {to} is about {delay} min behind schedule',
'notif.flight_landed.title': 'Flight landed',
'notif.flight_landed.text': '{flight} from {from} has landed in {to}',
'notif.trip_reminder.title': 'Trip Reminder',
'notif.trip_reminder.text': 'Your trip {trip} is coming up soon!',
'notif.todo_due.title': 'To-do due',
+3
View File
@@ -122,6 +122,9 @@ const settings: TranslationStrings = {
'settings.notifications': 'Notifications',
'settings.notifyTripInvite': 'Trip invitations',
'settings.notifyBookingChange': 'Booking changes',
'settings.notifyFlightDeparted': 'Flight departures',
'settings.notifyFlightDelayed': 'Flight delays',
'settings.notifyFlightLanded': 'Flight landings',
'settings.notifyTripReminder': 'Trip reminders',
'settings.notifyTodoDue': 'Todo due soon',
'settings.notifyVacayInvite': 'Vacay fusion invitations',
@@ -17,6 +17,18 @@ const es: NotificationLocale = {
title: `Nueva reserva: ${p.booking}`,
body: `${p.actor} añadió una reserva "${p.booking}" (${p.type}) a "${p.trip}".`,
}),
flight_departed: (p) => ({
title: `${p.flight} ha despegado`,
body: `${p.flight} de ${p.from} a ${p.to} está en el aire.`,
}),
flight_delayed: (p) => ({
title: `${p.flight} va con retraso`,
body: `${p.flight} de ${p.from} a ${p.to} lleva unos ${p.delay} minutos de retraso.`,
}),
flight_landed: (p) => ({
title: `${p.flight} ha aterrizado`,
body: `${p.flight} desde ${p.from} ha aterrizado en ${p.to}.`,
}),
trip_reminder: (p) => ({
title: `Recordatorio: ${p.trip}`,
body: `¡Tu viaje "${p.trip}" se acerca!`,
+6
View File
@@ -9,6 +9,12 @@ const notif: TranslationStrings = {
'notif.trip_invite.text': '{actor} te invitó a {trip}',
'notif.booking_change.title': 'Reserva actualizada',
'notif.booking_change.text': '{actor} actualizó una reserva en {trip}',
'notif.flight_departed.title': 'Vuelo despegado',
'notif.flight_departed.text': '{flight} de {from} a {to} está en el aire',
'notif.flight_delayed.title': 'Vuelo con retraso',
'notif.flight_delayed.text': '{flight} de {from} a {to} lleva unos {delay} min de retraso',
'notif.flight_landed.title': 'Vuelo aterrizado',
'notif.flight_landed.text': '{flight} desde {from} ha aterrizado en {to}',
'notif.trip_reminder.title': 'Recordatorio de viaje',
'notif.trip_reminder.text': '¡Tu viaje {trip} se acerca!',
'notif.todo_due.title': 'Tarea pendiente',
+3
View File
@@ -112,6 +112,9 @@ const settings: TranslationStrings = {
'settings.notifications': 'Notificaciones',
'settings.notifyTripInvite': 'Invitaciones de viaje',
'settings.notifyBookingChange': 'Cambios en reservas',
'settings.notifyFlightDeparted': 'Despegues',
'settings.notifyFlightDelayed': 'Retrasos de vuelos',
'settings.notifyFlightLanded': 'Aterrizajes',
'settings.notifyTripReminder': 'Recordatorios de viaje',
'settings.notifyTodoDue': 'Tarea próxima',
'settings.notifyVacayInvite': 'Invitaciones de fusión Vacay',
@@ -18,6 +18,18 @@ const et: NotificationLocale = {
title: `Uus broneering: ${p.booking}`,
body: `${p.actor} lisas reisile „${p.trip}” uue broneeringu „${p.booking}” (tüüp: ${reservations[`reservations.type.${p.type}`] ?? (p.type === 'booking' ? 'broneering' : p.type)}).`,
}),
flight_departed: (p) => ({
title: `${p.flight} tõusis õhku`,
body: `Lend ${p.flight} lennujaamast ${p.from} lennujaama ${p.to} on õhus.`,
}),
flight_delayed: (p) => ({
title: `${p.flight} hilineb`,
body: `Lend ${p.flight} lennujaamast ${p.from} lennujaama ${p.to} hilineb umbes ${p.delay} minutit.`,
}),
flight_landed: (p) => ({
title: `${p.flight} maandus`,
body: `Lend ${p.flight} lennujaamast ${p.from} maandus lennujaamas ${p.to}.`,
}),
trip_reminder: (p) => ({
title: `Reisi meeldetuletus: ${p.trip}`,
body: `Sinu reis „${p.trip}” algab peagi!`,
+6
View File
@@ -9,6 +9,12 @@ const notif: TranslationStrings = {
'notif.trip_invite.text': '{actor} kutsus sind reisile {trip}',
'notif.booking_change.title': 'Broneering uuendatud',
'notif.booking_change.text': '{actor} uuendas reisi {trip} broneeringut',
'notif.flight_departed.title': 'Lend tõusis õhku',
'notif.flight_departed.text': 'Lend {flight} lennujaamast {from} lennujaama {to} on õhus',
'notif.flight_delayed.title': 'Lend hilineb',
'notif.flight_delayed.text': 'Lend {flight} lennujaamast {from} lennujaama {to} hilineb umbes {delay} min',
'notif.flight_landed.title': 'Lend maandus',
'notif.flight_landed.text': 'Lend {flight} lennujaamast {from} maandus lennujaamas {to}',
'notif.trip_reminder.title': 'Reisimeeldetuletus',
'notif.trip_reminder.text': 'Sinu reis {trip} algab peagi!',
'notif.todo_due.title': 'Ülesande tähtaeg',
+3
View File
@@ -127,6 +127,9 @@ const settings: TranslationStrings = {
'settings.notifications': 'Teavitused',
'settings.notifyTripInvite': 'Reisikutsed',
'settings.notifyBookingChange': 'Broneeringute muudatused',
'settings.notifyFlightDeparted': 'Väljalennud',
'settings.notifyFlightDelayed': 'Lendude hilinemised',
'settings.notifyFlightLanded': 'Maandumised',
'settings.notifyTripReminder': 'Reisimeeldetuletused',
'settings.notifyTodoDue': 'Ülesande tähtaeg läheneb',
'settings.notifyVacayInvite': 'Vacay ühendamiskutsed',
@@ -24,6 +24,9 @@ export interface PasswordResetStrings {
export type NotificationEventKey =
| 'trip_invite'
| 'booking_change'
| 'flight_departed'
| 'flight_delayed'
| 'flight_landed'
| 'trip_reminder'
| 'todo_due'
| 'vacay_invite'
@@ -17,6 +17,18 @@ const fr: NotificationLocale = {
title: `Nouvelle réservation : ${p.booking}`,
body: `${p.actor} a ajouté une réservation "${p.booking}" (${p.type}) à "${p.trip}".`,
}),
flight_departed: (p) => ({
title: `${p.flight} a décollé`,
body: `${p.flight} de ${p.from} à ${p.to} est en vol.`,
}),
flight_delayed: (p) => ({
title: `${p.flight} est en retard`,
body: `${p.flight} de ${p.from} à ${p.to} a environ ${p.delay} minutes de retard.`,
}),
flight_landed: (p) => ({
title: `${p.flight} a atterri`,
body: `${p.flight} en provenance de ${p.from} a atterri à ${p.to}.`,
}),
trip_reminder: (p) => ({
title: `Rappel de voyage : ${p.trip}`,
body: `Votre voyage "${p.trip}" approche !`,
+6
View File
@@ -9,6 +9,12 @@ const notif: TranslationStrings = {
'notif.trip_invite.text': '{actor} vous a invité à {trip}',
'notif.booking_change.title': 'Réservation mise à jour',
'notif.booking_change.text': '{actor} a mis à jour une réservation dans {trip}',
'notif.flight_departed.title': 'Vol parti',
'notif.flight_departed.text': '{flight} de {from} à {to} est en vol',
'notif.flight_delayed.title': 'Vol en retard',
'notif.flight_delayed.text': '{flight} de {from} à {to} a environ {delay} min de retard',
'notif.flight_landed.title': 'Vol arrivé',
'notif.flight_landed.text': '{flight} en provenance de {from} a atterri à {to}',
'notif.trip_reminder.title': 'Rappel de voyage',
'notif.trip_reminder.text': 'Votre voyage {trip} approche !',
'notif.todo_due.title': 'Tâche à échéance',
+3
View File
@@ -114,6 +114,9 @@ const settings: TranslationStrings = {
'settings.notifications': 'Notifications', // same-as-en
'settings.notifyTripInvite': 'Invitations de voyage',
'settings.notifyBookingChange': 'Modifications de réservation',
'settings.notifyFlightDeparted': 'Décollages',
'settings.notifyFlightDelayed': 'Retards de vol',
'settings.notifyFlightLanded': 'Atterrissages',
'settings.notifyTripReminder': 'Rappels de voyage',
'settings.notifyTodoDue': 'Tâche à échéance',
'settings.notifyVacayInvite': 'Invitations de fusion Vacay',
@@ -17,6 +17,18 @@ const gr: NotificationLocale = {
title: `Νέα κράτηση: ${p.booking}`,
body: `Ο/Η ${p.actor} πρόσθεσε μια νέα κράτηση "${p.booking}" (${p.type}) στο "${p.trip}".`,
}),
flight_departed: (p) => ({
title: `Η πτήση ${p.flight} αναχώρησε`,
body: `Η πτήση ${p.flight} από ${p.from} προς ${p.to} είναι στον αέρα.`,
}),
flight_delayed: (p) => ({
title: `Η πτήση ${p.flight} έχει καθυστέρηση`,
body: `Η πτήση ${p.flight} από ${p.from} προς ${p.to} έχει καθυστέρηση περίπου ${p.delay} λεπτών.`,
}),
flight_landed: (p) => ({
title: `Η πτήση ${p.flight} προσγειώθηκε`,
body: `Η πτήση ${p.flight} από ${p.from} προσγειώθηκε στο ${p.to}.`,
}),
trip_reminder: (p) => ({
title: `Υπενθύμιση ταξιδιού: ${p.trip}`,
body: `Το ταξίδι σας "${p.trip}" πλησιάζει!`,
+6
View File
@@ -9,6 +9,12 @@ const notif: TranslationStrings = {
'notif.trip_invite.text': 'Ο/Η {actor} σας προσκάλεσε στο {trip}',
'notif.booking_change.title': 'Η Κράτηση Ενημερώθηκε',
'notif.booking_change.text': 'Ο/Η {actor} ενημέρωσε μια κράτηση στο {trip}',
'notif.flight_departed.title': 'Η πτήση αναχώρησε',
'notif.flight_departed.text': 'Η πτήση {flight} από {from} προς {to} είναι στον αέρα',
'notif.flight_delayed.title': 'Καθυστέρηση πτήσης',
'notif.flight_delayed.text': 'Η πτήση {flight} από {from} προς {to} έχει καθυστέρηση περίπου {delay} λεπτών',
'notif.flight_landed.title': 'Η πτήση προσγειώθηκε',
'notif.flight_landed.text': 'Η πτήση {flight} από {from} προσγειώθηκε στο {to}',
'notif.trip_reminder.title': 'Υπενθύμιση Ταξιδιού',
'notif.trip_reminder.text': 'Το ταξίδι σας {trip} πλησιάζει!',
'notif.todo_due.title': 'Εργασία προς λήξη',
+3
View File
@@ -119,6 +119,9 @@ const settings: TranslationStrings = {
'settings.notifications': 'Ειδοποιήσεις',
'settings.notifyTripInvite': 'Προσκλήσεις ταξιδιού',
'settings.notifyBookingChange': 'Αλλαγές κρατήσεων',
'settings.notifyFlightDeparted': 'Αναχωρήσεις πτήσεων',
'settings.notifyFlightDelayed': 'Καθυστερήσεις πτήσεων',
'settings.notifyFlightLanded': 'Προσγειώσεις πτήσεων',
'settings.notifyTripReminder': 'Υπενθυμίσεις ταξιδιού',
'settings.notifyTodoDue': 'Εκκρεμότητα λήγει σύντομα',
'settings.notifyVacayInvite': 'Προσκλήσεις Vacay fusion',
@@ -17,6 +17,18 @@ const he: NotificationLocale = {
title: `הזמנה חדשה: ${p.booking}`,
body: `${p.actor} הוסיף/ה ${p.type} חדש/ה "${p.booking}" אל "${p.trip}".`,
}),
flight_departed: (p) => ({
title: `טיסה ${p.flight} המריאה`,
body: `טיסה ${p.flight} מ-${p.from} ל-${p.to} באוויר.`,
}),
flight_delayed: (p) => ({
title: `טיסה ${p.flight} מתעכבת`,
body: `טיסה ${p.flight} מ-${p.from} ל-${p.to} מאחרת בכ-${p.delay} דקות.`,
}),
flight_landed: (p) => ({
title: `טיסה ${p.flight} נחתה`,
body: `טיסה ${p.flight} מ-${p.from} נחתה ב-${p.to}.`,
}),
trip_reminder: (p) => ({
title: `תזכורת לטיול: ${p.trip}`,
body: `הטיול "${p.trip}" מתקרב!`,
+6
View File
@@ -9,6 +9,12 @@ const notif: TranslationStrings = {
'notif.trip_invite.text': '{actor} הזמין/ה אתכם אל {trip}',
'notif.booking_change.title': 'הזמנה עודכנה',
'notif.booking_change.text': '{actor} עדכן/ה הזמנה ב-{trip}',
'notif.flight_departed.title': 'הטיסה המריאה',
'notif.flight_departed.text': 'טיסה {flight} מ-{from} ל-{to} באוויר',
'notif.flight_delayed.title': 'הטיסה מתעכבת',
'notif.flight_delayed.text': 'טיסה {flight} מ-{from} ל-{to} מאחרת בכ-{delay} דקות',
'notif.flight_landed.title': 'הטיסה נחתה',
'notif.flight_landed.text': 'טיסה {flight} מ-{from} נחתה ב-{to}',
'notif.trip_reminder.title': 'תזכורת לטיול',
'notif.trip_reminder.text': 'הטיול {trip} מתקרב!',
'notif.todo_due.title': 'משימה לביצוע',
+3
View File
@@ -120,6 +120,9 @@ const settings: TranslationStrings = {
'settings.notifications': 'התראות',
'settings.notifyTripInvite': 'הזמנות לטיולים',
'settings.notifyBookingChange': 'שינויים בהזמנות',
'settings.notifyFlightDeparted': 'המראות',
'settings.notifyFlightDelayed': 'עיכובי טיסות',
'settings.notifyFlightLanded': 'נחיתות',
'settings.notifyTripReminder': 'תזכורות לטיולים',
'settings.notifyTodoDue': 'משימות שמועדן מתקרב',
'settings.notifyVacayInvite': 'הזמנות לאיחוד חופשות',
@@ -17,6 +17,18 @@ const hu: NotificationLocale = {
title: `Új foglalás: ${p.booking}`,
body: `${p.actor} hozzáadott egy "${p.booking}" (${p.type}) foglalást a(z) "${p.trip}" utazáshoz.`,
}),
flight_departed: (p) => ({
title: `A(z) ${p.flight} felszállt`,
body: `A(z) ${p.flight} járat ${p.from} és ${p.to} között a levegőben van.`,
}),
flight_delayed: (p) => ({
title: `A(z) ${p.flight} késik`,
body: `A(z) ${p.flight} járat ${p.from} és ${p.to} között körülbelül ${p.delay} percet késik.`,
}),
flight_landed: (p) => ({
title: `A(z) ${p.flight} leszállt`,
body: `A(z) ${p.flight} járat ${p.from} felől leszállt itt: ${p.to}.`,
}),
trip_reminder: (p) => ({
title: `Utazás emlékeztető: ${p.trip}`,
body: `A(z) "${p.trip}" utazás hamarosan kezdődik!`,
+6
View File
@@ -9,6 +9,12 @@ const notif: TranslationStrings = {
'notif.trip_invite.text': '{actor} meghívott a(z) {trip} utazásra',
'notif.booking_change.title': 'Foglalás frissítve',
'notif.booking_change.text': '{actor} frissített egy foglalást a(z) {trip} utazásban',
'notif.flight_departed.title': 'Járat felszállt',
'notif.flight_departed.text': 'A(z) {flight} járat {from} és {to} között a levegőben van',
'notif.flight_delayed.title': 'Járat késik',
'notif.flight_delayed.text': 'A(z) {flight} járat {from} és {to} között körülbelül {delay} percet késik',
'notif.flight_landed.title': 'Járat leszállt',
'notif.flight_landed.text': 'A(z) {flight} járat {from} felől leszállt itt: {to}',
'notif.trip_reminder.title': 'Utazás emlékeztető',
'notif.trip_reminder.text': 'A(z) {trip} utazás hamarosan kezdődik!',
'notif.todo_due.title': 'Teendő esedékes',
+3
View File
@@ -112,6 +112,9 @@ const settings: TranslationStrings = {
'settings.notifications': 'Értesítések',
'settings.notifyTripInvite': 'Utazási meghívók',
'settings.notifyBookingChange': 'Foglalási változások',
'settings.notifyFlightDeparted': 'Felszállások',
'settings.notifyFlightDelayed': 'Járatkésések',
'settings.notifyFlightLanded': 'Leszállások',
'settings.notifyTripReminder': 'Utazási emlékeztetők',
'settings.notifyTodoDue': 'Teendő esedékes',
'settings.notifyVacayInvite': 'Vacay összevonási meghívók',
@@ -16,6 +16,18 @@ const id: NotificationLocale = {
title: `Pemesanan baru: ${p.booking}`,
body: `${p.actor} menambahkan "${p.booking}" (${p.type}) baru ke "${p.trip}".`,
}),
flight_departed: (p) => ({
title: `${p.flight} telah lepas landas`,
body: `${p.flight} dari ${p.from} ke ${p.to} sedang mengudara.`,
}),
flight_delayed: (p) => ({
title: `${p.flight} terlambat`,
body: `${p.flight} dari ${p.from} ke ${p.to} terlambat sekitar ${p.delay} menit dari jadwal.`,
}),
flight_landed: (p) => ({
title: `${p.flight} telah mendarat`,
body: `${p.flight} dari ${p.from} telah mendarat di ${p.to}.`,
}),
trip_reminder: (p) => ({
title: `Pengingat perjalanan: ${p.trip}`,
body: `Perjalanan Anda "${p.trip}" akan segera tiba!`,
+6
View File
@@ -9,6 +9,12 @@ const notif: TranslationStrings = {
'notif.trip_invite.text': '{actor} mengundangmu ke {trip}',
'notif.booking_change.title': 'Pemesanan Diperbarui',
'notif.booking_change.text': '{actor} memperbarui pemesanan di {trip}',
'notif.flight_departed.title': 'Penerbangan lepas landas',
'notif.flight_departed.text': '{flight} dari {from} ke {to} sedang mengudara',
'notif.flight_delayed.title': 'Penerbangan terlambat',
'notif.flight_delayed.text': '{flight} dari {from} ke {to} terlambat sekitar {delay} menit',
'notif.flight_landed.title': 'Penerbangan mendarat',
'notif.flight_landed.text': '{flight} dari {from} telah mendarat di {to}',
'notif.trip_reminder.title': 'Pengingat Perjalanan',
'notif.trip_reminder.text': 'Perjalananmu {trip} akan segera dimulai!',
'notif.todo_due.title': 'Tugas jatuh tempo',
+3
View File
@@ -109,6 +109,9 @@ const settings: TranslationStrings = {
'settings.notifications': 'Notifikasi',
'settings.notifyTripInvite': 'Undangan perjalanan',
'settings.notifyBookingChange': 'Perubahan pemesanan',
'settings.notifyFlightDeparted': 'Penerbangan lepas landas',
'settings.notifyFlightDelayed': 'Penerbangan terlambat',
'settings.notifyFlightLanded': 'Penerbangan mendarat',
'settings.notifyTripReminder': 'Pengingat perjalanan',
'settings.notifyTodoDue': 'Tugas jatuh tempo',
'settings.notifyVacayInvite': 'Undangan Vacay fusion',
@@ -17,6 +17,18 @@ const it: NotificationLocale = {
title: `Nuova prenotazione: ${p.booking}`,
body: `${p.actor} ha aggiunto una prenotazione "${p.booking}" (${p.type}) a "${p.trip}".`,
}),
flight_departed: (p) => ({
title: `${p.flight} è decollato`,
body: `${p.flight} da ${p.from} a ${p.to} è in volo.`,
}),
flight_delayed: (p) => ({
title: `${p.flight} è in ritardo`,
body: `${p.flight} da ${p.from} a ${p.to} ha circa ${p.delay} minuti di ritardo.`,
}),
flight_landed: (p) => ({
title: `${p.flight} è atterrato`,
body: `${p.flight} da ${p.from} è atterrato a ${p.to}.`,
}),
trip_reminder: (p) => ({
title: `Promemoria viaggio: ${p.trip}`,
body: `Il tuo viaggio "${p.trip}" si avvicina!`,
+6
View File
@@ -9,6 +9,12 @@ const notif: TranslationStrings = {
'notif.trip_invite.text': '{actor} ti ha invitato a {trip}',
'notif.booking_change.title': 'Prenotazione aggiornata',
'notif.booking_change.text': '{actor} ha aggiornato una prenotazione in {trip}',
'notif.flight_departed.title': 'Volo decollato',
'notif.flight_departed.text': '{flight} da {from} a {to} è in volo',
'notif.flight_delayed.title': 'Volo in ritardo',
'notif.flight_delayed.text': '{flight} da {from} a {to} ha circa {delay} min di ritardo',
'notif.flight_landed.title': 'Volo atterrato',
'notif.flight_landed.text': '{flight} da {from} è atterrato a {to}',
'notif.trip_reminder.title': 'Promemoria viaggio',
'notif.trip_reminder.text': 'Il tuo viaggio {trip} si avvicina!',
'notif.todo_due.title': 'Attività in scadenza',
+3
View File
@@ -111,6 +111,9 @@ const settings: TranslationStrings = {
'settings.notifications': 'Notifiche',
'settings.notifyTripInvite': 'Inviti di viaggio',
'settings.notifyBookingChange': 'Modifiche alle prenotazioni',
'settings.notifyFlightDeparted': 'Decolli',
'settings.notifyFlightDelayed': 'Ritardi dei voli',
'settings.notifyFlightLanded': 'Atterraggi',
'settings.notifyTripReminder': 'Promemoria di viaggio',
'settings.notifyTodoDue': 'Attività in scadenza',
'settings.notifyVacayInvite': 'Inviti fusione Vacay',

Some files were not shown because too many files have changed in this diff Show More