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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
Reference in New Issue
Block a user