Files
flights_web/src/features/schedule/url.ts
T
gnezim 1c5e85ea8e Implement schedule URL serializer/parser with TDD (Phase 3A)
Covers one-way search, round-trip search, multi-flight details (catch-all),
and airport-code-interleaved details format. Reuses online-board's flight
param parser for individual flight segments.
2026-04-15 09:20:56 +03:00

289 lines
8.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Schedule URL serializer/parser.
*
* Pure functions -- no side effects, no Angular imports, no Date objects.
* Byte-exact parity with Angular's ScheduleUrlBuilderService /
* ScheduleUrlParserService.
*
* @module
*/
import { parseFlightUrlParams, buildFlightUrlParams } from "../online-board/url.js";
import type { IScheduleRouteDirectionParams, IScheduleFlightId } from "./types.js";
// ---------------------------------------------------------------------------
// Public types
// ---------------------------------------------------------------------------
/** Discriminated union of all parsed schedule URL parameter shapes */
export type ScheduleParams =
| { type: "start" }
| { type: "route"; outbound: IScheduleRouteDirectionParams }
| { type: "roundtrip"; outbound: IScheduleRouteDirectionParams; inbound: IScheduleRouteDirectionParams }
| { type: "details"; flights: IScheduleFlightId[] };
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const SCHEDULE_PREFIX = "schedule";
// ---------------------------------------------------------------------------
// Validation helpers
// ---------------------------------------------------------------------------
function isValidDate(date: string): boolean {
return /^\d{8}$/.test(date);
}
function isValidTimeRange(timeRange: string): boolean {
return /^\d{8}$/.test(timeRange);
}
// ---------------------------------------------------------------------------
// Route direction params parser/builder
// ---------------------------------------------------------------------------
/**
* Parse a schedule route direction params string.
* Format: {dep}-{arr}-{dateFrom}-{dateTo}[-{timeFromTo}][-C{connections}]
*
* Both timeRange and connections are optional. Connections starts with 'C'.
*/
export function parseScheduleRouteParams(raw: string): IScheduleRouteDirectionParams | null {
if (!raw) return null;
const parts = raw.split("-");
if (parts.length < 4) return null;
const departure = parts[0];
const arrival = parts[1];
const dateFrom = parts[2];
const dateTo = parts[3];
if (departure === undefined || arrival === undefined || dateFrom === undefined || dateTo === undefined) return null;
if (!isValidDate(dateFrom) || !isValidDate(dateTo)) return null;
// Remaining parts are optional: timeRange, connections (prefixed with C)
const rest = parts.slice(4);
const { timeFrom, timeTo, connections } = parseOptionalRouteParams(rest);
const result: IScheduleRouteDirectionParams = {
departure,
arrival,
dateFrom,
dateTo,
};
if (timeFrom !== undefined && timeTo !== undefined) {
result.timeFrom = timeFrom;
result.timeTo = timeTo;
}
if (connections !== undefined) {
result.connections = connections;
}
return result;
}
function parseOptionalRouteParams(parts: string[]): {
timeFrom?: string;
timeTo?: string;
connections?: number;
} {
if (parts.length === 0) return {};
if (parts.length === 2) {
// Both timeRange and connections
const timeRange = parts[0];
const connStr = parts[1];
if (timeRange !== undefined && connStr !== undefined) {
const time = parseTimeRange(timeRange);
const conn = parseConnections(connStr);
return {
...time,
...(conn !== undefined ? { connections: conn } : {}),
};
}
return {};
}
// Single part: either connections (starts with C) or timeRange
const part = parts[0];
if (part === undefined) return {};
if (part.startsWith("C") || part.startsWith("\u0421")) {
// C (Latin) or С (Cyrillic) -- Angular spec uses Cyrillic С in one test case
const conn = parseConnections(part);
return conn !== undefined ? { connections: conn } : {};
}
const time = parseTimeRange(part);
return time;
}
function parseTimeRange(raw: string): { timeFrom: string; timeTo: string } | Record<string, never> {
if (!raw || !isValidTimeRange(raw)) return {};
return {
timeFrom: raw.slice(0, 4),
timeTo: raw.slice(4, 8),
};
}
function parseConnections(raw: string): number | undefined {
if (!raw || raw.length < 2) return undefined;
const numStr = raw.slice(1);
const num = Number(numStr);
return Number.isNaN(num) ? undefined : num;
}
/**
* Build a schedule route direction params string.
*/
export function buildScheduleRouteParams(params: IScheduleRouteDirectionParams): string {
let base = `${params.departure}-${params.arrival}-${params.dateFrom}-${params.dateTo}`;
if (params.timeFrom && params.timeTo) {
base = `${base}-${params.timeFrom}${params.timeTo}`;
}
if (params.connections !== undefined) {
base = `${base}-C${params.connections}`;
}
return base;
}
// ---------------------------------------------------------------------------
// Details flight segment parser
// ---------------------------------------------------------------------------
/**
* Parse a catch-all flight details path into flight IDs.
* Handles two formats:
* 1. Simple: SU0012-20220501/SU0013-20220507
* 2. With airports: SVO/SU0012-20220501/LED/LED/SU0013-20220507/SVO
* (3-letter airport codes are skipped)
*/
function parseFlightDetailsSegments(raw: string): IScheduleFlightId[] | null {
if (!raw) return null;
const segments = raw.split("/").filter(Boolean);
if (segments.length === 0) return null;
const flights: IScheduleFlightId[] = [];
for (const segment of segments) {
// Skip 3-letter airport codes (no dash, <= 3 chars)
if (segment.length <= 3) continue;
const parsed = parseFlightUrlParams(segment);
if (parsed) {
const flight: IScheduleFlightId = {
carrier: parsed.carrier,
flightNumber: parsed.flightNumber,
date: parsed.date,
};
if (parsed.suffix !== undefined) {
flight.suffix = parsed.suffix;
}
flights.push(flight);
}
}
return flights.length > 0 ? flights : null;
}
// ---------------------------------------------------------------------------
// Top-level parse / build
// ---------------------------------------------------------------------------
/**
* Parse a raw URL path into typed schedule params.
* Returns null if the path does not match any known schedule URL shape.
*/
export function parseScheduleUrl(path: string): ScheduleParams | null {
if (!path) return null;
const normalized = path.startsWith("/") ? path.slice(1) : path;
if (!normalized.startsWith(SCHEDULE_PREFIX)) return null;
const rest = normalized.slice(SCHEDULE_PREFIX.length);
// Start page
if (!rest || rest === "/") return { type: "start" };
if (!rest.startsWith("/")) return null;
const afterPrefix = rest.slice(1);
// Route search: route/{params}[/{returnParams}]
if (afterPrefix.startsWith("route/")) {
const routeContent = afterPrefix.slice("route/".length);
if (!routeContent) return null;
// Route params use "-" as separator, "/" separates outbound from inbound.
// Try round-trip first (split on "/"), then fall back to one-way.
const slashIdx = routeContent.indexOf("/");
if (slashIdx >= 0) {
const outboundRaw = routeContent.slice(0, slashIdx);
const inboundRaw = routeContent.slice(slashIdx + 1);
const parsedOutbound = parseScheduleRouteParams(outboundRaw);
const parsedInbound = parseScheduleRouteParams(inboundRaw);
if (parsedOutbound && parsedInbound) {
return { type: "roundtrip", outbound: parsedOutbound, inbound: parsedInbound };
}
}
// Try one-way (entire routeContent is a single route param set)
const outbound = parseScheduleRouteParams(routeContent);
if (outbound) {
return { type: "route", outbound };
}
return null;
}
// Route without prefix "route/": must be details (catch-all)
const flights = parseFlightDetailsSegments(afterPrefix);
if (flights) {
return { type: "details", flights };
}
return null;
}
/**
* Build a URL path segment from typed schedule params.
*
* @returns the path segment without leading slash (e.g. "schedule/route/MOW-KUF-20220425-20220501")
*/
export function buildScheduleUrl(params: ScheduleParams): string {
switch (params.type) {
case "start":
return SCHEDULE_PREFIX;
case "route":
return `${SCHEDULE_PREFIX}/route/${buildScheduleRouteParams(params.outbound)}`;
case "roundtrip":
return `${SCHEDULE_PREFIX}/route/${buildScheduleRouteParams(params.outbound)}/${buildScheduleRouteParams(params.inbound)}`;
case "details": {
const segments = params.flights.map((flight) => {
return buildFlightUrlParams({
carrier: flight.carrier,
flightNumber: flight.flightNumber,
date: flight.date,
...(flight.suffix !== undefined ? { suffix: flight.suffix } : {}),
});
});
return `${SCHEDULE_PREFIX}/${segments.join("/")}`;
}
}
}