From 8b0d559df96f394a1b35cb37af80b3ff2af11422 Mon Sep 17 00:00:00 2001 From: gnezim Date: Tue, 21 Apr 2026 22:48:39 +0300 Subject: [PATCH] =?UTF-8?q?Implement=20Online-Board=20flight-list=20defaul?= =?UTF-8?q?t=20sort=20per=20TZ=20=C2=A74.1.13.2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../components/OnlineBoardSearchPage.tsx | 13 +- src/features/online-board/sortFlights.test.ts | 241 ++++++++++++++++++ src/features/online-board/sortFlights.ts | 85 ++++++ 3 files changed, 338 insertions(+), 1 deletion(-) create mode 100644 src/features/online-board/sortFlights.test.ts create mode 100644 src/features/online-board/sortFlights.ts diff --git a/src/features/online-board/components/OnlineBoardSearchPage.tsx b/src/features/online-board/components/OnlineBoardSearchPage.tsx index 3460c080..c01e3c5c 100644 --- a/src/features/online-board/components/OnlineBoardSearchPage.tsx +++ b/src/features/online-board/components/OnlineBoardSearchPage.tsx @@ -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 = ({ ); // 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 diff --git a/src/features/online-board/sortFlights.test.ts b/src/features/online-board/sortFlights.test.ts new file mode 100644 index 00000000..b58326d7 --- /dev/null +++ b/src/features/online-board/sortFlights.test.ts @@ -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); + }); +}); diff --git a/src/features/online-board/sortFlights.ts b/src/features/online-board/sortFlights.ts new file mode 100644 index 00000000..70e22513 --- /dev/null +++ b/src/features/online-board/sortFlights.ts @@ -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)); +}