Files
flights_web/src/features/online-board/url.ts
T
gnezim ceeae1a7b1 Strip dashes from date when building flight-details URL
The API returns flight.flightId.date as 'yyyy-MM-dd' (dashed). Our URL
builder pasted it verbatim, producing /onlineboard/SU6162-2026-04-18
which the route parser (expecting yyyyMMdd) rejected. Normalise the
date to compact form inside buildFlightUrlParams so the URL always
matches the route pattern, regardless of whether the caller passes a
compact or dashed date.
2026-04-18 15:41:14 +03:00

401 lines
12 KiB
TypeScript

/**
* Online Board URL serializer/parser.
*
* Pure functions — no side effects, no Angular imports, no Date objects.
* Byte-exact parity with Angular's OnlineBoardUrlBuilderService /
* OnlineBoardUrlParserService.
*
* @module
*/
import type { IParsedFlightId } from "./types";
// ---------------------------------------------------------------------------
// Public types
// ---------------------------------------------------------------------------
/** Discriminated union of all parsed online board URL parameter shapes */
export type OnlineBoardParams =
| { type: "start" }
| { type: "flight"; carrier: string; flightNumber: string; suffix?: string; date: string }
| { type: "departure"; station: string; date: string; timeFrom?: string; timeTo?: string }
| { type: "arrival"; station: string; date: string; timeFrom?: string; timeTo?: string }
| {
type: "route";
departure: string;
arrival: string;
date: string;
timeFrom?: string;
timeTo?: string;
}
| { type: "details"; carrier: string; flightNumber: string; suffix?: string; date: string };
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const ONLINE_BOARD_PREFIX = "onlineboard";
const TIME_RANGE_LENGTH = 8;
// ---------------------------------------------------------------------------
// Validation helpers
// ---------------------------------------------------------------------------
function isValidDate(date: string): boolean {
return /^\d{8}$/.test(date);
}
function isValidTimeRange(timeRange: string): boolean {
return /^\d{8}$/.test(timeRange);
}
function isLetter(ch: string): boolean {
return /^[A-Za-z]$/.test(ch);
}
// ---------------------------------------------------------------------------
// Low-level parsers
// ---------------------------------------------------------------------------
/**
* Parse a flight URL parameter string into its constituent parts.
* Handles format: {carrier}{flightNumber}{suffix?}-{yyyyMMdd}
*
* Carrier detection logic (matching Angular):
* - First 2 chars are always carrier.
* - If the 3rd char exists and is a letter, it's part of the carrier (3-char carrier code).
* - Last char of the flight info (before the dash) that is a letter is the suffix.
*/
export function parseFlightUrlParams(raw: string): IParsedFlightId | null {
if (!raw) return null;
const dashIdx = raw.indexOf("-");
if (dashIdx < 0) return null;
const flightInfo = raw.slice(0, dashIdx);
const dateStr = raw.slice(dashIdx + 1);
if (!flightInfo || !isValidDate(dateStr)) return null;
// Carrier: first 2 chars + optional 3rd letter
let carrierLen = 2;
const thirdChar = flightInfo[2];
if (flightInfo.length > 2 && thirdChar !== undefined && isLetter(thirdChar)) {
carrierLen = 3;
}
if (flightInfo.length <= carrierLen) return null;
const carrier = flightInfo.slice(0, carrierLen);
// Suffix: last char if it's a letter
const lastChar = flightInfo.at(-1);
if (lastChar === undefined) return null;
const hasSuffix = isLetter(lastChar);
const suffix = hasSuffix ? lastChar : undefined;
// Flight number: everything between carrier and optional suffix
const numberEnd = hasSuffix ? flightInfo.length - 1 : flightInfo.length;
const flightNumber = flightInfo.slice(carrierLen, numberEnd);
if (!flightNumber) return null;
const result: IParsedFlightId = {
carrier,
flightNumber,
date: dateStr,
};
if (suffix !== undefined) {
result.suffix = suffix;
}
return result;
}
/**
* Build a flight URL parameter string from parts.
* Output format: {carrier}{paddedFlightNumber}{suffix?}-{yyyyMMdd}
*
* Flight number is zero-padded so that flightNumber+suffix is at least 5 chars
* (matching Angular's `.slice(-5)` logic).
*/
export function buildFlightUrlParams(id: IParsedFlightId): string {
const suffix = id.suffix ?? "";
// Pad flight number to 4 digits (standard IATA width).
// Angular's `.slice(-5)` on `flightNumber+suffix` is a truncation guard,
// not padding — the API already supplies zero-padded numbers. We pad here
// to handle cases where the caller passes unpadded numbers (e.g. "100").
const paddedNumber = id.flightNumber.padStart(4, "0");
// Angular truncation: take last 5 chars of (number + suffix) to cap length
const combined = `${paddedNumber}${suffix}`.slice(-5);
// The flight's API date may arrive dashed (yyyy-MM-dd from real payloads)
// or compact (yyyyMMdd from our own URL parser). Normalise to compact so
// the resulting URL always matches the route pattern.
const compactDate = id.date.replace(/-/g, "");
return `${id.carrier}${combined}-${compactDate}`;
}
/**
* Parse time range from an 8-char continuous string "HHmmHHmm".
* Returns undefined fields if the input is not a valid time range.
*/
function parseTimeRange(
raw: string | undefined,
): { timeFrom: string; timeTo: string } | undefined {
if (!raw || !isValidTimeRange(raw)) return undefined;
return {
timeFrom: raw.slice(0, 4),
timeTo: raw.slice(4, TIME_RANGE_LENGTH),
};
}
/**
* Build time range string from optional from/to fields.
* Returns undefined if either field is missing.
*/
function buildTimeRange(
timeFrom: string | undefined,
timeTo: string | undefined,
): string | undefined {
if (!timeFrom || !timeTo) return undefined;
return `${timeFrom}${timeTo}`;
}
/**
* Parse a station URL parameter string.
* Handles format: {station}-{yyyyMMdd}[-{timeFrom}{timeTo}]
*/
export function parseStationUrlParams(
raw: string,
): { station: string; date: string; timeFrom?: string; timeTo?: string } | null {
if (!raw) return null;
const parts = raw.split("-");
const station = parts[0];
const dateStr = parts[1];
if (station === undefined || dateStr === undefined) return null;
if (!isValidDate(dateStr)) return null;
const timeRaw = parts[2];
const timeRange = parseTimeRange(timeRaw);
// If there's a 3rd segment but it's not a valid time range, reject
if (timeRaw !== undefined && !timeRange) return null;
const result: { station: string; date: string; timeFrom?: string; timeTo?: string } = {
station,
date: dateStr,
};
if (timeRange) {
result.timeFrom = timeRange.timeFrom;
result.timeTo = timeRange.timeTo;
}
return result;
}
/**
* Parse a route URL parameter string.
* Handles format: {dep}-{arr}-{yyyyMMdd}[-{timeFrom}{timeTo}]
*/
export function parseRouteUrlParams(
raw: string,
): {
departure: string;
arrival: string;
date: string;
timeFrom?: string;
timeTo?: string;
} | null {
if (!raw) return null;
const parts = raw.split("-");
const departure = parts[0];
const arrival = parts[1];
const dateStr = parts[2];
if (departure === undefined || arrival === undefined || dateStr === undefined) return null;
if (!isValidDate(dateStr)) return null;
const timeRaw = parts[3];
const timeRange = parseTimeRange(timeRaw);
const result: {
departure: string;
arrival: string;
date: string;
timeFrom?: string;
timeTo?: string;
} = {
departure,
arrival,
date: dateStr,
};
if (timeRange) {
result.timeFrom = timeRange.timeFrom;
result.timeTo = timeRange.timeTo;
}
return result;
}
// ---------------------------------------------------------------------------
// Helpers for constructing OnlineBoardParams with exactOptionalPropertyTypes
// ---------------------------------------------------------------------------
function toFlightParams(
type: "flight" | "details",
parsed: IParsedFlightId,
): OnlineBoardParams {
if (parsed.suffix !== undefined) {
return { type, carrier: parsed.carrier, flightNumber: parsed.flightNumber, suffix: parsed.suffix, date: parsed.date };
}
return { type, carrier: parsed.carrier, flightNumber: parsed.flightNumber, date: parsed.date };
}
function toStationParams(
type: "departure" | "arrival",
parsed: { station: string; date: string; timeFrom?: string; timeTo?: string },
): OnlineBoardParams {
if (parsed.timeFrom !== undefined && parsed.timeTo !== undefined) {
return { type, station: parsed.station, date: parsed.date, timeFrom: parsed.timeFrom, timeTo: parsed.timeTo };
}
return { type, station: parsed.station, date: parsed.date };
}
// ---------------------------------------------------------------------------
// Top-level parse / build
// ---------------------------------------------------------------------------
/**
* Parse a raw URL path into typed online board params.
* Returns null if the path does not match any known online board URL shape.
*/
export function parseOnlineBoardUrl(path: string): OnlineBoardParams | null {
if (!path) return null;
// Strip leading slash
const normalized = path.startsWith("/") ? path.slice(1) : path;
// Must start with "onlineboard"
if (!normalized.startsWith(ONLINE_BOARD_PREFIX)) return null;
// Get the rest after "onlineboard"
const rest = normalized.slice(ONLINE_BOARD_PREFIX.length);
// Start page: empty or just "/"
if (!rest || rest === "/") return { type: "start" };
// Must have "/" after "onlineboard"
if (!rest.startsWith("/")) return null;
const afterPrefix = rest.slice(1); // Remove leading "/"
// Split into route type and params
const slashIdx = afterPrefix.indexOf("/");
if (slashIdx >= 0) {
const routeType = afterPrefix.slice(0, slashIdx);
const params = afterPrefix.slice(slashIdx + 1);
switch (routeType) {
case "flight": {
const parsed = parseFlightUrlParams(params);
if (!parsed) return null;
return toFlightParams("flight", parsed);
}
case "departure": {
const parsed = parseStationUrlParams(params);
if (!parsed) return null;
return toStationParams("departure", parsed);
}
case "arrival": {
const parsed = parseStationUrlParams(params);
if (!parsed) return null;
return toStationParams("arrival", parsed);
}
case "route": {
const parsed = parseRouteUrlParams(params);
if (!parsed) return null;
return parsed.timeFrom !== undefined && parsed.timeTo !== undefined
? { type: "route", departure: parsed.departure, arrival: parsed.arrival, date: parsed.date, timeFrom: parsed.timeFrom, timeTo: parsed.timeTo }
: { type: "route", departure: parsed.departure, arrival: parsed.arrival, date: parsed.date };
}
default:
return null;
}
}
// No sub-slash: must be a details URL (flight ID directly after /onlineboard/)
const parsed = parseFlightUrlParams(afterPrefix);
if (!parsed) return null;
return toFlightParams("details", parsed);
}
/**
* Build a URL path segment from typed online board params.
* Output is byte-exact match with Angular's URL builder.
*
* @returns the path segment without leading slash (e.g. "onlineboard/flight/SU0100-20250115")
*/
export function buildOnlineBoardUrl(params: OnlineBoardParams): string {
switch (params.type) {
case "start":
return ONLINE_BOARD_PREFIX;
case "flight": {
const id: IParsedFlightId = {
carrier: params.carrier,
flightNumber: params.flightNumber,
date: params.date,
};
if (params.suffix !== undefined) id.suffix = params.suffix;
return `${ONLINE_BOARD_PREFIX}/flight/${buildFlightUrlParams(id)}`;
}
case "departure": {
const timeRange = buildTimeRange(params.timeFrom, params.timeTo);
const locationDate = `${params.station}-${params.date}`;
return timeRange
? `${ONLINE_BOARD_PREFIX}/departure/${locationDate}-${timeRange}`
: `${ONLINE_BOARD_PREFIX}/departure/${locationDate}`;
}
case "arrival": {
const timeRange = buildTimeRange(params.timeFrom, params.timeTo);
const locationDate = `${params.station}-${params.date}`;
return timeRange
? `${ONLINE_BOARD_PREFIX}/arrival/${locationDate}-${timeRange}`
: `${ONLINE_BOARD_PREFIX}/arrival/${locationDate}`;
}
case "route": {
const timeRange = buildTimeRange(params.timeFrom, params.timeTo);
const locationDate = `${params.departure}-${params.arrival}-${params.date}`;
return timeRange
? `${ONLINE_BOARD_PREFIX}/route/${locationDate}-${timeRange}`
: `${ONLINE_BOARD_PREFIX}/route/${locationDate}`;
}
case "details": {
const id: IParsedFlightId = {
carrier: params.carrier,
flightNumber: params.flightNumber,
date: params.date,
};
if (params.suffix !== undefined) id.suffix = params.suffix;
return `${ONLINE_BOARD_PREFIX}/${buildFlightUrlParams(id)}`;
}
}
}