Extend detailsRequestParam codec for area:schedule (one-way + round-trip + connections)

This commit is contained in:
2026-04-21 17:42:54 +03:00
parent 1821f7f78e
commit 12807cf085
2 changed files with 538 additions and 6 deletions
+349
View File
@@ -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<DetailsRequest>([
{
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);
});
});
+189 -6
View File
@@ -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<typeof result.returnTrip> = {
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];