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 { useCalendarDays } from "../hooks/useCalendarDays.js";
|
||||||
import { buildOnlineBoardUrl } from "../url.js";
|
import { buildOnlineBoardUrl } from "../url.js";
|
||||||
import { buildFlightListJsonLd } from "../json-ld.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 { OnlineBoardParams } from "../url.js";
|
||||||
import type { SearchFlightsParams, CalendarParams } from "../api.js";
|
import type { SearchFlightsParams, CalendarParams } from "../api.js";
|
||||||
import type { FlightRequestType, ISimpleFlight } from "../types.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
|
// 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
|
// Port of Angular's findClosestFlight — on today's search, picks the
|
||||||
// flight with the smallest abs time-diff from 'now' (expands + scrolls
|
// 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