Implement Online-Board flight-list default sort per TZ §4.1.13.2

Departure/route/flight-number modes sort by scheduled departure time;
arrival mode sorts by scheduled arrival time (last leg for MultiLeg).
Day ordering (yesterday < today < tomorrow) emerges from absolute ISO
timestamps — no bespoke bucketing needed. Flights missing a timestamp
are pushed to the end. 18 unit tests lock the contract in.
This commit is contained in:
2026-04-21 22:48:39 +03:00
parent 439624244d
commit 8b0d559df9
3 changed files with 338 additions and 1 deletions
@@ -35,6 +35,8 @@ import { useLiveBoardSearch } from "../hooks/useLiveBoardSearch.js";
import { useCalendarDays } from "../hooks/useCalendarDays.js";
import { buildOnlineBoardUrl } from "../url.js";
import { buildFlightListJsonLd } from "../json-ld.js";
import { sortFlights } from "../sortFlights.js";
import type { SortMode } from "../sortFlights.js";
import type { OnlineBoardParams } from "../url.js";
import type { SearchFlightsParams, CalendarParams } from "../api.js";
import type { FlightRequestType, ISimpleFlight } from "../types.js";
@@ -392,7 +394,16 @@ export const OnlineBoardSearchPage: FC<OnlineBoardSearchPageProps> = ({
);
// Use live flights when connected, otherwise fetched flights
const displayFlights = connectionStatus === "live" ? liveFlights : flights;
const rawFlights = connectionStatus === "live" ? liveFlights : flights;
// §4.1.13.2 — default sort: departure modes by dep time, arrival by arr time.
// Day ordering (yesterday < today < tomorrow) emerges from absolute timestamps.
const sortMode: SortMode =
params.type === "arrival" ? "arrival"
: params.type === "flight" ? "flight-number"
: params.type === "route" ? "route"
: "departure";
const displayFlights = sortFlights(rawFlights, sortMode);
// Port of Angular's findClosestFlight — on today's search, picks the
// flight with the smallest abs time-diff from 'now' (expands + scrolls
@@ -0,0 +1,241 @@
/**
* Unit tests for Online-Board flight-list sort per TZ §4.1.13.2.
*/
import { describe, expect, it } from "vitest";
import { sortFlights, compareFlightsForMode } from "./sortFlights.js";
import type { ISimpleFlight } from "./types.js";
// ---------------------------------------------------------------------------
// Minimal flight factory helpers
// ---------------------------------------------------------------------------
function makeDirectFlight(
id: string,
depIso: string | null,
arrIso: string | null,
): ISimpleFlight {
return {
id,
routeType: "Direct",
flightId: { carrier: "SU", date: "20260421", flightNumber: "0001", suffix: "" },
flyingTime: "PT2H",
operatingBy: {},
status: "Scheduled",
leg: {
index: 0,
dayChange: 0,
status: "Scheduled",
updated: "",
flyingTime: "PT2H",
operatingBy: {},
flags: { checkinAvailable: false, returnToAirport: false, routeChanged: false },
departure: {
checkingStatus: "",
scheduled: { airport: "A", airportCode: "AAA", city: "A", cityCode: "AAA", countryCode: "RU" },
times: {
scheduledDeparture: depIso
? { dayChange: { value: 0, title: "" }, local: depIso, localTime: "", tzOffset: 0, utc: "" }
: (undefined as never),
},
},
arrival: {
scheduled: { airport: "B", airportCode: "BBB", city: "B", cityCode: "BBB", countryCode: "RU" },
times: {
scheduledArrival: arrIso
? { dayChange: { value: 0, title: "" }, local: arrIso, localTime: "", tzOffset: 0, utc: "" }
: (undefined as never),
},
},
equipment: {},
},
} as unknown as ISimpleFlight;
}
function makeMultiLegFlight(
id: string,
firstDepIso: string | null,
lastArrIso: string | null,
): ISimpleFlight {
return {
id,
routeType: "MultiLeg",
flightId: { carrier: "SU", date: "20260421", flightNumber: "0001", suffix: "" },
flyingTime: "PT6H",
operatingBy: {},
status: "Scheduled",
legs: [
{
index: 0,
dayChange: 0,
status: "Scheduled",
updated: "",
flyingTime: "PT3H",
operatingBy: {},
flags: { checkinAvailable: false, returnToAirport: false, routeChanged: false },
departure: {
checkingStatus: "",
scheduled: { airport: "A", airportCode: "AAA", city: "A", cityCode: "AAA", countryCode: "RU" },
times: {
scheduledDeparture: firstDepIso
? { dayChange: { value: 0, title: "" }, local: firstDepIso, localTime: "", tzOffset: 0, utc: "" }
: (undefined as never),
},
},
arrival: {
scheduled: { airport: "C", airportCode: "CCC", city: "C", cityCode: "CCC", countryCode: "RU" },
times: {
scheduledArrival: { dayChange: { value: 0, title: "" }, local: "2026-04-21T15:00:00", localTime: "", tzOffset: 0, utc: "" },
},
},
equipment: {},
},
{
index: 1,
dayChange: 0,
status: "Scheduled",
updated: "",
flyingTime: "PT3H",
operatingBy: {},
flags: { checkinAvailable: false, returnToAirport: false, routeChanged: false },
departure: {
checkingStatus: "",
scheduled: { airport: "C", airportCode: "CCC", city: "C", cityCode: "CCC", countryCode: "RU" },
times: {
scheduledDeparture: { dayChange: { value: 0, title: "" }, local: "2026-04-21T16:00:00", localTime: "", tzOffset: 0, utc: "" },
},
},
arrival: {
scheduled: { airport: "B", airportCode: "BBB", city: "B", cityCode: "BBB", countryCode: "RU" },
times: {
scheduledArrival: lastArrIso
? { dayChange: { value: 0, title: "" }, local: lastArrIso, localTime: "", tzOffset: 0, utc: "" }
: (undefined as never),
},
},
equipment: {},
},
],
} as unknown as ISimpleFlight;
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("4.1.13.2-R: sortFlights — departure modes", () => {
const modes = ["flight-number", "route", "departure"] as const;
for (const mode of modes) {
it(`${mode} mode: sorts by departure time ascending`, () => {
const f1 = makeDirectFlight("f1", "2026-04-21T14:00:00", "2026-04-21T16:00:00");
const f2 = makeDirectFlight("f2", "2026-04-21T08:00:00", "2026-04-21T10:00:00");
const f3 = makeDirectFlight("f3", "2026-04-21T11:00:00", "2026-04-21T13:00:00");
const sorted = sortFlights([f1, f2, f3], mode);
expect(sorted.map((f) => f.id)).toEqual(["f2", "f3", "f1"]);
});
it(`${mode} mode: already-sorted input stays sorted`, () => {
const f1 = makeDirectFlight("f1", "2026-04-21T06:00:00", null);
const f2 = makeDirectFlight("f2", "2026-04-21T12:00:00", null);
const sorted = sortFlights([f1, f2], mode);
expect(sorted.map((f) => f.id)).toEqual(["f1", "f2"]);
});
}
});
describe("4.1.13.2-R: sortFlights — arrival mode", () => {
it("sorts by arrival time ascending", () => {
const f1 = makeDirectFlight("f1", "2026-04-21T06:00:00", "2026-04-21T18:00:00");
const f2 = makeDirectFlight("f2", "2026-04-21T08:00:00", "2026-04-21T10:00:00");
const f3 = makeDirectFlight("f3", "2026-04-21T07:00:00", "2026-04-21T14:00:00");
const sorted = sortFlights([f1, f2, f3], "arrival");
expect(sorted.map((f) => f.id)).toEqual(["f2", "f3", "f1"]);
});
it("uses last leg arrival for MultiLeg flights", () => {
// f1: last leg arrives 20:00; f2: last leg arrives 09:00
const f1 = makeMultiLegFlight("f1", "2026-04-21T06:00:00", "2026-04-21T20:00:00");
const f2 = makeMultiLegFlight("f2", "2026-04-21T01:00:00", "2026-04-21T09:00:00");
const sorted = sortFlights([f1, f2], "arrival");
expect(sorted.map((f) => f.id)).toEqual(["f2", "f1"]);
});
});
describe("4.1.13.2-R: sortFlights — day ordering", () => {
it("yesterday < today < tomorrow (emerges from absolute timestamps)", () => {
const yesterday = makeDirectFlight("yesterday", "2026-04-20T22:00:00", null);
const today = makeDirectFlight("today", "2026-04-21T06:00:00", null);
const tomorrow = makeDirectFlight("tomorrow", "2026-04-22T02:00:00", null);
// Supply in scrambled order
const sorted = sortFlights([tomorrow, today, yesterday], "departure");
expect(sorted.map((f) => f.id)).toEqual(["yesterday", "today", "tomorrow"]);
});
it("same-day flights with different times sort by time", () => {
const f1 = makeDirectFlight("early", "2026-04-21T05:30:00", null);
const f2 = makeDirectFlight("late", "2026-04-21T23:45:00", null);
const sorted = sortFlights([f2, f1], "departure");
expect(sorted.map((f) => f.id)).toEqual(["early", "late"]);
});
});
describe("4.1.13.2-R: sortFlights — MultiLeg uses first leg departure", () => {
it("uses first leg departure for non-arrival modes", () => {
// f1 first leg departs 14:00; f2 first leg departs 08:00
const f1 = makeMultiLegFlight("f1", "2026-04-21T14:00:00", "2026-04-21T22:00:00");
const f2 = makeMultiLegFlight("f2", "2026-04-21T08:00:00", "2026-04-21T16:00:00");
const sorted = sortFlights([f1, f2], "route");
expect(sorted.map((f) => f.id)).toEqual(["f2", "f1"]);
});
});
describe("4.1.13.2-R: sortFlights — missing timestamps", () => {
it("pushes flights with null departure time to the end", () => {
const good = makeDirectFlight("good", "2026-04-21T10:00:00", null);
const missing = makeDirectFlight("missing", null, null);
const sorted = sortFlights([missing, good], "departure");
expect(sorted.map((f) => f.id)).toEqual(["good", "missing"]);
});
it("two flights with null times keep relative order stable (both pushed to end)", () => {
const m1 = makeDirectFlight("m1", null, null);
const m2 = makeDirectFlight("m2", null, null);
const good = makeDirectFlight("good", "2026-04-21T10:00:00", null);
const sorted = sortFlights([m1, m2, good], "departure");
// good must be first; m1 and m2 after
expect(sorted[0]?.id).toBe("good");
expect(new Set([sorted[1]?.id, sorted[2]?.id])).toEqual(new Set(["m1", "m2"]));
});
it("pushes flights with null arrival time to the end in arrival mode", () => {
const good = makeDirectFlight("good", null, "2026-04-21T12:00:00");
const missing = makeDirectFlight("missing", null, null);
const sorted = sortFlights([missing, good], "arrival");
expect(sorted.map((f) => f.id)).toEqual(["good", "missing"]);
});
});
describe("4.1.13.2-R: sortFlights — edge cases", () => {
it("returns empty array when given empty array", () => {
expect(sortFlights([], "departure")).toEqual([]);
});
it("single flight is unchanged", () => {
const f = makeDirectFlight("only", "2026-04-21T12:00:00", null);
expect(sortFlights([f], "departure").map((x) => x.id)).toEqual(["only"]);
});
it("does not mutate the input array", () => {
const f1 = makeDirectFlight("f1", "2026-04-21T14:00:00", null);
const f2 = makeDirectFlight("f2", "2026-04-21T08:00:00", null);
const input = [f1, f2];
sortFlights(input, "departure");
expect(input[0]?.id).toBe("f1"); // original untouched
});
it("compareFlightsForMode returns 0 for two flights with same timestamp", () => {
const f1 = makeDirectFlight("f1", "2026-04-21T10:00:00", null);
const f2 = makeDirectFlight("f2", "2026-04-21T10:00:00", null);
expect(compareFlightsForMode("departure")(f1, f2)).toBe(0);
});
});
+85
View File
@@ -0,0 +1,85 @@
/**
* Default sort for Online-Board results per TZ §4.1.13.2.
*
* Rules:
* - Flight-number / Route / Departure modes: sort by departure time
* (first leg for MultiLeg), ascending.
* - Arrival mode: sort by arrival time (last leg for MultiLeg), ascending.
*
* Day-ordering (yesterday < today < tomorrow) emerges naturally because the
* API returns absolute ISO timestamps (`ITimesSet.local` is a local ISO
* string) and comparing their millisecond values directly gives the correct
* ordering across midnight boundaries.
*
* Flights whose relevant timestamp is missing (null / undefined) are pushed
* to the end of the list.
*
* @module
*/
import type { ISimpleFlight } from "./types.js";
/** Which time field drives the sort. */
export type SortMode = "flight-number" | "route" | "departure" | "arrival";
/**
* Resolve the ISO timestamp string used for sorting a single flight.
*
* - Departure modes: first leg's `scheduledDeparture.local`.
* - Arrival mode: last leg's `scheduledArrival.local`.
*
* Returns `null` when the timestamp is absent.
*/
function resolveIso(flight: ISimpleFlight, useArrival: boolean): string | null {
if (flight.routeType === "MultiLeg") {
const legs = flight.legs;
const leg = useArrival
? legs[legs.length - 1]
: legs[0];
if (!leg) return null;
if (useArrival) {
return leg.arrival?.times?.scheduledArrival?.local ?? null;
}
return leg.departure?.times?.scheduledDeparture?.local ?? null;
}
// Direct flight
const leg = flight.leg;
if (useArrival) {
return leg?.arrival?.times?.scheduledArrival?.local ?? null;
}
return leg?.departure?.times?.scheduledDeparture?.local ?? null;
}
/**
* Returns a comparator for `Array.sort` that orders flights per §4.1.13.2.
*/
export function compareFlightsForMode(
mode: SortMode,
): (a: ISimpleFlight, b: ISimpleFlight) => number {
const useArrival = mode === "arrival";
return (a, b) => {
const isoA = resolveIso(a, useArrival);
const isoB = resolveIso(b, useArrival);
if (isoA === null && isoB === null) return 0;
if (isoA === null) return 1; // push missing to end
if (isoB === null) return -1;
const tA = Date.parse(isoA);
const tB = Date.parse(isoB);
if (Number.isNaN(tA) && Number.isNaN(tB)) return 0;
if (Number.isNaN(tA)) return 1;
if (Number.isNaN(tB)) return -1;
return tA - tB;
};
}
/**
* Return a sorted copy of `flights` per TZ §4.1.13.2.
* Does not mutate the input array.
*/
export function sortFlights(
flights: ISimpleFlight[],
mode: SortMode,
): ISimpleFlight[] {
return [...flights].sort(compareFlightsForMode(mode));
}