a5c64a2270
- AbortController wired through ApiClient → api functions → hooks so a new search immediately aborts the previous in-flight request (§4.1.12) - cancel() exposed from useOnlineBoard / useScheduleSearch; Escape key triggers it while the loader is showing (§4.1.12) - «Отменить поиск» button rendered during loading; hides when idle (§4.1.12) - data-searching attribute on search pages disables filter/tabs/breadcrumbs via pointer-events:none CSS while a search is running (§4.1.10/11) - Submit buttons disabled for 30 s after each search (hardcoded, per TZ §4.1.10/11: «не должно выноситься в конфигурационный файл») - Per-status error messages: BOARD.ERROR-TIMEOUT / ERROR-4XX / ERROR-5XX replace the generic LOAD-FAILED-MESSAGE (§4.1.10.1/11.1) - Error messages added to all 9 locales - 8 new tests: 3 for AbortController wiring, 5 for error banners + cancel button visibility
169 lines
5.7 KiB
TypeScript
169 lines
5.7 KiB
TypeScript
/**
|
|
* Schedule API functions.
|
|
*
|
|
* Pure functions -- each takes an `ApiClient` as a parameter (dependency
|
|
* injection). No React hooks, no context, no side effects.
|
|
*
|
|
* @module
|
|
*/
|
|
|
|
import type { ApiClient } from "@/shared/api/client.js";
|
|
import type {
|
|
IScheduleSearchRequest,
|
|
IScheduleResponse,
|
|
IScheduleDetailsResponse,
|
|
IScheduleCalendarParams,
|
|
IScheduleDaysResponse,
|
|
} from "./types.js";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// API functions
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Search schedule flights.
|
|
* Maps to: `GET schedule/1?departure=SVO&arrival=LED&dateFrom=2026-04-18&dateTo=2026-04-26&...`
|
|
*
|
|
* Mirrors Angular's ScheduleApiService.getFlights which:
|
|
* - uses GET with query params (not POST with JSON body)
|
|
* - sends dateTo = requested end + 1 day (half-open interval)
|
|
* - coerces `connections` to a string ('0' / '1')
|
|
* - drops empty timeFrom/timeTo
|
|
*/
|
|
export async function searchSchedule(
|
|
client: ApiClient,
|
|
params: IScheduleSearchRequest,
|
|
signal?: AbortSignal,
|
|
): Promise<IScheduleResponse> {
|
|
const query: Record<string, string> = {
|
|
departure: params.departure,
|
|
arrival: params.arrival,
|
|
dateFrom: params.dateFrom,
|
|
dateTo: addOneDayIso(params.dateTo),
|
|
connections: String(params.connections ?? 1),
|
|
};
|
|
if (params.timeFrom) query["timeFrom"] = params.timeFrom;
|
|
if (params.timeTo) query["timeTo"] = params.timeTo;
|
|
if (params.attribute) query["attribute"] = String(params.attribute);
|
|
return client.get<IScheduleResponse>(
|
|
`flights/1/${client.locale}/schedule`,
|
|
query,
|
|
signal,
|
|
);
|
|
}
|
|
|
|
/** Return the date one day after a yyyy-MM-dd string (passthrough on bad input). */
|
|
function addOneDayIso(iso: string): string {
|
|
const m = /^(\d{4})-(\d{2})-(\d{2})(.*)$/.exec(iso);
|
|
if (!m) return iso;
|
|
const [, y, mm, dd, tail] = m;
|
|
const d = new Date(Number(y), Number(mm) - 1, Number(dd));
|
|
d.setDate(d.getDate() + 1);
|
|
const ny = d.getFullYear().toString();
|
|
const nm = (d.getMonth() + 1).toString().padStart(2, "0");
|
|
const nd = d.getDate().toString().padStart(2, "0");
|
|
return `${ny}-${nm}-${nd}${tail ?? ""}`;
|
|
}
|
|
|
|
/**
|
|
* Get multi-flight schedule details.
|
|
* Maps to: `GET schedule/details?flights[0]=...&dates[0]=...&departure=...&arrival=...`
|
|
*
|
|
* The Angular service uses indexed query params (flights[0], flights[1], etc.).
|
|
* We build the query object to match that format.
|
|
*/
|
|
export async function getScheduleDetails(
|
|
client: ApiClient,
|
|
params: {
|
|
flights: string[];
|
|
dates: string[];
|
|
departure: string;
|
|
arrival: string;
|
|
},
|
|
): Promise<IScheduleDetailsResponse> {
|
|
// Omit blank departure/arrival so the upstream doesn't reject the
|
|
// request. Angular's details-data-source.service falls back to
|
|
// per-leg values when the page-level params are missing; the
|
|
// backend accepts the request either way.
|
|
const query: Record<string, string> = {};
|
|
if (params.departure) query["departure"] = params.departure;
|
|
if (params.arrival) query["arrival"] = params.arrival;
|
|
|
|
for (let i = 0; i < params.flights.length; i++) {
|
|
const flight = params.flights[i];
|
|
if (flight !== undefined) query[`flights[${i}]`] = flight;
|
|
}
|
|
|
|
for (let i = 0; i < params.dates.length; i++) {
|
|
const date = params.dates[i];
|
|
if (date !== undefined) query[`dates[${i}]`] = date;
|
|
}
|
|
|
|
return client.get<IScheduleDetailsResponse>(`flights/v1.1/${client.locale}/schedule/details`, query);
|
|
}
|
|
|
|
/**
|
|
* Get available calendar days for a given route.
|
|
* Maps to: `GET days/{date}/382/{param}/schedule/v1`
|
|
*
|
|
* The API returns `{ days: "1111110001..." }` — a 382-char bitmask
|
|
* where each character represents a day starting from (baseDate - 1).
|
|
* '1' = available, '0' = no flights. Equivalent to the board endpoint.
|
|
*/
|
|
export async function getScheduleCalendarDays(
|
|
client: ApiClient,
|
|
params: IScheduleCalendarParams,
|
|
): Promise<string[]> {
|
|
const routeSegment = params.connections
|
|
? `connections/${params.departure}-${params.arrival}-1`
|
|
: `route/${params.departure}-${params.arrival}`;
|
|
|
|
const path = `flights/v1/${client.locale}/days/${params.date}/382/${routeSegment}/schedule/`;
|
|
|
|
const response = await client.get<IScheduleDaysResponse>(path);
|
|
return parseCalendarDays(response.days, params.date);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Internal helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Parse the /days response into an array of yyyy-MM-dd strings.
|
|
*
|
|
* Handles two shapes:
|
|
* - legacy comma-separated ("2025-01-01,2025-01-02,…")
|
|
* - bitmask ("1111000…") where each position maps to a day starting from
|
|
* `baseDate - 1`. This is what the upstream actually returns today.
|
|
*/
|
|
function parseCalendarDays(days: string, baseDate: string): string[] {
|
|
if (!days) return [];
|
|
if (/^[01]+$/.test(days)) {
|
|
return bitmaskToDates(days, baseDate);
|
|
}
|
|
return days.split(",").map((d) => d.trim()).filter(Boolean);
|
|
}
|
|
|
|
function bitmaskToDates(bitmask: string, baseDate: string): string[] {
|
|
// baseDate is yyyy-MM-dd (possibly with Txx:xx:xx suffix).
|
|
const iso = baseDate.includes("T")
|
|
? baseDate.split("T")[0] ?? baseDate
|
|
: baseDate;
|
|
const [y, m, d] = iso.split("-");
|
|
if (!y || !m || !d) return [];
|
|
const cursor = new Date(Number(y), Number(m) - 1, Number(d));
|
|
cursor.setDate(cursor.getDate() - 1);
|
|
|
|
const result: string[] = [];
|
|
for (let i = 0; i < bitmask.length; i++) {
|
|
if (bitmask[i] === "1") {
|
|
const yy = cursor.getFullYear().toString();
|
|
const mm = (cursor.getMonth() + 1).toString().padStart(2, "0");
|
|
const dd = cursor.getDate().toString().padStart(2, "0");
|
|
result.push(`${yy}-${mm}-${dd}`);
|
|
}
|
|
cursor.setDate(cursor.getDate() + 1);
|
|
}
|
|
return result;
|
|
}
|