diff --git a/src/features/schedule/components/ScheduleFlightBody.scss b/src/features/schedule/components/ScheduleFlightBody.scss
index 02a4e8b6..db8f8911 100644
--- a/src/features/schedule/components/ScheduleFlightBody.scss
+++ b/src/features/schedule/components/ScheduleFlightBody.scss
@@ -224,6 +224,26 @@
&:hover { background: colors.$blue--hover; }
}
+ // §4.1.14.4.6 – Детали рейса: always-visible outline button (white bg,
+ // blue border+text), mirrors Angular's secondary CTA style.
+ &__details-btn {
+ background: colors.$white;
+ color: colors.$blue;
+ border: 1px solid colors.$blue;
+ border-radius: vars.$border-radius;
+ padding: vars.$space-m 24px;
+ font-size: fonts.$font-size-m;
+ font-weight: fonts.$font-medium;
+ cursor: pointer;
+ min-width: 150px;
+ transition: background-color 0.2s ease, color 0.2s ease;
+
+ &:hover {
+ background: colors.$blue;
+ color: colors.$white;
+ }
+ }
+
// ----- horizontal timeline (route summary) -----------------------------
&__timeline {
padding: vars.$space-l vars.$space-xl vars.$space-m;
diff --git a/src/features/schedule/components/ScheduleFlightBody.test.tsx b/src/features/schedule/components/ScheduleFlightBody.test.tsx
new file mode 100644
index 00000000..bfd2c152
--- /dev/null
+++ b/src/features/schedule/components/ScheduleFlightBody.test.tsx
@@ -0,0 +1,344 @@
+// @vitest-environment jsdom
+/**
+ * Tests for ScheduleFlightBody – TZ §4.1.14.4 compliance assertions.
+ *
+ * Coverage:
+ * - Direct flight: leg number, flight-id, operator logo, dep/arr times, duration
+ * - Transfer box between legs (multi-leg + connecting): label + duration + station info
+ * - Buy button visibility gate: >2h ahead AND <330 days ahead (TZ §4.1.14.4.4)
+ * - Status button visibility gate: same-day departure only (TZ §4.1.14.4.5)
+ * - Share button always present
+ * - Actions area rendered at bottom
+ */
+
+import { describe, it, expect, vi } from "vitest";
+import { render, screen, fireEvent } from "@testing-library/react";
+import type { ISimpleFlight, IFlightLeg } from "@/features/online-board/types.js";
+
+// ── i18n stub ─────────────────────────────────────────────────────────────────
+vi.mock("@/i18n/provider.js", () => ({
+ useTranslation: () => ({ t: (k: string) => k }),
+}));
+vi.mock("@/i18n/useLocale.js", () => ({
+ useLocale: () => ({ language: "ru", locale: "ru-ru" }),
+}));
+
+// ── UI stubs ──────────────────────────────────────────────────────────────────
+vi.mock("@/ui/flights/TimeGroup.js", () => ({
+ TimeGroup: ({ scheduled }: { scheduled: string }) => (
+ {scheduled}
+ ),
+}));
+vi.mock("@/ui/flights/StationDisplay.js", () => ({
+ StationDisplay: ({ airportCode }: { airportCode: string }) => (
+ {airportCode}
+ ),
+}));
+vi.mock("@/ui/flights/OperatorLogo.js", () => ({
+ OperatorLogo: ({ carrier }: { carrier: string }) => (
+ {carrier}
+ ),
+}));
+
+import { ScheduleFlightBody } from "./ScheduleFlightBody.js";
+
+// ── Helpers ───────────────────────────────────────────────────────────────────
+
+function makeTimesSet(utc: string, local: string) {
+ return {
+ scheduledDeparture: {
+ utc,
+ local,
+ localTime: local,
+ tzOffset: 0,
+ dayChange: { value: 0, title: "" },
+ },
+ };
+}
+
+function makeArrivalTimesSet(utc: string, local: string) {
+ return {
+ scheduledArrival: {
+ utc,
+ local,
+ localTime: local,
+ tzOffset: 0,
+ dayChange: { value: 0, title: "" },
+ },
+ };
+}
+
+function makeStation(code: string, city: string, airport: string): {
+ scheduled: { airportCode: string; city: string; airport: string; cityCode: string; countryCode: string };
+ terminal: string;
+} {
+ return {
+ scheduled: {
+ airportCode: code,
+ city,
+ airport,
+ cityCode: code,
+ countryCode: "RU",
+ },
+ terminal: "",
+ };
+}
+
+function makeLeg(depUtc: string, depLocal: string, arrUtc: string, arrLocal: string, depCode = "SVO", arrCode = "LED"): IFlightLeg {
+ return {
+ departure: {
+ ...makeStation(depCode, "Moscow", "Sheremetyevo"),
+ times: makeTimesSet(depUtc, depLocal),
+ checkingStatus: "",
+ },
+ arrival: {
+ ...makeStation(arrCode, "Saint Petersburg", "Pulkovo"),
+ times: makeArrivalTimesSet(arrUtc, arrLocal),
+ },
+ flyingTime: "02:00:00",
+ equipment: {},
+ operatingBy: {},
+ status: "Scheduled",
+ flags: { checkinAvailable: false, returnToAirport: false, routeChanged: false },
+ dayChange: 0,
+ index: 0,
+ updated: "",
+ } as unknown as IFlightLeg;
+}
+
+function makeDirectFlight(depUtc: string, depLocal = "10:00:00"): ISimpleFlight {
+ const arrUtc = new Date(new Date(depUtc).getTime() + 2 * 3600 * 1000).toISOString();
+ return {
+ routeType: "Direct",
+ id: "test-direct",
+ flyingTime: "02:00:00",
+ status: "Scheduled",
+ flightId: { carrier: "SU", flightNumber: "1234", date: depUtc.slice(0, 10) },
+ operatingBy: {},
+ leg: makeLeg(depUtc, depLocal, arrUtc, "12:00:00"),
+ } as unknown as ISimpleFlight;
+}
+
+function makeMultiLegFlight(depUtc: string): ISimpleFlight {
+ const mid = new Date(new Date(depUtc).getTime() + 2 * 3600 * 1000).toISOString();
+ const midNext = new Date(new Date(mid).getTime() + 1.5 * 3600 * 1000).toISOString();
+ const arr = new Date(new Date(midNext).getTime() + 3 * 3600 * 1000).toISOString();
+ return {
+ routeType: "MultiLeg",
+ id: "test-multileg",
+ flyingTime: "06:30:00",
+ status: "Scheduled",
+ flightId: { carrier: "SU", flightNumber: "5678", date: depUtc.slice(0, 10) },
+ operatingBy: {},
+ legs: [
+ makeLeg(depUtc, "10:00", mid, "12:00", "SVO", "KJA"),
+ makeLeg(midNext, "13:30", arr, "16:30", "KJA", "LED"),
+ ],
+ } as unknown as ISimpleFlight;
+}
+
+function makeConnectingFlight(depUtc: string): ISimpleFlight {
+ const mid = new Date(new Date(depUtc).getTime() + 2 * 3600 * 1000).toISOString();
+ const midNext = new Date(new Date(mid).getTime() + 2 * 3600 * 1000).toISOString();
+ const arr = new Date(new Date(midNext).getTime() + 3 * 3600 * 1000).toISOString();
+ return {
+ routeType: "MultiLeg",
+ id: "su6188+su6233",
+ flyingTime: "07:00:00",
+ status: "Scheduled",
+ flightId: { carrier: "SU", flightNumber: "6188", date: depUtc.slice(0, 10) },
+ operatingBy: {},
+ legs: [
+ makeLeg(depUtc, "10:00", mid, "12:00", "SVO", "KUF"),
+ makeLeg(midNext, "14:00", arr, "17:00", "KUF", "LED"),
+ ],
+ _childFlightIds: [
+ { carrier: "SU", flightNumber: "6188" },
+ { carrier: "SU", flightNumber: "6233" },
+ ],
+ } as unknown as ISimpleFlight;
+}
+
+// ── Buy-gate helpers ──────────────────────────────────────────────────────────
+
+/** 10 days from now — buy should be visible */
+const FUTURE_10D = new Date(Date.now() + 10 * 24 * 3600 * 1000).toISOString();
+/** 340 days from now — buy should be hidden (> 330 days) */
+const FUTURE_340D = new Date(Date.now() + 340 * 24 * 3600 * 1000).toISOString();
+/** 1 hour from now — buy should be hidden (< 2h threshold) */
+const FUTURE_1H = new Date(Date.now() + 1 * 3600 * 1000).toISOString();
+/** Yesterday — buy should be hidden (past) */
+const YESTERDAY = new Date(Date.now() - 24 * 3600 * 1000).toISOString();
+/** Today (same calendar day) — status button should be visible */
+function todayUtc(offsetHours = 3): string {
+ const now = new Date();
+ now.setHours(now.getHours() + offsetHours);
+ return now.toISOString();
+}
+
+// ── Tests ─────────────────────────────────────────────────────────────────────
+
+describe("ScheduleFlightBody – TZ §4.1.14.4", () => {
+
+ describe("Direct flight structure", () => {
+ it("renders schedule-flight-body container", () => {
+ render();
+ expect(screen.getByTestId("schedule-flight-body")).toBeTruthy();
+ });
+
+ it("renders leg number badge '1' for single leg", () => {
+ render();
+ expect(screen.getByText("1")).toBeTruthy();
+ });
+
+ it("renders flight number SU 1234", () => {
+ render();
+ expect(screen.getByText("SU 1234")).toBeTruthy();
+ });
+
+ it("renders operator logo for the leg carrier", () => {
+ render();
+ expect(screen.getByTestId("operator-logo")).toBeTruthy();
+ });
+
+ it("renders departure and arrival station codes", () => {
+ render();
+ const stations = screen.getAllByTestId("station-display");
+ expect(stations.some((s) => s.textContent === "SVO")).toBe(true);
+ expect(stations.some((s) => s.textContent === "LED")).toBe(true);
+ });
+
+ it("renders share button always (TZ §4.1.14.4.3)", () => {
+ render();
+ expect(screen.getByTestId("schedule-share-button")).toBeTruthy();
+ });
+ });
+
+ describe("Multi-leg flight structure", () => {
+ it("renders 2 leg rows for multi-leg flight", () => {
+ render();
+ // Both leg numbers should appear
+ const nums = screen.getAllByText(/^[12]$/);
+ expect(nums.length).toBeGreaterThanOrEqual(2);
+ });
+
+ it("renders horizontal timeline for multi-leg (TZ §4.1.14.4 timeline row)", () => {
+ render();
+ expect(screen.getByTestId("schedule-timeline")).toBeTruthy();
+ });
+
+ it("renders transfer box between legs", () => {
+ render();
+ expect(screen.getByTestId("flight-transfer")).toBeTruthy();
+ });
+
+ it("shows SHARED.INTERMEDIATE-LANDING-PLURAL-ONE for multi-leg transfer", () => {
+ render();
+ expect(screen.getByText("SHARED.INTERMEDIATE-LANDING-PLURAL-ONE")).toBeTruthy();
+ });
+
+ it("renders transfer content area in transfer box", () => {
+ render();
+ // Transfer box rendered; content (icon + label) is present
+ const transfer = screen.getByTestId("flight-transfer");
+ expect(transfer.textContent).toContain("SHARED.INTERMEDIATE-LANDING-PLURAL-ONE");
+ });
+ });
+
+ describe("Connecting flight structure", () => {
+ it("renders separate flight numbers per leg for connecting flight", () => {
+ render();
+ expect(screen.getByText("SU 6188")).toBeTruthy();
+ expect(screen.getByText("SU 6233")).toBeTruthy();
+ });
+
+ it("shows SHARED.FLIGHT-TRANSFER label for connecting transfer box", () => {
+ render();
+ expect(screen.getByText("SHARED.FLIGHT-TRANSFER")).toBeTruthy();
+ });
+ });
+
+ // ────────────────────────────────────────────────────────────────────────────
+ // TZ §4.1.14.4.4 — Buy button visibility gate
+ // ────────────────────────────────────────────────────────────────────────────
+
+ describe("Buy button – TZ §4.1.14.4.4", () => {
+ it("shows buy button when departure is 10 days ahead", () => {
+ const onBuy = vi.fn();
+ render();
+ expect(screen.getByTestId("schedule-buy-button")).toBeTruthy();
+ });
+
+ it("hides buy button when departure is > 330 days ahead", () => {
+ const onBuy = vi.fn();
+ render();
+ expect(screen.queryByTestId("schedule-buy-button")).toBeNull();
+ });
+
+ it("hides buy button when departure is less than 2h away", () => {
+ const onBuy = vi.fn();
+ render();
+ expect(screen.queryByTestId("schedule-buy-button")).toBeNull();
+ });
+
+ it("hides buy button when departure is in the past", () => {
+ const onBuy = vi.fn();
+ render();
+ expect(screen.queryByTestId("schedule-buy-button")).toBeNull();
+ });
+
+ it("hides buy button when onBuy prop not provided", () => {
+ render();
+ expect(screen.queryByTestId("schedule-buy-button")).toBeNull();
+ });
+
+ it("calls onBuy handler when buy button clicked", () => {
+ const onBuy = vi.fn();
+ render();
+ fireEvent.click(screen.getByTestId("schedule-buy-button"));
+ expect(onBuy).toHaveBeenCalledOnce();
+ });
+
+ it("uses first-leg departure UTC for multi-leg buy gate (TZ §4.1.14.4.4)", () => {
+ const onBuy = vi.fn();
+ render();
+ expect(screen.getByTestId("schedule-buy-button")).toBeTruthy();
+ });
+ });
+
+ // ────────────────────────────────────────────────────────────────────────────
+ // TZ §4.1.14.4.5 — Status button (today-only)
+ // ────────────────────────────────────────────────────────────────────────────
+
+ describe("Status button – TZ §4.1.14.4.5", () => {
+ it("shows status button when flight departs today", () => {
+ const onStatus = vi.fn();
+ render();
+ expect(screen.getByTestId("schedule-status-button")).toBeTruthy();
+ });
+
+ it("hides status button when flight departs 10 days from now", () => {
+ const onStatus = vi.fn();
+ render();
+ expect(screen.queryByTestId("schedule-status-button")).toBeNull();
+ });
+
+ it("hides status button when flight departed yesterday", () => {
+ const onStatus = vi.fn();
+ render();
+ expect(screen.queryByTestId("schedule-status-button")).toBeNull();
+ });
+
+ it("hides status button when onStatus prop not provided", () => {
+ render();
+ expect(screen.queryByTestId("schedule-status-button")).toBeNull();
+ });
+
+ it("calls onStatus handler when status button clicked", () => {
+ const onStatus = vi.fn();
+ render();
+ fireEvent.click(screen.getByTestId("schedule-status-button"));
+ expect(onStatus).toHaveBeenCalledOnce();
+ });
+ });
+});
diff --git a/src/features/schedule/components/ScheduleFlightBody.tsx b/src/features/schedule/components/ScheduleFlightBody.tsx
index 32bd5655..8b1dac38 100644
--- a/src/features/schedule/components/ScheduleFlightBody.tsx
+++ b/src/features/schedule/components/ScheduleFlightBody.tsx
@@ -80,6 +80,46 @@ function transferDuration(prev: IFlightLeg, next: IFlightLeg): string {
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}:00`;
}
+// ── TZ §4.1.14.4.4 – Buy-button visibility gate ─────────────────────────────
+// Visible when:
+// • Departure UTC is > 2 h from now (not about to depart / already departed)
+// • Departure UTC is < 330 days from now
+// Eligibility is assessed on the FIRST leg of the flight (TZ §4.1.14.4.4:
+// "рассчитывается исходя из времени первого сегмента").
+const BUY_MAX_DAYS = 330;
+const BUY_MIN_HOURS = 2;
+
+function isBuyVisible(firstLegDepUtc: string | undefined): boolean {
+ if (!firstLegDepUtc) return false;
+ const depMs = new Date(firstLegDepUtc).getTime();
+ if (Number.isNaN(depMs)) return false;
+ const nowMs = Date.now();
+ const diffMs = depMs - nowMs;
+ if (diffMs <= 0) return false;
+ if (diffMs < BUY_MIN_HOURS * 3600 * 1000) return false;
+ if (diffMs > BUY_MAX_DAYS * 24 * 3600 * 1000) return false;
+ return true;
+}
+
+// ── TZ §4.1.14.4.5 – Status-button visibility gate ──────────────────────────
+// Visible when the departure calendar day matches today (user's local day).
+// Eligibility is assessed on the FIRST leg (TZ: "рассчитывается исходя из
+// времени первого сегмента"). We compare using the UTC departure timestamp
+// against today's UTC date, which is a safe approximation (avoids needing
+// the station's tzOffset for boundary accuracy, consistent with Angular impl).
+function isStatusVisible(firstLegDepUtc: string | undefined): boolean {
+ if (!firstLegDepUtc) return false;
+ const depMs = new Date(firstLegDepUtc).getTime();
+ if (Number.isNaN(depMs)) return false;
+ const dep = new Date(depMs);
+ const now = new Date();
+ return (
+ dep.getUTCFullYear() === now.getUTCFullYear() &&
+ dep.getUTCMonth() === now.getUTCMonth() &&
+ dep.getUTCDate() === now.getUTCDate()
+ );
+}
+
export const ScheduleFlightBody: FC = ({
flight,
onBuy,
@@ -92,6 +132,14 @@ export const ScheduleFlightBody: FC = ({
flight.routeType === "Direct" ? [flight.leg] : flight.legs;
if (legs.length === 0) return null;
+ const firstLeg = legs[0];
+ const buyVisible =
+ Boolean(onBuy) &&
+ isBuyVisible(firstLeg?.departure.times.scheduledDeparture.utc);
+ const statusVisible =
+ Boolean(onStatus) &&
+ isStatusVisible(firstLeg?.departure.times.scheduledDeparture.utc);
+
const childFlightIds = (flight as ISimpleFlight & {
_childFlightIds?: ChildFlightId[];
})._childFlightIds;
@@ -355,28 +403,49 @@ export const ScheduleFlightBody: FC = ({
-
-
+ {/* TZ §4.1.14.4.4 – Buy: visible only within 2h–330d window */}
+ {buyVisible && (
+
+ )}
+ {/* TZ §4.1.14.4.5 – Status: visible only on the departure day */}
+ {statusVisible && (
+
+ )}
+ {/* TZ §4.1.14.4.6 – Details: always visible when handler provided */}
+ {onStatus && (
+
+ )}
);