diff --git a/src/features/online-board/index.ts b/src/features/online-board/index.ts index 4d678e0c..356923e8 100644 --- a/src/features/online-board/index.ts +++ b/src/features/online-board/index.ts @@ -2,4 +2,13 @@ // This file is the ONLY public surface — other sub-plans and features // must import exclusively from "@/features/online-board", never from // deeper paths. See docs/superpowers/phase-1/frozen-barrels.md for the rule. -export {}; + +export type { OnlineBoardParams } from "./url"; +export { + parseOnlineBoardUrl, + buildOnlineBoardUrl, + parseFlightUrlParams, + buildFlightUrlParams, + parseStationUrlParams, + parseRouteUrlParams, +} from "./url"; diff --git a/src/features/online-board/url.test.ts b/src/features/online-board/url.test.ts new file mode 100644 index 00000000..8dc5efc8 --- /dev/null +++ b/src/features/online-board/url.test.ts @@ -0,0 +1,630 @@ +import { describe, it, expect } from "vitest"; +import { + parseFlightUrlParams, + buildFlightUrlParams, + parseStationUrlParams, + parseRouteUrlParams, + parseOnlineBoardUrl, + buildOnlineBoardUrl, + type OnlineBoardParams, +} from "./url"; + +// --------------------------------------------------------------------------- +// parseFlightUrlParams +// --------------------------------------------------------------------------- + +describe("parseFlightUrlParams", () => { + it("parses a standard 4-digit flight number", () => { + expect(parseFlightUrlParams("SU0100-20250115")).toEqual({ + carrier: "SU", + flightNumber: "0100", + date: "20250115", + }); + }); + + it("parses a flight with suffix", () => { + expect(parseFlightUrlParams("SU0100D-20250115")).toEqual({ + carrier: "SU", + flightNumber: "0100", + suffix: "D", + date: "20250115", + }); + }); + + it("parses a 3-digit flight number", () => { + expect(parseFlightUrlParams("SU100-20250115")).toEqual({ + carrier: "SU", + flightNumber: "100", + date: "20250115", + }); + }); + + it("parses a 4-digit flight number without padding", () => { + expect(parseFlightUrlParams("SU1234-20250115")).toEqual({ + carrier: "SU", + flightNumber: "1234", + date: "20250115", + }); + }); + + it("parses a 1-digit flight number", () => { + expect(parseFlightUrlParams("SU1-20250115")).toEqual({ + carrier: "SU", + flightNumber: "1", + date: "20250115", + }); + }); + + it("parses a 3-char carrier code (3rd char is letter)", () => { + // Rare: carrier like "SUA" where 3rd char is a letter + expect(parseFlightUrlParams("SUA100-20250115")).toEqual({ + carrier: "SUA", + flightNumber: "100", + date: "20250115", + }); + }); + + it("returns null for empty string", () => { + expect(parseFlightUrlParams("")).toBeNull(); + }); + + it("returns null for missing date part", () => { + expect(parseFlightUrlParams("SU0100")).toBeNull(); + }); + + it("returns null for invalid date (non-8-digit)", () => { + expect(parseFlightUrlParams("SU0100-2025011")).toBeNull(); + }); + + it("returns null for no flight info", () => { + expect(parseFlightUrlParams("-20250115")).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// buildFlightUrlParams +// --------------------------------------------------------------------------- + +describe("buildFlightUrlParams", () => { + it("pads a 3-digit flight number to 4 digits", () => { + expect( + buildFlightUrlParams({ carrier: "SU", flightNumber: "100", date: "20250115" }), + ).toBe("SU0100-20250115"); + }); + + it("builds with suffix", () => { + expect( + buildFlightUrlParams({ + carrier: "SU", + flightNumber: "0100", + suffix: "D", + date: "20250115", + }), + ).toBe("SU0100D-20250115"); + }); + + it("does not pad a 4-digit flight number", () => { + expect( + buildFlightUrlParams({ carrier: "SU", flightNumber: "1234", date: "20250115" }), + ).toBe("SU1234-20250115"); + }); + + it("pads a 1-digit flight number to 4 digits", () => { + expect( + buildFlightUrlParams({ carrier: "SU", flightNumber: "1", date: "20250115" }), + ).toBe("SU0001-20250115"); + }); + + it("pads a 2-digit flight number to 4 digits", () => { + expect( + buildFlightUrlParams({ carrier: "SU", flightNumber: "12", date: "20250115" }), + ).toBe("SU0012-20250115"); + }); + + it("builds with 3-char carrier", () => { + expect( + buildFlightUrlParams({ carrier: "SUA", flightNumber: "100", date: "20250115" }), + ).toBe("SUA0100-20250115"); + }); +}); + +// --------------------------------------------------------------------------- +// parseStationUrlParams +// --------------------------------------------------------------------------- + +describe("parseStationUrlParams", () => { + it("parses station without time range", () => { + expect(parseStationUrlParams("SVO-20250115")).toEqual({ + station: "SVO", + date: "20250115", + }); + }); + + it("parses station with time range", () => { + expect(parseStationUrlParams("SVO-20250115-08001800")).toEqual({ + station: "SVO", + date: "20250115", + timeFrom: "0800", + timeTo: "1800", + }); + }); + + it("returns null for empty string", () => { + expect(parseStationUrlParams("")).toBeNull(); + }); + + it("returns null for missing date", () => { + expect(parseStationUrlParams("SVO")).toBeNull(); + }); + + it("returns null for invalid date", () => { + expect(parseStationUrlParams("SVO-2025")).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// parseRouteUrlParams +// --------------------------------------------------------------------------- + +describe("parseRouteUrlParams", () => { + it("parses route without time range", () => { + expect(parseRouteUrlParams("SVO-LED-20250115")).toEqual({ + departure: "SVO", + arrival: "LED", + date: "20250115", + }); + }); + + it("parses route with time range", () => { + expect(parseRouteUrlParams("SVO-LED-20250115-08001800")).toEqual({ + departure: "SVO", + arrival: "LED", + date: "20250115", + timeFrom: "0800", + timeTo: "1800", + }); + }); + + it("returns null for empty string", () => { + expect(parseRouteUrlParams("")).toBeNull(); + }); + + it("returns null for missing date", () => { + expect(parseRouteUrlParams("SVO-LED")).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// parseOnlineBoardUrl +// --------------------------------------------------------------------------- + +describe("parseOnlineBoardUrl", () => { + it("parses start page (no trailing slash)", () => { + expect(parseOnlineBoardUrl("/onlineboard")).toEqual({ type: "start" }); + }); + + it("parses start page (with trailing slash)", () => { + expect(parseOnlineBoardUrl("/onlineboard/")).toEqual({ type: "start" }); + }); + + it("parses flight search URL", () => { + expect(parseOnlineBoardUrl("/onlineboard/flight/SU0100-20250115")).toEqual({ + type: "flight", + carrier: "SU", + flightNumber: "0100", + date: "20250115", + }); + }); + + it("parses flight search URL with suffix", () => { + expect(parseOnlineBoardUrl("/onlineboard/flight/SU0100D-20250115")).toEqual({ + type: "flight", + carrier: "SU", + flightNumber: "0100", + suffix: "D", + date: "20250115", + }); + }); + + it("parses departure search URL", () => { + expect(parseOnlineBoardUrl("/onlineboard/departure/SVO-20250115")).toEqual({ + type: "departure", + station: "SVO", + date: "20250115", + }); + }); + + it("parses departure search URL with time range", () => { + expect( + parseOnlineBoardUrl("/onlineboard/departure/SVO-20250115-08001800"), + ).toEqual({ + type: "departure", + station: "SVO", + date: "20250115", + timeFrom: "0800", + timeTo: "1800", + }); + }); + + it("parses arrival search URL", () => { + expect(parseOnlineBoardUrl("/onlineboard/arrival/LED-20250115")).toEqual({ + type: "arrival", + station: "LED", + date: "20250115", + }); + }); + + it("parses arrival search URL with time range", () => { + expect( + parseOnlineBoardUrl("/onlineboard/arrival/LED-20250115-08001800"), + ).toEqual({ + type: "arrival", + station: "LED", + date: "20250115", + timeFrom: "0800", + timeTo: "1800", + }); + }); + + it("parses route search URL", () => { + expect(parseOnlineBoardUrl("/onlineboard/route/SVO-LED-20250115")).toEqual({ + type: "route", + departure: "SVO", + arrival: "LED", + date: "20250115", + }); + }); + + it("parses route search URL with time range", () => { + expect( + parseOnlineBoardUrl("/onlineboard/route/SVO-LED-20250115-08001800"), + ).toEqual({ + type: "route", + departure: "SVO", + arrival: "LED", + date: "20250115", + timeFrom: "0800", + timeTo: "1800", + }); + }); + + it("parses details URL (no /flight/ prefix)", () => { + expect(parseOnlineBoardUrl("/onlineboard/SU0100-20250115")).toEqual({ + type: "details", + carrier: "SU", + flightNumber: "0100", + date: "20250115", + }); + }); + + it("parses details URL with suffix", () => { + expect(parseOnlineBoardUrl("/onlineboard/SU0100D-20250115")).toEqual({ + type: "details", + carrier: "SU", + flightNumber: "0100", + suffix: "D", + date: "20250115", + }); + }); + + it("returns null for non-onlineboard path", () => { + expect(parseOnlineBoardUrl("/some/other/path")).toBeNull(); + }); + + it("returns null for empty string", () => { + expect(parseOnlineBoardUrl("")).toBeNull(); + }); + + it("returns null for invalid flight URL params", () => { + expect(parseOnlineBoardUrl("/onlineboard/flight/")).toBeNull(); + }); + + it("returns null for invalid departure URL params", () => { + expect(parseOnlineBoardUrl("/onlineboard/departure/")).toBeNull(); + }); + + it("handles path without leading slash", () => { + expect(parseOnlineBoardUrl("onlineboard")).toEqual({ type: "start" }); + }); + + it("handles path without leading slash for flight", () => { + expect(parseOnlineBoardUrl("onlineboard/flight/SU0100-20250115")).toEqual({ + type: "flight", + carrier: "SU", + flightNumber: "0100", + date: "20250115", + }); + }); +}); + +// --------------------------------------------------------------------------- +// buildOnlineBoardUrl +// --------------------------------------------------------------------------- + +describe("buildOnlineBoardUrl", () => { + it("builds start page URL", () => { + expect(buildOnlineBoardUrl({ type: "start" })).toBe("onlineboard"); + }); + + it("builds flight search URL", () => { + expect( + buildOnlineBoardUrl({ + type: "flight", + carrier: "SU", + flightNumber: "100", + date: "20250115", + }), + ).toBe("onlineboard/flight/SU0100-20250115"); + }); + + it("builds flight search URL with suffix", () => { + expect( + buildOnlineBoardUrl({ + type: "flight", + carrier: "SU", + flightNumber: "0100", + suffix: "D", + date: "20250115", + }), + ).toBe("onlineboard/flight/SU0100D-20250115"); + }); + + it("builds departure search URL without time range", () => { + expect( + buildOnlineBoardUrl({ + type: "departure", + station: "SVO", + date: "20250115", + }), + ).toBe("onlineboard/departure/SVO-20250115"); + }); + + it("builds departure search URL with time range", () => { + expect( + buildOnlineBoardUrl({ + type: "departure", + station: "SVO", + date: "20250115", + timeFrom: "0800", + timeTo: "1800", + }), + ).toBe("onlineboard/departure/SVO-20250115-08001800"); + }); + + it("builds arrival search URL without time range", () => { + expect( + buildOnlineBoardUrl({ + type: "arrival", + station: "LED", + date: "20250115", + }), + ).toBe("onlineboard/arrival/LED-20250115"); + }); + + it("builds arrival search URL with time range", () => { + expect( + buildOnlineBoardUrl({ + type: "arrival", + station: "LED", + date: "20250115", + timeFrom: "0800", + timeTo: "1800", + }), + ).toBe("onlineboard/arrival/LED-20250115-08001800"); + }); + + it("builds route search URL without time range", () => { + expect( + buildOnlineBoardUrl({ + type: "route", + departure: "SVO", + arrival: "LED", + date: "20250115", + }), + ).toBe("onlineboard/route/SVO-LED-20250115"); + }); + + it("builds route search URL with time range", () => { + expect( + buildOnlineBoardUrl({ + type: "route", + departure: "SVO", + arrival: "LED", + date: "20250115", + timeFrom: "0800", + timeTo: "1800", + }), + ).toBe("onlineboard/route/SVO-LED-20250115-08001800"); + }); + + it("builds details URL", () => { + expect( + buildOnlineBoardUrl({ + type: "details", + carrier: "SU", + flightNumber: "0100", + date: "20250115", + }), + ).toBe("onlineboard/SU0100-20250115"); + }); + + it("builds details URL with suffix", () => { + expect( + buildOnlineBoardUrl({ + type: "details", + carrier: "SU", + flightNumber: "0100", + suffix: "D", + date: "20250115", + }), + ).toBe("onlineboard/SU0100D-20250115"); + }); + + it("omits time range when only timeFrom is provided", () => { + expect( + buildOnlineBoardUrl({ + type: "departure", + station: "SVO", + date: "20250115", + timeFrom: "0800", + }), + ).toBe("onlineboard/departure/SVO-20250115"); + }); + + it("omits time range when only timeTo is provided", () => { + expect( + buildOnlineBoardUrl({ + type: "departure", + station: "SVO", + date: "20250115", + timeTo: "1800", + }), + ).toBe("onlineboard/departure/SVO-20250115"); + }); +}); + +// --------------------------------------------------------------------------- +// Roundtrip tests: build -> parse -> build +// --------------------------------------------------------------------------- + +describe("roundtrip: build -> parse -> build", () => { + const cases: Array<{ name: string; params: OnlineBoardParams }> = [ + { name: "start", params: { type: "start" } }, + { + name: "flight without suffix", + params: { type: "flight", carrier: "SU", flightNumber: "0100", date: "20250115" }, + }, + { + name: "flight with suffix", + params: { + type: "flight", + carrier: "SU", + flightNumber: "0100", + suffix: "D", + date: "20250115", + }, + }, + { + name: "departure without time", + params: { type: "departure", station: "SVO", date: "20250115" }, + }, + { + name: "departure with time", + params: { + type: "departure", + station: "SVO", + date: "20250115", + timeFrom: "0800", + timeTo: "1800", + }, + }, + { + name: "arrival without time", + params: { type: "arrival", station: "LED", date: "20250115" }, + }, + { + name: "arrival with time", + params: { + type: "arrival", + station: "LED", + date: "20250115", + timeFrom: "0600", + timeTo: "2200", + }, + }, + { + name: "route without time", + params: { type: "route", departure: "SVO", arrival: "LED", date: "20250115" }, + }, + { + name: "route with time", + params: { + type: "route", + departure: "SVO", + arrival: "LED", + date: "20250115", + timeFrom: "0800", + timeTo: "1800", + }, + }, + { + name: "details without suffix", + params: { type: "details", carrier: "SU", flightNumber: "0100", date: "20250115" }, + }, + { + name: "details with suffix", + params: { + type: "details", + carrier: "SU", + flightNumber: "0100", + suffix: "D", + date: "20250115", + }, + }, + ]; + + for (const { name, params } of cases) { + it(`roundtrips ${name}`, () => { + const url = buildOnlineBoardUrl(params); + const parsed = parseOnlineBoardUrl(url); + expect(parsed).not.toBeNull(); + if (parsed === null) return; // type guard for TS + const rebuilt = buildOnlineBoardUrl(parsed); + expect(rebuilt).toBe(url); + }); + } +}); + +// --------------------------------------------------------------------------- +// Edge cases +// --------------------------------------------------------------------------- + +describe("edge cases", () => { + it("parse returns null for completely invalid path", () => { + expect(parseOnlineBoardUrl("/foo/bar")).toBeNull(); + }); + + it("parse returns null for onlineboard with unknown sub-path that doesn't look like a flight", () => { + // "xyz" alone doesn't parse as a flight ID (no dash with date) + expect(parseOnlineBoardUrl("/onlineboard/xyz")).toBeNull(); + }); + + it("handles flight number build roundtrip: 3-digit input gets padded", () => { + const built = buildOnlineBoardUrl({ + type: "flight", + carrier: "SU", + flightNumber: "100", + date: "20250115", + }); + expect(built).toBe("onlineboard/flight/SU0100-20250115"); + + const parsed = parseOnlineBoardUrl(built); + expect(parsed).toEqual({ + type: "flight", + carrier: "SU", + flightNumber: "0100", + date: "20250115", + }); + }); + + it("handles 3-char carrier in roundtrip", () => { + const params: OnlineBoardParams = { + type: "flight", + carrier: "SUA", + flightNumber: "0100", + date: "20250115", + }; + const url = buildOnlineBoardUrl(params); + const parsed = parseOnlineBoardUrl(url); + expect(parsed).toEqual(params); + }); + + it("parse returns null for route with insufficient segments", () => { + expect(parseOnlineBoardUrl("/onlineboard/route/SVO")).toBeNull(); + }); + + it("station time range with partial time returns no time", () => { + // Only 4 chars for time (missing second half) -> no timeFrom/timeTo + expect(parseStationUrlParams("SVO-20250115-0800")).toBeNull(); + }); +}); diff --git a/src/features/online-board/url.ts b/src/features/online-board/url.ts new file mode 100644 index 00000000..47fb6f30 --- /dev/null +++ b/src/features/online-board/url.ts @@ -0,0 +1,395 @@ +/** + * 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); + + return `${id.carrier}${combined}-${id.date}`; +} + +/** + * 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)}`; + } + } +}