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.
This commit is contained in:
2026-04-15 09:20:56 +03:00
parent f7813b04b1
commit 1c5e85ea8e
3 changed files with 891 additions and 0 deletions
+101
View File
@@ -0,0 +1,101 @@
/**
* Data model types for the Schedule feature.
*
* Reuses flight types from online-board where possible.
* Schedule-specific types cover the different URL/API param shapes.
*
* @module
*/
import type { IFlight, ISimpleFlight, IBoardResponse } from "../online-board/types.js";
// Re-export shared flight types used by schedule
export type { IFlight, ISimpleFlight, IBoardResponse };
// ---------------------------------------------------------------------------
// URL parameter types
// ---------------------------------------------------------------------------
/**
* Parsed route direction params from a single URL segment.
* Format: {dep}-{arr}-{dateFrom}-{dateTo}[-{timeFromTo}][-C{connections}]
*/
export interface IScheduleRouteDirectionParams {
departure: string;
arrival: string;
dateFrom: string;
dateTo: string;
timeFrom?: string;
timeTo?: string;
connections?: number;
}
/**
* Full parsed schedule route params (one-way or round-trip).
*/
export interface IScheduleRouteParams {
outbound: IScheduleRouteDirectionParams;
inbound?: IScheduleRouteDirectionParams;
}
/**
* Parsed flight ID for schedule details (reuses online-board's IParsedFlightId shape).
*/
export interface IScheduleFlightId {
carrier: string;
flightNumber: string;
suffix?: string;
date: string;
}
/**
* Parsed details params from the catch-all URL.
*/
export interface IScheduleDetailsParams {
flights: IScheduleFlightId[];
}
// ---------------------------------------------------------------------------
// API request/response types
// ---------------------------------------------------------------------------
/**
* Parameters for POST schedule/1 search.
*/
export interface IScheduleSearchRequest {
departure: string;
arrival: string;
dateFrom: string;
dateTo: string;
timeFrom?: string;
timeTo?: string;
connections?: number;
attribute?: 1 | 2;
}
/**
* Schedule search API response -- array of flights.
*/
export type IScheduleResponse = IFlight[];
/**
* Schedule details API response -- same as online board response.
*/
export type IScheduleDetailsResponse = IBoardResponse;
/**
* Parameters for GET days/.../schedule/v1 calendar endpoint.
*/
export interface IScheduleCalendarParams {
date: string;
departure: string;
arrival: string;
connections: boolean;
}
/**
* Calendar days API response.
*/
export interface IScheduleDaysResponse {
days: string;
}
+502
View File
@@ -0,0 +1,502 @@
import { describe, it, expect } from "vitest";
import {
parseScheduleRouteParams,
buildScheduleRouteParams,
parseScheduleUrl,
buildScheduleUrl,
type ScheduleParams,
} from "./url";
// ---------------------------------------------------------------------------
// parseScheduleRouteParams
// ---------------------------------------------------------------------------
describe("parseScheduleRouteParams", () => {
it("parses route without time range or connections", () => {
expect(parseScheduleRouteParams("MOW-KUF-20220425-20220501")).toEqual({
departure: "MOW",
arrival: "KUF",
dateFrom: "20220425",
dateTo: "20220501",
});
});
it("parses route with time range", () => {
expect(parseScheduleRouteParams("MOW-KUF-20220425-20220501-06002200")).toEqual({
departure: "MOW",
arrival: "KUF",
dateFrom: "20220425",
dateTo: "20220501",
timeFrom: "0600",
timeTo: "2200",
});
});
it("parses route with connections only", () => {
expect(parseScheduleRouteParams("MOW-KUF-20220425-20220501-C1")).toEqual({
departure: "MOW",
arrival: "KUF",
dateFrom: "20220425",
dateTo: "20220501",
connections: 1,
});
});
it("parses route with connections=0", () => {
expect(parseScheduleRouteParams("MOW-KUF-20220425-20220501-C0")).toEqual({
departure: "MOW",
arrival: "KUF",
dateFrom: "20220425",
dateTo: "20220501",
connections: 0,
});
});
it("parses route with time range and connections", () => {
expect(parseScheduleRouteParams("MOW-KUF-20220425-20220501-06002200-C0")).toEqual({
departure: "MOW",
arrival: "KUF",
dateFrom: "20220425",
dateTo: "20220501",
timeFrom: "0600",
timeTo: "2200",
connections: 0,
});
});
it("returns null for empty string", () => {
expect(parseScheduleRouteParams("")).toBeNull();
});
it("returns null for insufficient segments", () => {
expect(parseScheduleRouteParams("MOW-KUF-20220425")).toBeNull();
});
it("returns null for invalid dateFrom", () => {
expect(parseScheduleRouteParams("MOW-KUF-2022-20220501")).toBeNull();
});
it("returns null for invalid dateTo", () => {
expect(parseScheduleRouteParams("MOW-KUF-20220425-2022")).toBeNull();
});
});
// ---------------------------------------------------------------------------
// buildScheduleRouteParams
// ---------------------------------------------------------------------------
describe("buildScheduleRouteParams", () => {
it("builds route without time range or connections", () => {
expect(buildScheduleRouteParams({
departure: "MOW",
arrival: "KUF",
dateFrom: "20220425",
dateTo: "20220501",
})).toBe("MOW-KUF-20220425-20220501");
});
it("builds route with time range", () => {
expect(buildScheduleRouteParams({
departure: "MOW",
arrival: "KUF",
dateFrom: "20220425",
dateTo: "20220501",
timeFrom: "0600",
timeTo: "2200",
})).toBe("MOW-KUF-20220425-20220501-06002200");
});
it("builds route with connections", () => {
expect(buildScheduleRouteParams({
departure: "MOW",
arrival: "KUF",
dateFrom: "20220425",
dateTo: "20220501",
connections: 1,
})).toBe("MOW-KUF-20220425-20220501-C1");
});
it("builds route with connections=0", () => {
expect(buildScheduleRouteParams({
departure: "MOW",
arrival: "KUF",
dateFrom: "20220425",
dateTo: "20220501",
connections: 0,
})).toBe("MOW-KUF-20220425-20220501-C0");
});
it("builds route with time range and connections", () => {
expect(buildScheduleRouteParams({
departure: "MOW",
arrival: "KUF",
dateFrom: "20220425",
dateTo: "20220501",
timeFrom: "0600",
timeTo: "2200",
connections: 0,
})).toBe("MOW-KUF-20220425-20220501-06002200-C0");
});
it("omits time range when only timeFrom is provided", () => {
expect(buildScheduleRouteParams({
departure: "MOW",
arrival: "KUF",
dateFrom: "20220425",
dateTo: "20220501",
timeFrom: "0600",
})).toBe("MOW-KUF-20220425-20220501");
});
});
// ---------------------------------------------------------------------------
// parseScheduleUrl
// ---------------------------------------------------------------------------
describe("parseScheduleUrl", () => {
it("parses start page (no trailing slash)", () => {
expect(parseScheduleUrl("/schedule")).toEqual({ type: "start" });
});
it("parses start page (with trailing slash)", () => {
expect(parseScheduleUrl("/schedule/")).toEqual({ type: "start" });
});
it("parses one-way route search", () => {
expect(parseScheduleUrl("/schedule/route/MOW-KUF-20220425-20220501")).toEqual({
type: "route",
outbound: {
departure: "MOW",
arrival: "KUF",
dateFrom: "20220425",
dateTo: "20220501",
},
});
});
it("parses one-way route with time range", () => {
expect(parseScheduleUrl("/schedule/route/MOW-KUF-20220425-20220501-06002200")).toEqual({
type: "route",
outbound: {
departure: "MOW",
arrival: "KUF",
dateFrom: "20220425",
dateTo: "20220501",
timeFrom: "0600",
timeTo: "2200",
},
});
});
it("parses one-way route with connections", () => {
expect(parseScheduleUrl("/schedule/route/MOW-KUF-20220425-20220501-C1")).toEqual({
type: "route",
outbound: {
departure: "MOW",
arrival: "KUF",
dateFrom: "20220425",
dateTo: "20220501",
connections: 1,
},
});
});
it("parses round-trip route search", () => {
expect(parseScheduleUrl("/schedule/route/MOW-KUF-20220425-20220501/KUF-MOW-20220502-20220508")).toEqual({
type: "roundtrip",
outbound: {
departure: "MOW",
arrival: "KUF",
dateFrom: "20220425",
dateTo: "20220501",
},
inbound: {
departure: "KUF",
arrival: "MOW",
dateFrom: "20220502",
dateTo: "20220508",
},
});
});
it("parses round-trip with time ranges", () => {
expect(parseScheduleUrl("/schedule/route/MOW-KUF-20220425-20220501-06002200/KUF-MOW-20220502-20220508-06002200")).toEqual({
type: "roundtrip",
outbound: {
departure: "MOW",
arrival: "KUF",
dateFrom: "20220425",
dateTo: "20220501",
timeFrom: "0600",
timeTo: "2200",
},
inbound: {
departure: "KUF",
arrival: "MOW",
dateFrom: "20220502",
dateTo: "20220508",
timeFrom: "0600",
timeTo: "2200",
},
});
});
it("parses round-trip with connections", () => {
expect(parseScheduleUrl("/schedule/route/MOW-KUF-20220425-20220501-C0/KUF-MOW-20220502-20220508-C0")).toEqual({
type: "roundtrip",
outbound: {
departure: "MOW",
arrival: "KUF",
dateFrom: "20220425",
dateTo: "20220501",
connections: 0,
},
inbound: {
departure: "KUF",
arrival: "MOW",
dateFrom: "20220502",
dateTo: "20220508",
connections: 0,
},
});
});
it("parses single-flight details", () => {
expect(parseScheduleUrl("/schedule/SU0012-20220527")).toEqual({
type: "details",
flights: [
{ carrier: "SU", flightNumber: "0012", date: "20220527" },
],
});
});
it("parses multi-flight details (connecting)", () => {
expect(parseScheduleUrl("/schedule/SU0012-20220501/SU0013-20220507")).toEqual({
type: "details",
flights: [
{ carrier: "SU", flightNumber: "0012", date: "20220501" },
{ carrier: "SU", flightNumber: "0013", date: "20220507" },
],
});
});
it("parses details with airport codes interleaved", () => {
// Format: /{depCode}/{flight-date}/{arrCode}/{depCode2}/{flight2-date}/{arrCode2}
// Airport codes (3-letter) are skipped, flight segments are parsed
expect(parseScheduleUrl("/schedule/SVO/SU0012-20220501/LED/LED/SU0013-20220507/SVO")).toEqual({
type: "details",
flights: [
{ carrier: "SU", flightNumber: "0012", date: "20220501" },
{ carrier: "SU", flightNumber: "0013", date: "20220507" },
],
});
});
it("returns null for empty string", () => {
expect(parseScheduleUrl("")).toBeNull();
});
it("returns null for non-schedule path", () => {
expect(parseScheduleUrl("/some/other/path")).toBeNull();
});
it("returns null for invalid route params", () => {
expect(parseScheduleUrl("/schedule/route/")).toBeNull();
});
it("handles path without leading slash", () => {
expect(parseScheduleUrl("schedule")).toEqual({ type: "start" });
});
it("handles path without leading slash for route", () => {
expect(parseScheduleUrl("schedule/route/MOW-KUF-20220425-20220501")).toEqual({
type: "route",
outbound: {
departure: "MOW",
arrival: "KUF",
dateFrom: "20220425",
dateTo: "20220501",
},
});
});
});
// ---------------------------------------------------------------------------
// buildScheduleUrl
// ---------------------------------------------------------------------------
describe("buildScheduleUrl", () => {
it("builds start page URL", () => {
expect(buildScheduleUrl({ type: "start" })).toBe("schedule");
});
it("builds one-way route URL", () => {
expect(buildScheduleUrl({
type: "route",
outbound: {
departure: "MOW",
arrival: "KUF",
dateFrom: "20220425",
dateTo: "20220501",
},
})).toBe("schedule/route/MOW-KUF-20220425-20220501");
});
it("builds one-way route with time range and connections", () => {
expect(buildScheduleUrl({
type: "route",
outbound: {
departure: "MOW",
arrival: "KUF",
dateFrom: "20220425",
dateTo: "20220501",
timeFrom: "0600",
timeTo: "2200",
connections: 0,
},
})).toBe("schedule/route/MOW-KUF-20220425-20220501-06002200-C0");
});
it("builds round-trip route URL", () => {
expect(buildScheduleUrl({
type: "roundtrip",
outbound: {
departure: "MOW",
arrival: "KUF",
dateFrom: "20220425",
dateTo: "20220501",
},
inbound: {
departure: "KUF",
arrival: "MOW",
dateFrom: "20220502",
dateTo: "20220508",
},
})).toBe("schedule/route/MOW-KUF-20220425-20220501/KUF-MOW-20220502-20220508");
});
it("builds round-trip with time ranges and connections", () => {
expect(buildScheduleUrl({
type: "roundtrip",
outbound: {
departure: "MOW",
arrival: "KUF",
dateFrom: "20220425",
dateTo: "20220501",
timeFrom: "0600",
timeTo: "2200",
connections: 0,
},
inbound: {
departure: "KUF",
arrival: "MOW",
dateFrom: "20220502",
dateTo: "20220508",
timeFrom: "0600",
timeTo: "2200",
connections: 0,
},
})).toBe("schedule/route/MOW-KUF-20220425-20220501-06002200-C0/KUF-MOW-20220502-20220508-06002200-C0");
});
it("builds single-flight details URL", () => {
expect(buildScheduleUrl({
type: "details",
flights: [
{ carrier: "SU", flightNumber: "0012", date: "20220527" },
],
})).toBe("schedule/SU0012-20220527");
});
it("builds multi-flight details URL", () => {
expect(buildScheduleUrl({
type: "details",
flights: [
{ carrier: "SU", flightNumber: "0012", date: "20220501" },
{ carrier: "SU", flightNumber: "0013", date: "20220507" },
],
})).toBe("schedule/SU0012-20220501/SU0013-20220507");
});
});
// ---------------------------------------------------------------------------
// Roundtrip tests: build -> parse -> build
// ---------------------------------------------------------------------------
describe("roundtrip: build -> parse -> build", () => {
const cases: Array<{ name: string; params: ScheduleParams }> = [
{ name: "start", params: { type: "start" } },
{
name: "one-way route without extras",
params: {
type: "route",
outbound: { departure: "MOW", arrival: "KUF", dateFrom: "20220425", dateTo: "20220501" },
},
},
{
name: "one-way route with time range",
params: {
type: "route",
outbound: { departure: "MOW", arrival: "KUF", dateFrom: "20220425", dateTo: "20220501", timeFrom: "0600", timeTo: "2200" },
},
},
{
name: "one-way route with connections",
params: {
type: "route",
outbound: { departure: "MOW", arrival: "KUF", dateFrom: "20220425", dateTo: "20220501", connections: 1 },
},
},
{
name: "one-way route with time range and connections",
params: {
type: "route",
outbound: { departure: "MOW", arrival: "KUF", dateFrom: "20220425", dateTo: "20220501", timeFrom: "0600", timeTo: "2200", connections: 0 },
},
},
{
name: "round-trip without extras",
params: {
type: "roundtrip",
outbound: { departure: "MOW", arrival: "KUF", dateFrom: "20220425", dateTo: "20220501" },
inbound: { departure: "KUF", arrival: "MOW", dateFrom: "20220502", dateTo: "20220508" },
},
},
{
name: "round-trip with time range and connections",
params: {
type: "roundtrip",
outbound: { departure: "MOW", arrival: "KUF", dateFrom: "20220425", dateTo: "20220501", timeFrom: "0600", timeTo: "2200", connections: 0 },
inbound: { departure: "KUF", arrival: "MOW", dateFrom: "20220502", dateTo: "20220508", timeFrom: "0600", timeTo: "2200", connections: 0 },
},
},
{
name: "single-flight details",
params: {
type: "details",
flights: [{ carrier: "SU", flightNumber: "0012", date: "20220527" }],
},
},
{
name: "multi-flight details",
params: {
type: "details",
flights: [
{ carrier: "SU", flightNumber: "0012", date: "20220501" },
{ carrier: "SU", flightNumber: "0013", date: "20220507" },
],
},
},
];
for (const { name, params } of cases) {
it(`roundtrips ${name}`, () => {
const url = buildScheduleUrl(params);
const parsed = parseScheduleUrl(url);
expect(parsed).not.toBeNull();
if (parsed === null) return;
const rebuilt = buildScheduleUrl(parsed);
expect(rebuilt).toBe(url);
});
}
});
+288
View File
@@ -0,0 +1,288 @@
/**
* 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("/")}`;
}
}
}