Files
flights_web/src/features/schedule/api.ts
T
gnezim a5c64a2270 Search execution, cancellation, and error handling per TZ §4.1.10/11/12
- 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
2026-04-21 22:08:11 +03:00

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;
}