From 12807cf085c2dee5c28f3c96c25b9fefb6ebbad3 Mon Sep 17 00:00:00 2001 From: gnezim Date: Tue, 21 Apr 2026 17:42:54 +0300 Subject: [PATCH] Extend detailsRequestParam codec for area:schedule (one-way + round-trip + connections) --- src/shared/detailsRequestParam.test.ts | 349 +++++++++++++++++++++++++ src/shared/detailsRequestParam.ts | 195 +++++++++++++- 2 files changed, 538 insertions(+), 6 deletions(-) diff --git a/src/shared/detailsRequestParam.test.ts b/src/shared/detailsRequestParam.test.ts index d28bd9f1..47b93145 100644 --- a/src/shared/detailsRequestParam.test.ts +++ b/src/shared/detailsRequestParam.test.ts @@ -160,3 +160,352 @@ describe("detailsRequestParam — roundtrip", () => { expect(decoded).toEqual(input); }); }); + +// --------------------------------------------------------------------------- +// Schedule area +// --------------------------------------------------------------------------- + +describe("detailsRequestParam — schedule area — build", () => { + it("4.1.2 Table 5 row 11: encodes one-way schedule route (no time, no connections)", () => { + const r: DetailsRequest = { + area: "schedule", + kind: "route", + departure: "NBC", + arrival: "KHV", + dateFrom: "20220307", + dateTo: "20220313", + }; + expect(buildDetailsRequestParam(r)).toBe("schedule-route-NBC-KHV-20220307-20220313"); + }); + + it("encodes one-way with time range", () => { + expect( + buildDetailsRequestParam({ + area: "schedule", + kind: "route", + departure: "NBC", + arrival: "KHV", + dateFrom: "20220307", + dateTo: "20220313", + timeFrom: "0600", + timeTo: "2200", + }), + ).toBe("schedule-route-NBC-KHV-20220307-20220313-06002200"); + }); + + it("encodes one-way with connections C0 (direct-only)", () => { + expect( + buildDetailsRequestParam({ + area: "schedule", + kind: "route", + departure: "NBC", + arrival: "KHV", + dateFrom: "20220307", + dateTo: "20220313", + connections: 0, + }), + ).toBe("schedule-route-NBC-KHV-20220307-20220313-C0"); + }); + + it("encodes one-way with time + connections", () => { + expect( + buildDetailsRequestParam({ + area: "schedule", + kind: "route", + departure: "NBC", + arrival: "KHV", + dateFrom: "20220307", + dateTo: "20220313", + timeFrom: "0600", + timeTo: "2200", + connections: 0, + }), + ).toBe("schedule-route-NBC-KHV-20220307-20220313-06002200-C0"); + }); + + it("encodes round-trip without time or connections", () => { + expect( + buildDetailsRequestParam({ + area: "schedule", + kind: "route", + departure: "NBC", + arrival: "KHV", + dateFrom: "20220307", + dateTo: "20220313", + returnTrip: { + departure: "KHV", + arrival: "NBC", + dateFrom: "20220320", + dateTo: "20220326", + }, + }), + ).toBe("schedule-route-NBC-KHV-20220307-20220313-KHV-NBC-20220320-20220326"); + }); + + it("encodes round-trip with time + connections on both legs", () => { + expect( + buildDetailsRequestParam({ + area: "schedule", + kind: "route", + departure: "NBC", + arrival: "KHV", + dateFrom: "20220307", + dateTo: "20220313", + timeFrom: "0600", + timeTo: "2200", + connections: 0, + returnTrip: { + departure: "KHV", + arrival: "NBC", + dateFrom: "20220320", + dateTo: "20220326", + timeFrom: "0800", + timeTo: "1800", + connections: 1, + }, + }), + ).toBe( + "schedule-route-NBC-KHV-20220307-20220313-06002200-C0-KHV-NBC-20220320-20220326-08001800-C1", + ); + }); + + it("encodes round-trip with time on outbound only", () => { + expect( + buildDetailsRequestParam({ + area: "schedule", + kind: "route", + departure: "SVO", + arrival: "LED", + dateFrom: "20220401", + dateTo: "20220407", + timeFrom: "1000", + timeTo: "2000", + returnTrip: { + departure: "LED", + arrival: "SVO", + dateFrom: "20220408", + dateTo: "20220414", + }, + }), + ).toBe("schedule-route-SVO-LED-20220401-20220407-10002000-LED-SVO-20220408-20220414"); + }); +}); + +describe("detailsRequestParam — schedule area — parse", () => { + it("returns null for schedule-flight (not a known schedule kind)", () => { + expect(parseDetailsRequestParam("schedule-flight-SU1234-20260515")).toBeNull(); + }); + + it("parses one-way without time or connections", () => { + expect(parseDetailsRequestParam("schedule-route-NBC-KHV-20220307-20220313")).toEqual({ + area: "schedule", + kind: "route", + departure: "NBC", + arrival: "KHV", + dateFrom: "20220307", + dateTo: "20220313", + }); + }); + + it("parses one-way with time range", () => { + expect(parseDetailsRequestParam("schedule-route-NBC-KHV-20220307-20220313-06002200")).toEqual({ + area: "schedule", + kind: "route", + departure: "NBC", + arrival: "KHV", + dateFrom: "20220307", + dateTo: "20220313", + timeFrom: "0600", + timeTo: "2200", + }); + }); + + it("parses one-way with connections (C0)", () => { + expect(parseDetailsRequestParam("schedule-route-NBC-KHV-20220307-20220313-C0")).toEqual({ + area: "schedule", + kind: "route", + departure: "NBC", + arrival: "KHV", + dateFrom: "20220307", + dateTo: "20220313", + connections: 0, + }); + }); + + it("parses one-way with Cyrillic С0 (Angular spec compatibility)", () => { + // Cyrillic С (U+0421) should be accepted the same as Latin C + expect(parseDetailsRequestParam("schedule-route-NBC-KHV-20220307-20220313-С0")).toEqual({ + area: "schedule", + kind: "route", + departure: "NBC", + arrival: "KHV", + dateFrom: "20220307", + dateTo: "20220313", + connections: 0, + }); + }); + + it("parses one-way with time + connections", () => { + expect(parseDetailsRequestParam("schedule-route-NBC-KHV-20220307-20220313-06002200-C0")).toEqual({ + area: "schedule", + kind: "route", + departure: "NBC", + arrival: "KHV", + dateFrom: "20220307", + dateTo: "20220313", + timeFrom: "0600", + timeTo: "2200", + connections: 0, + }); + }); + + it("parses round-trip without time or connections", () => { + expect( + parseDetailsRequestParam("schedule-route-NBC-KHV-20220307-20220313-KHV-NBC-20220320-20220326"), + ).toEqual({ + area: "schedule", + kind: "route", + departure: "NBC", + arrival: "KHV", + dateFrom: "20220307", + dateTo: "20220313", + returnTrip: { + departure: "KHV", + arrival: "NBC", + dateFrom: "20220320", + dateTo: "20220326", + }, + }); + }); + + it("parses round-trip with time + connections on both legs", () => { + expect( + parseDetailsRequestParam( + "schedule-route-NBC-KHV-20220307-20220313-06002200-C0-KHV-NBC-20220320-20220326-08001800-C1", + ), + ).toEqual({ + area: "schedule", + kind: "route", + departure: "NBC", + arrival: "KHV", + dateFrom: "20220307", + dateTo: "20220313", + timeFrom: "0600", + timeTo: "2200", + connections: 0, + returnTrip: { + departure: "KHV", + arrival: "NBC", + dateFrom: "20220320", + dateTo: "20220326", + timeFrom: "0800", + timeTo: "1800", + connections: 1, + }, + }); + }); + + it("parses round-trip with time on outbound only", () => { + expect( + parseDetailsRequestParam( + "schedule-route-SVO-LED-20220401-20220407-10002000-LED-SVO-20220408-20220414", + ), + ).toEqual({ + area: "schedule", + kind: "route", + departure: "SVO", + arrival: "LED", + dateFrom: "20220401", + dateTo: "20220407", + timeFrom: "1000", + timeTo: "2000", + returnTrip: { + departure: "LED", + arrival: "SVO", + dateFrom: "20220408", + dateTo: "20220414", + }, + }); + }); +}); + +describe("detailsRequestParam — schedule area — roundtrip property", () => { + it.each([ + { + area: "schedule", + kind: "route", + departure: "NBC", + arrival: "KHV", + dateFrom: "20220307", + dateTo: "20220313", + }, + { + area: "schedule", + kind: "route", + departure: "NBC", + arrival: "KHV", + dateFrom: "20220307", + dateTo: "20220313", + timeFrom: "0600", + timeTo: "2200", + }, + { + area: "schedule", + kind: "route", + departure: "NBC", + arrival: "KHV", + dateFrom: "20220307", + dateTo: "20220313", + connections: 0, + }, + { + area: "schedule", + kind: "route", + departure: "NBC", + arrival: "KHV", + dateFrom: "20220307", + dateTo: "20220313", + timeFrom: "0600", + timeTo: "2200", + connections: 0, + }, + { + area: "schedule", + kind: "route", + departure: "NBC", + arrival: "KHV", + dateFrom: "20220307", + dateTo: "20220313", + returnTrip: { + departure: "KHV", + arrival: "NBC", + dateFrom: "20220320", + dateTo: "20220326", + }, + }, + { + area: "schedule", + kind: "route", + departure: "NBC", + arrival: "KHV", + dateFrom: "20220307", + dateTo: "20220313", + timeFrom: "0600", + timeTo: "2200", + connections: 0, + returnTrip: { + departure: "KHV", + arrival: "NBC", + dateFrom: "20220320", + dateTo: "20220326", + timeFrom: "0800", + timeTo: "1800", + connections: 1, + }, + }, + ])("round-trips %o", (input) => { + const encoded = buildDetailsRequestParam(input); + const decoded = parseDetailsRequestParam(encoded); + expect(decoded).toEqual(input); + }); +}); diff --git a/src/shared/detailsRequestParam.ts b/src/shared/detailsRequestParam.ts index fc2254d2..1ee557a7 100644 --- a/src/shared/detailsRequestParam.ts +++ b/src/shared/detailsRequestParam.ts @@ -1,13 +1,15 @@ /** - * Codec for the `?request=...` query parameter on Online-Board flight-details URLs. - * Per TZ §4.1.2 Table 5 row 6. The param encodes the parent search context so - * the mini-list can rebuild the list on refresh / deep-link entry. + * Codec for the `?request=...` query parameter on flight-details URLs. * - * Forms (canonical): + * Online-Board forms per TZ §4.1.2 Table 5 row 6: * onlineboard-flight-{flightNumber}-{yyyyMMdd} * onlineboard-departure-{iata}-{yyyyMMdd}[-{HHmmHHmm}] * onlineboard-arrival-{iata}-{yyyyMMdd}[-{HHmmHHmm}] * onlineboard-route-{depIata}-{arrIata}-{yyyyMMdd}[-{HHmmHHmm}] + * + * Schedule forms per TZ §4.1.2 Table 5 row 11: + * schedule-route-{dep}-{arr}-{dateFrom}-{dateTo}[-{HHmmHHmm}][-C{n}] + * schedule-route-{dep}-{arr}-{dateFrom}-{dateTo}[-{HHmmHHmm}][-C{n}]-{arr}-{dep}-{returnDateFrom}-{returnDateTo}[-{HHmmHHmm}][-C{n}] */ export type DetailsRequest = @@ -28,9 +30,30 @@ export type DetailsRequest = date: string; timeFrom?: string; timeTo?: string; + } + | { + area: "schedule"; + kind: "route"; + departure: string; + arrival: string; + dateFrom: string; // yyyyMMdd (outbound dateFrom) + dateTo: string; // yyyyMMdd (outbound dateTo) + timeFrom?: string; // HHmm + timeTo?: string; // HHmm + connections?: number; // 0 = C0 (direct-only), 1 = C1 (one connection) + returnTrip?: { + departure: string; + arrival: string; + dateFrom: string; + dateTo: string; + timeFrom?: string; + timeTo?: string; + connections?: number; + }; }; -const AREA = "onlineboard"; +const ONLINEBOARD_AREA = "onlineboard"; +const SCHEDULE_AREA = "schedule"; function isDate(s: string | undefined): s is string { return typeof s === "string" && /^\d{8}$/.test(s); @@ -40,7 +63,99 @@ function isTimeRange(s: string | undefined): s is string { return typeof s === "string" && /^\d{8}$/.test(s); } +function isConnectionToken(s: string | undefined): boolean { + // Latin C or Cyrillic С (U+0421) + return typeof s === "string" && (s.startsWith("C") || s.startsWith("С")) && s.length >= 2; +} + +function parseConnectionToken(s: string): number | undefined { + const numStr = s.slice(1); + const num = Number(numStr); + return Number.isNaN(num) ? undefined : num; +} + +// --------------------------------------------------------------------------- +// Schedule leg serialization helpers +// --------------------------------------------------------------------------- + +interface ScheduleLeg { + departure: string; + arrival: string; + dateFrom: string; + dateTo: string; + timeFrom?: string; + timeTo?: string; + connections?: number; +} + +function buildScheduleLegTokens(leg: ScheduleLeg): string[] { + const tokens: string[] = [leg.departure, leg.arrival, leg.dateFrom, leg.dateTo]; + if (leg.timeFrom && leg.timeTo) { + tokens.push(`${leg.timeFrom}${leg.timeTo}`); + } + if (leg.connections !== undefined) { + tokens.push(`C${leg.connections}`); + } + return tokens; +} + +/** + * Try to consume one schedule leg from `parts` starting at `offset`. + * A leg starts with: {iata}-{iata}-{yyyyMMdd}-{yyyyMMdd} then optional time/conn tokens. + * Returns the parsed leg and the index of the next unused part, or null if invalid. + */ +function parseScheduleLeg( + parts: string[], + offset: number, +): { leg: ScheduleLeg; nextOffset: number } | null { + // Minimum: dep, arr, dateFrom, dateTo = 4 tokens + if (offset + 3 >= parts.length) return null; + + const departure = parts[offset]; + const arrival = parts[offset + 1]; + const dateFrom = parts[offset + 2]; + const dateTo = parts[offset + 3]; + + if (!departure || !arrival || !isDate(dateFrom) || !isDate(dateTo)) return null; + + let nextOffset = offset + 4; + const leg: ScheduleLeg = { departure, arrival, dateFrom, dateTo }; + + // Consume optional timeRange + const maybeTime = parts[nextOffset]; + if (maybeTime && isTimeRange(maybeTime) && !isConnectionToken(maybeTime)) { + leg.timeFrom = maybeTime.slice(0, 4); + leg.timeTo = maybeTime.slice(4, 8); + nextOffset++; + } + + // Consume optional connections token + const maybeConn = parts[nextOffset]; + if (maybeConn && isConnectionToken(maybeConn)) { + const conn = parseConnectionToken(maybeConn); + if (conn !== undefined) { + leg.connections = conn; + } + nextOffset++; + } + + return { leg, nextOffset }; +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + export function buildDetailsRequestParam(r: DetailsRequest): string { + if (r.area === SCHEDULE_AREA) { + const outboundTokens = buildScheduleLegTokens(r); + const allTokens = [SCHEDULE_AREA, "route", ...outboundTokens]; + if (r.returnTrip) { + allTokens.push(...buildScheduleLegTokens(r.returnTrip)); + } + return allTokens.join("-"); + } + switch (r.kind) { case "flight": return `${r.area}-flight-${r.flightNumber}-${r.date}`; @@ -60,7 +175,75 @@ export function parseDetailsRequestParam(raw: string): DetailsRequest | null { if (!raw) return null; const parts = raw.split("-"); if (parts.length < 4) return null; - if (parts[0] !== AREA) return null; + + const area = parts[0]; + + // --------------------------------------------------------------------------- + // Schedule area + // --------------------------------------------------------------------------- + if (area === SCHEDULE_AREA) { + const kind = parts[1]; + if (kind !== "route") return null; + + // parts[2..] are leg tokens starting from index 2 + const outboundResult = parseScheduleLeg(parts, 2); + if (!outboundResult) return null; + + const { leg: outbound, nextOffset } = outboundResult; + + // Detect whether a second leg follows. A second leg starts with two IATA + // codes followed by two dates. Check that parts[nextOffset] and + // parts[nextOffset+1] are 3-letter IATA-ish codes and parts[nextOffset+2] + // and [nextOffset+3] are dates. This disambiguates a return trip from + // trailing garbage. + let returnTrip: ScheduleLeg | undefined; + if (nextOffset < parts.length) { + const maybeSecond = parseScheduleLeg(parts, nextOffset); + if (maybeSecond) { + returnTrip = maybeSecond.leg; + } + } + + const result: DetailsRequest = { + area: "schedule", + kind: "route", + departure: outbound.departure, + arrival: outbound.arrival, + dateFrom: outbound.dateFrom, + dateTo: outbound.dateTo, + }; + + if (outbound.timeFrom !== undefined && outbound.timeTo !== undefined) { + result.timeFrom = outbound.timeFrom; + result.timeTo = outbound.timeTo; + } + if (outbound.connections !== undefined) { + result.connections = outbound.connections; + } + if (returnTrip) { + const rt: NonNullable = { + departure: returnTrip.departure, + arrival: returnTrip.arrival, + dateFrom: returnTrip.dateFrom, + dateTo: returnTrip.dateTo, + }; + if (returnTrip.timeFrom !== undefined && returnTrip.timeTo !== undefined) { + rt.timeFrom = returnTrip.timeFrom; + rt.timeTo = returnTrip.timeTo; + } + if (returnTrip.connections !== undefined) { + rt.connections = returnTrip.connections; + } + result.returnTrip = rt; + } + + return result; + } + + // --------------------------------------------------------------------------- + // Online-Board area + // --------------------------------------------------------------------------- + if (area !== ONLINEBOARD_AREA) return null; const kind = parts[1];