Files
flights_web/docs/superpowers/plans/2026-04-17-board-details-header.md
T

91 KiB

Board Details Header + Action Buttons (B.4) Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Build the Online Board flight details header with airline logo, flight number, codesharing, action buttons (Buy Ticket, Register, Flight Status, Share, Print), event indicators (route change, reroute), and last-update timestamp — matching Angular parity including time-based visibility.

Architecture: Four layers. (1) Add date-fns, extend useAppSettings with buttons config. (2) Build pure visibility functions in visibility/ directory — tested independently. (3) Build leaf components (OperatorLogo, FlightEvents, LastUpdate, 5 action buttons, SharePanel) and mid-level composites (DetailsHeaderBadge, FlightActions). (4) Compose into BoardDetailsHeader, wire into OnlineBoardDetailsPage.

Tech Stack: React 18, TypeScript, date-fns (new dep), Rspack for SVG/PNG asset imports, existing Modern.js router, Vitest + React Testing Library.


File Structure

All new files live under src/features/online-board/components/BoardDetailsHeader/. Each task creates one focused file + its tests.

New source files

  • airlines.ts — Airline URL/capability config + AIRLINES_WITH_STATUS set
  • visibility/buyTicketVisibility.ts
  • visibility/registrationVisibility.ts
  • visibility/flightStatusVisibility.ts
  • OperatorLogo.tsx + OperatorLogo.scss
  • FlightEvents.tsx + inline SVG icons
  • LastUpdate.tsx
  • BuyTicketButton.tsx
  • RegistrationButton.tsx
  • FlightStatusButton.tsx
  • ShareButton.tsx + SharePanel.tsx
  • PrintButton.tsx
  • DetailsHeaderBadge.tsx
  • FlightActions.tsx
  • BoardDetailsHeader.tsx + BoardDetailsHeader.scss
  • actions.scss (shared button styles)
  • icons.scss (share/print icons)
  • index.ts (barrel exports)

Assets copied from Angular

  • 35 airline logo directories from ClientApp/src/assets/img/airlines-logo/src/features/online-board/components/BoardDetailsHeader/airlines-logo/

Modified files

  • package.json — add date-fns
  • src/shared/hooks/useAppSettings.ts — add flightStatusAvailableFromHours, buyTicketMinHours, buyTicketMaxHours
  • src/shared/hooks/useAppSettings.test.ts — add buttons config tests
  • src/features/online-board/components/OnlineBoardDetailsPage.tsx — replace inline header with <BoardDetailsHeader>
  • src/features/online-board/components/OnlineBoardDetailsPage.test.tsx — update mocks

Task 1: Add date-fns Dependency

Files:

  • Modify: package.json

  • Step 1: Add the dependency

Run:

pnpm add date-fns
  • Step 2: Verify

Run: grep '"date-fns"' package.json

Expected: matches the line "date-fns": "^N.N.N" under dependencies.

  • Step 3: Commit
git add package.json pnpm-lock.yaml
git commit -m "Add date-fns for flight details time-window logic"

Task 2: Extend useAppSettings with Buttons Config

Files:

  • Modify: src/shared/hooks/useAppSettings.ts

  • Modify: src/shared/hooks/useAppSettings.test.ts

  • Modify: src/shared/api/appSettings.ts (extend AppSettingsResponse type)

  • Step 1: Extend AppSettingsResponse type

Edit src/shared/api/appSettings.ts. Replace the AppSettingsResponse interface:

export interface AppSettingsButtonsBuyTicketPeriod {
  min?: string;  // e.g. "2h"
  max?: string;  // e.g. "72h"
}

export interface AppSettingsButtonsBuyTicket {
  period?: AppSettingsButtonsBuyTicketPeriod;
}

export interface AppSettingsButtonsFlightStatus {
  availableFrom?: string;  // e.g. "24h"
  visible?: string;
}

export interface AppSettingsButtons {
  flightStatus?: AppSettingsButtonsFlightStatus;
  buyTicket?: AppSettingsButtonsBuyTicket;
}

export interface AppSettingsResponse {
  showDebugVersion?: string;
  uiOptions?: {
    isTestVersion?: string;
    filter?: {
      onlineboard?: AppSettingsFilterOptions;
      schedule?: AppSettingsFilterOptions;
    };
    buttons?: AppSettingsButtons;
  };
}
  • Step 2: Write failing test

Add to src/shared/hooks/useAppSettings.test.ts (append to existing describe block):

  it("parses buttons config into hour numbers", async () => {
    const response: AppSettingsResponse = {
      uiOptions: {
        buttons: {
          flightStatus: { availableFrom: "24h" },
          buyTicket: { period: { min: "2h", max: "72h" } },
        },
      },
    };
    mockGetAppSettings.mockResolvedValue(response);

    const { result } = renderHook(() => useAppSettings());

    await waitFor(() => expect(result.current.loading).toBe(false));
    expect(result.current.flightStatusAvailableFromHours).toBe(24);
    expect(result.current.buyTicketMinHours).toBe(2);
    expect(result.current.buyTicketMaxHours).toBe(72);
  });

  it("returns button-config defaults when fields are missing", async () => {
    mockGetAppSettings.mockResolvedValue({});

    const { result } = renderHook(() => useAppSettings());

    await waitFor(() => expect(result.current.loading).toBe(false));
    expect(result.current.flightStatusAvailableFromHours).toBe(24);
    expect(result.current.buyTicketMinHours).toBe(2);
    expect(result.current.buyTicketMaxHours).toBe(72);
  });
  • Step 3: Verify fail

Run: pnpm vitest run src/shared/hooks/useAppSettings.test.ts

Expected: the 2 new tests fail with "property does not exist".

  • Step 4: Extend the hook

Edit src/shared/hooks/useAppSettings.ts. Replace the whole file with:

import { useEffect, useState } from "react";
import { useApiClient } from "@/shared/api/provider.js";
import { getAppSettings } from "@/shared/api/appSettings.js";

const DAYS_PATTERN = /^(\d+)d$/;
const HOURS_PATTERN = /^(\d+)h$/;

const DEFAULTS = {
  onlineboardSearchFrom: 2,
  onlineboardSearchTo: 14,
  scheduleSearchFrom: 30,
  scheduleSearchTo: 30,
  flightStatusAvailableFromHours: 24,
  buyTicketMinHours: 2,
  buyTicketMaxHours: 72,
} as const;

function parsePattern(value: string | undefined, pattern: RegExp, fallback: number): number {
  if (!value) return fallback;
  const match = pattern.exec(value);
  if (!match) return fallback;
  return parseInt(match[1]!, 10);
}

function parseDays(value: string | undefined, fallback: number): number {
  return parsePattern(value, DAYS_PATTERN, fallback);
}

function parseHours(value: string | undefined, fallback: number): number {
  return parsePattern(value, HOURS_PATTERN, fallback);
}

export interface UseAppSettingsResult {
  onlineboardSearchFrom: number;
  onlineboardSearchTo: number;
  scheduleSearchFrom: number;
  scheduleSearchTo: number;
  flightStatusAvailableFromHours: number;
  buyTicketMinHours: number;
  buyTicketMaxHours: number;
  loading: boolean;
  error: Error | null;
}

/**
 * Fetches the global app settings and exposes day-range and button-config numbers.
 * On error or parse failure, returns defaults.
 */
export function useAppSettings(): UseAppSettingsResult {
  const client = useApiClient();
  const [state, setState] = useState<Omit<UseAppSettingsResult, "loading" | "error">>(DEFAULTS);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<Error | null>(null);

  useEffect(() => {
    let cancelled = false;

    getAppSettings(client)
      .then((response) => {
        if (cancelled) return;
        const ob = response.uiOptions?.filter?.onlineboard;
        const sc = response.uiOptions?.filter?.schedule;
        const fs = response.uiOptions?.buttons?.flightStatus;
        const bt = response.uiOptions?.buttons?.buyTicket;

        setState({
          onlineboardSearchFrom: parseDays(ob?.searchFrom, DEFAULTS.onlineboardSearchFrom),
          onlineboardSearchTo: parseDays(ob?.searchTo, DEFAULTS.onlineboardSearchTo),
          scheduleSearchFrom: parseDays(sc?.searchFrom, DEFAULTS.scheduleSearchFrom),
          scheduleSearchTo: parseDays(sc?.searchTo, DEFAULTS.scheduleSearchTo),
          flightStatusAvailableFromHours: parseHours(fs?.availableFrom, DEFAULTS.flightStatusAvailableFromHours),
          buyTicketMinHours: parseHours(bt?.period?.min, DEFAULTS.buyTicketMinHours),
          buyTicketMaxHours: parseHours(bt?.period?.max, DEFAULTS.buyTicketMaxHours),
        });
        setLoading(false);
      })
      .catch((err: Error) => {
        if (cancelled) return;
        setError(err);
        setLoading(false);
      });

    return () => {
      cancelled = true;
    };
  }, [client]);

  return { ...state, loading, error };
}
  • Step 5: Verify pass

Run: pnpm vitest run src/shared/hooks/useAppSettings.test.ts

Expected: all tests pass (existing + 2 new).

  • Step 6: Commit
git add src/shared/hooks/useAppSettings.ts src/shared/hooks/useAppSettings.test.ts src/shared/api/appSettings.ts
git commit -m "Extend useAppSettings with flightStatus and buyTicket button config"

Task 3: Airline Config

Files:

  • Create: src/features/online-board/components/BoardDetailsHeader/airlines.ts

  • Create: src/features/online-board/components/BoardDetailsHeader/airlines.test.ts

  • Step 1: Write failing test

// airlines.test.ts
import { describe, it, expect } from "vitest";
import { AIRLINES, AIRLINES_WITH_STATUS } from "./airlines.js";

describe("AIRLINES", () => {
  it("has SU Aeroflot with registration URL and native status", () => {
    const su = AIRLINES.SU;
    expect(su).toBeDefined();
    expect(su?.name).toBe("Aeroflot");
    expect(su?.registrationUrl).toContain("aeroflot.ru");
    expect(su?.hasNativeStatus).toBe(true);
  });

  it("has HZ Aurora with external status URL", () => {
    const hz = AIRLINES.HZ;
    expect(hz?.hasNativeStatus).toBe(false);
    expect(hz?.statusUrl).toContain("flyaurora");
  });

  it("has AF AirFrance with no registration URL", () => {
    const af = AIRLINES.AF;
    expect(af).toBeDefined();
    expect(af?.registrationUrl).toBeUndefined();
  });
});

describe("AIRLINES_WITH_STATUS", () => {
  it("contains SU, HZ, FV, DP", () => {
    expect(AIRLINES_WITH_STATUS.has("SU")).toBe(true);
    expect(AIRLINES_WITH_STATUS.has("HZ")).toBe(true);
    expect(AIRLINES_WITH_STATUS.has("FV")).toBe(true);
    expect(AIRLINES_WITH_STATUS.has("DP")).toBe(true);
  });

  it("does not contain AF", () => {
    expect(AIRLINES_WITH_STATUS.has("AF")).toBe(false);
  });
});
  • Step 2: Verify fail

Run: pnpm vitest run src/features/online-board/components/BoardDetailsHeader/airlines.test.ts

Expected: FAIL — module not found.

  • Step 3: Create airlines.ts
export interface AirlineConfig {
  name: string;
  registrationUrl?: string;
  hasNativeStatus: boolean;
  statusUrl?: string;
}

export const AIRLINES: Record<string, AirlineConfig> = {
  SU: {
    name: "Aeroflot",
    registrationUrl: "https://www.aeroflot.ru/sb/ckin/app/ru-ru",
    hasNativeStatus: true,
  },
  FV: {
    name: "Rossiya",
    registrationUrl:
      "https://www.rossiya-airlines.com/flight-with-us/before_flight/the_ways_of_check-in/",
    hasNativeStatus: true,
  },
  HZ: {
    name: "Aurora",
    registrationUrl: "https://www.flyaurora.ru",
    hasNativeStatus: false,
    statusUrl: "https://www.flyaurora.ru",
  },
  DP: {
    name: "Pobeda",
    registrationUrl: "https://www.pobeda.aero",
    hasNativeStatus: false,
    statusUrl: "https://www.pobeda.aero",
  },
  AF: {
    name: "AirFrance",
    hasNativeStatus: false,
  },
};

export const AIRLINES_WITH_STATUS = new Set(["SU", "HZ", "FV", "DP"]);
  • Step 4: Verify pass + commit

Run: pnpm vitest run src/features/online-board/components/BoardDetailsHeader/airlines.test.ts

Expected: 5 tests pass.

git add src/features/online-board/components/BoardDetailsHeader/airlines.ts src/features/online-board/components/BoardDetailsHeader/airlines.test.ts
git commit -m "Add airline config for B.4 action buttons"

Task 4: Visibility Logic — canBuyTicket

Files:

  • Create: src/features/online-board/components/BoardDetailsHeader/visibility/buyTicketVisibility.ts

  • Create: src/features/online-board/components/BoardDetailsHeader/visibility/buyTicketVisibility.test.ts

  • Step 1: Write failing test

// buyTicketVisibility.test.ts
import { describe, it, expect } from "vitest";
import { canBuyTicket } from "./buyTicketVisibility.js";
import type { ISimpleFlight } from "../../../types.js";

function makeFlight(status: "Scheduled" | "Cancelled" | "InFlight", depUtc: string): ISimpleFlight {
  return {
    id: "SU0022-X",
    routeType: "Direct",
    flyingTime: "1h",
    status,
    flightId: { carrier: "SU", flightNumber: "0022", suffix: "", date: "20260417" },
    operatingBy: { carrier: "SU", flightNumber: "0022" },
    leg: {
      arrival: {
        scheduled: { airport: "", airportCode: "", city: "", cityCode: "", countryCode: "" },
        latest: { airport: "", airportCode: "", city: "", cityCode: "", countryCode: "" },
        dispatch: "", gate: "", terminal: "",
        times: { scheduledArrival: { dayChange: { value: 0, title: "" }, local: "", localTime: "", tzOffset: 0, utc: "" } },
      },
      dayChange: 0,
      departure: {
        scheduled: { airport: "", airportCode: "", city: "", cityCode: "", countryCode: "" },
        latest: { airport: "", airportCode: "", city: "", cityCode: "", countryCode: "" },
        dispatch: "", gate: "", terminal: "", checkingStatus: "Scheduled", parkingStand: "",
        times: { scheduledDeparture: { dayChange: { value: 0, title: "" }, local: "", localTime: "", tzOffset: 0, utc: depUtc } },
      },
      equipment: {},
      flags: { checkinAvailable: false, returnToAirport: false, routeChanged: false },
      flyingTime: "1h",
      index: 0,
      operatingBy: {},
      status,
      updated: "",
    },
  } as ISimpleFlight;
}

describe("canBuyTicket", () => {
  it("returns false when status is Cancelled", () => {
    const now = new Date("2026-04-17T10:00:00Z");
    const dep = "2026-04-18T10:00:00Z"; // 24h away, inside window
    expect(canBuyTicket(makeFlight("Cancelled", dep), now, 2, 72)).toBe(false);
  });

  it("returns false when status is InFlight", () => {
    const now = new Date("2026-04-17T10:00:00Z");
    const dep = "2026-04-18T10:00:00Z";
    expect(canBuyTicket(makeFlight("InFlight", dep), now, 2, 72)).toBe(false);
  });

  it("returns true when now is in window (24h before)", () => {
    const now = new Date("2026-04-17T10:00:00Z");
    const dep = "2026-04-18T10:00:00Z"; // 24h after now, inside [now+2h, now+72h]
    expect(canBuyTicket(makeFlight("Scheduled", dep), now, 2, 72)).toBe(true);
  });

  it("returns false when now is too close (<minHours before)", () => {
    const now = new Date("2026-04-17T09:00:00Z");
    const dep = "2026-04-17T10:00:00Z"; // only 1h away, minHours=2
    expect(canBuyTicket(makeFlight("Scheduled", dep), now, 2, 72)).toBe(false);
  });

  it("returns false when now is too far (>maxHours before)", () => {
    const now = new Date("2026-04-17T00:00:00Z");
    const dep = "2026-04-25T00:00:00Z"; // 192h away, maxHours=72
    expect(canBuyTicket(makeFlight("Scheduled", dep), now, 2, 72)).toBe(false);
  });

  it("returns false when UTC departure is empty", () => {
    const now = new Date();
    expect(canBuyTicket(makeFlight("Scheduled", ""), now, 2, 72)).toBe(false);
  });
});
  • Step 2: Verify fail

Run: pnpm vitest run src/features/online-board/components/BoardDetailsHeader/visibility/buyTicketVisibility.test.ts

Expected: FAIL.

  • Step 3: Implement
// buyTicketVisibility.ts
import { parseISO, subHours, isAfter, isBefore } from "date-fns";
import type { ISimpleFlight } from "../../../types.js";

/**
 * Buy Ticket button is visible when:
 * - flight is NOT Cancelled or InFlight
 * - now falls within [departure - maxHours, departure - minHours]
 */
export function canBuyTicket(
  flight: ISimpleFlight,
  now: Date,
  minHours: number,
  maxHours: number,
): boolean {
  if (flight.status === "Cancelled" || flight.status === "InFlight") return false;

  const leg = flight.routeType === "Direct" ? flight.leg : flight.legs[0];
  if (!leg) return false;

  const depUtc = leg.departure.times.scheduledDeparture.utc;
  if (!depUtc) return false;

  const departure = parseISO(depUtc);
  if (isNaN(departure.getTime())) return false;

  const showFrom = subHours(departure, maxHours);
  const showUntil = subHours(departure, minHours);

  return isAfter(now, showFrom) && isBefore(now, showUntil);
}
  • Step 4: Verify pass + commit

Run: pnpm vitest run src/features/online-board/components/BoardDetailsHeader/visibility/buyTicketVisibility.test.ts

Expected: 6 tests pass.

git add src/features/online-board/components/BoardDetailsHeader/visibility/buyTicketVisibility.ts src/features/online-board/components/BoardDetailsHeader/visibility/buyTicketVisibility.test.ts
git commit -m "Add canBuyTicket visibility logic"

Task 5: Visibility Logic — canRegister

Files:

  • Create: src/features/online-board/components/BoardDetailsHeader/visibility/registrationVisibility.ts

  • Create: src/features/online-board/components/BoardDetailsHeader/visibility/registrationVisibility.test.ts

  • Step 1: Write failing test

// registrationVisibility.test.ts
import { describe, it, expect } from "vitest";
import { canRegister } from "./registrationVisibility.js";
import { AIRLINES } from "../airlines.js";
import type { ISimpleFlight, FlightTransitionStatus } from "../../../types.js";

function makeFlight(carrier: string, regStatus?: FlightTransitionStatus): ISimpleFlight {
  return {
    id: "X-1",
    routeType: "Direct",
    flyingTime: "1h",
    status: "Scheduled",
    flightId: { carrier, flightNumber: "0022", suffix: "", date: "20260417" },
    operatingBy: { carrier, flightNumber: "0022" },
    leg: {
      arrival: {
        scheduled: { airport: "", airportCode: "", city: "", cityCode: "", countryCode: "" },
        latest: { airport: "", airportCode: "", city: "", cityCode: "", countryCode: "" },
        dispatch: "", gate: "", terminal: "",
        times: { scheduledArrival: { dayChange: { value: 0, title: "" }, local: "", localTime: "", tzOffset: 0, utc: "" } },
      },
      dayChange: 0,
      departure: {
        scheduled: { airport: "", airportCode: "", city: "", cityCode: "", countryCode: "" },
        latest: { airport: "", airportCode: "", city: "", cityCode: "", countryCode: "" },
        dispatch: "", gate: "", terminal: "", checkingStatus: "Scheduled", parkingStand: "",
        times: { scheduledDeparture: { dayChange: { value: 0, title: "" }, local: "", localTime: "", tzOffset: 0, utc: "" } },
      },
      equipment: {},
      flags: { checkinAvailable: false, returnToAirport: false, routeChanged: false },
      flyingTime: "1h",
      index: 0,
      operatingBy: {},
      status: "Scheduled",
      updated: "",
      ...(regStatus
        ? {
            transition: {
              registration: {
                start: {} as never,
                end: {} as never,
                status: regStatus,
                isActual: true,
              },
            },
          }
        : {}),
    },
  } as ISimpleFlight;
}

describe("canRegister", () => {
  it("returns true for SU with InProgress registration", () => {
    expect(canRegister(makeFlight("SU", "InProgress"), AIRLINES)).toBe(true);
  });

  it("returns false for SU without InProgress registration", () => {
    expect(canRegister(makeFlight("SU", "Scheduled"), AIRLINES)).toBe(false);
  });

  it("returns false for SU with no registration transition", () => {
    expect(canRegister(makeFlight("SU"), AIRLINES)).toBe(false);
  });

  it("returns false for AF (no registration URL)", () => {
    expect(canRegister(makeFlight("AF", "InProgress"), AIRLINES)).toBe(false);
  });

  it("returns false for unknown carrier", () => {
    expect(canRegister(makeFlight("XX", "InProgress"), AIRLINES)).toBe(false);
  });
});
  • Step 2: Verify fail

Run: pnpm vitest run src/features/online-board/components/BoardDetailsHeader/visibility/registrationVisibility.test.ts

Expected: FAIL.

  • Step 3: Implement
// registrationVisibility.ts
import type { ISimpleFlight } from "../../../types.js";
import type { AirlineConfig } from "../airlines.js";

export function canRegister(
  flight: ISimpleFlight,
  airlineConfig: Record<string, AirlineConfig>,
): boolean {
  const carrier = flight.operatingBy.carrier;
  if (!carrier) return false;
  const config = airlineConfig[carrier];
  if (!config?.registrationUrl) return false;

  const leg = flight.routeType === "Direct" ? flight.leg : flight.legs[0];
  if (!leg) return false;

  return leg.transition?.registration?.status === "InProgress";
}
  • Step 4: Verify pass + commit

Run: pnpm vitest run src/features/online-board/components/BoardDetailsHeader/visibility/registrationVisibility.test.ts

Expected: 5 tests pass.

git add src/features/online-board/components/BoardDetailsHeader/visibility/registrationVisibility.ts src/features/online-board/components/BoardDetailsHeader/visibility/registrationVisibility.test.ts
git commit -m "Add canRegister visibility logic"

Task 6: Visibility Logic — canViewFlightStatus

Files:

  • Create: src/features/online-board/components/BoardDetailsHeader/visibility/flightStatusVisibility.ts

  • Create: src/features/online-board/components/BoardDetailsHeader/visibility/flightStatusVisibility.test.ts

  • Step 1: Write failing test

// flightStatusVisibility.test.ts
import { describe, it, expect } from "vitest";
import { canViewFlightStatus } from "./flightStatusVisibility.js";
import { AIRLINES_WITH_STATUS } from "../airlines.js";
import type { ISimpleFlight } from "../../../types.js";

function makeFlight(carrier: string, depUtc: string): ISimpleFlight {
  return {
    id: "X-1",
    routeType: "Direct",
    flyingTime: "1h",
    status: "Scheduled",
    flightId: { carrier, flightNumber: "0022", suffix: "", date: "20260417" },
    operatingBy: { carrier, flightNumber: "0022" },
    leg: {
      arrival: {
        scheduled: { airport: "", airportCode: "", city: "", cityCode: "", countryCode: "" },
        latest: { airport: "", airportCode: "", city: "", cityCode: "", countryCode: "" },
        dispatch: "", gate: "", terminal: "",
        times: { scheduledArrival: { dayChange: { value: 0, title: "" }, local: "", localTime: "", tzOffset: 0, utc: "" } },
      },
      dayChange: 0,
      departure: {
        scheduled: { airport: "", airportCode: "", city: "", cityCode: "", countryCode: "" },
        latest: { airport: "", airportCode: "", city: "", cityCode: "", countryCode: "" },
        dispatch: "", gate: "", terminal: "", checkingStatus: "Scheduled", parkingStand: "",
        times: { scheduledDeparture: { dayChange: { value: 0, title: "" }, local: "", localTime: "", tzOffset: 0, utc: depUtc } },
      },
      equipment: {},
      flags: { checkinAvailable: false, returnToAirport: false, routeChanged: false },
      flyingTime: "1h",
      index: 0,
      operatingBy: {},
      status: "Scheduled",
      updated: "",
    },
  } as ISimpleFlight;
}

describe("canViewFlightStatus", () => {
  it("returns false for carrier not in AIRLINES_WITH_STATUS", () => {
    const now = new Date("2026-04-17T10:00:00Z");
    expect(canViewFlightStatus(makeFlight("AF", "2026-04-17T12:00:00Z"), now, 24, AIRLINES_WITH_STATUS)).toBe(false);
  });

  it("returns false when departure is not same day as now", () => {
    const now = new Date("2026-04-17T23:59:59Z");
    // Departure on different UTC day
    expect(canViewFlightStatus(makeFlight("SU", "2026-04-18T00:30:00Z"), now, 24, AIRLINES_WITH_STATUS)).toBe(false);
  });

  it("returns true when SU same-day and within availableFromHours before departure", () => {
    const now = new Date("2026-04-17T08:00:00Z");
    // Departure 2h later, availableFromHours=24 → now > dep-24h → true
    expect(canViewFlightStatus(makeFlight("SU", "2026-04-17T10:00:00Z"), now, 24, AIRLINES_WITH_STATUS)).toBe(true);
  });

  it("returns false when now is before availableFrom window", () => {
    const now = new Date("2026-04-17T00:00:00Z");
    // Departure 23:00 same day, availableFromHours=1 → available only from 22:00
    expect(canViewFlightStatus(makeFlight("SU", "2026-04-17T23:00:00Z"), now, 1, AIRLINES_WITH_STATUS)).toBe(false);
  });

  it("returns false when departure UTC is empty", () => {
    const now = new Date();
    expect(canViewFlightStatus(makeFlight("SU", ""), now, 24, AIRLINES_WITH_STATUS)).toBe(false);
  });
});
  • Step 2: Verify fail

Run: pnpm vitest run src/features/online-board/components/BoardDetailsHeader/visibility/flightStatusVisibility.test.ts

Expected: FAIL.

  • Step 3: Implement
// flightStatusVisibility.ts
import { parseISO, subHours, isAfter, isSameDay } from "date-fns";
import type { ISimpleFlight } from "../../../types.js";

export function canViewFlightStatus(
  flight: ISimpleFlight,
  now: Date,
  availableFromHours: number,
  airlinesWithStatus: Set<string>,
): boolean {
  const carrier = flight.operatingBy.carrier;
  if (!carrier || !airlinesWithStatus.has(carrier)) return false;

  const leg = flight.routeType === "Direct" ? flight.leg : flight.legs[0];
  if (!leg) return false;

  const depUtc = leg.departure.times.scheduledDeparture.utc;
  if (!depUtc) return false;

  const departure = parseISO(depUtc);
  if (isNaN(departure.getTime())) return false;

  if (!isSameDay(now, departure)) return false;

  const availableFrom = subHours(departure, availableFromHours);
  return isAfter(now, availableFrom);
}
  • Step 4: Verify pass + commit

Run: pnpm vitest run src/features/online-board/components/BoardDetailsHeader/visibility/flightStatusVisibility.test.ts

Expected: 5 tests pass.

git add src/features/online-board/components/BoardDetailsHeader/visibility/flightStatusVisibility.ts src/features/online-board/components/BoardDetailsHeader/visibility/flightStatusVisibility.test.ts
git commit -m "Add canViewFlightStatus visibility logic"

Task 7: Copy Airline Logo Assets + SCSS

Files:

  • Create: 35 directories under src/features/online-board/components/BoardDetailsHeader/airlines-logo/

  • Create: src/features/online-board/components/BoardDetailsHeader/OperatorLogo.scss

  • Step 1: Copy assets

Run:

cp -r ClientApp/src/assets/img/airlines-logo src/features/online-board/components/BoardDetailsHeader/
ls src/features/online-board/components/BoardDetailsHeader/airlines-logo | wc -l

Expected: 35.

  • Step 2: Create OperatorLogo.scss with all logo rules

Port Angular's _logos.scss, rewriting paths from ~src/assets/img/airlines-logo/ to ./airlines-logo/. Create src/features/online-board/components/BoardDetailsHeader/OperatorLogo.scss:

$width-base: 120px;
$height-base: 36px;

@mixin scale($base, $ratio: 0.25, $scale: 1.5) {
  width: $base;
  height: $base * $ratio;

  &.large {
    width: $base * $scale;
    height: $base * $scale * $ratio;
  }

  @media (max-width: 768px) {
    width: $base * 0.75;
    height: $base * $ratio * 0.75;

    &.large {
      width: $base * $scale * 0.75;
      height: $base * $scale * $ratio * 0.75;
    }
  }
}

.company-logo {
  display: inline-block;
  background-repeat: no-repeat;
  background-size: contain;
  background-position: left top;

  @include scale($width-base);

  &.round {
    width: $height-base !important;
    height: $height-base !important;
  }

  &--SU {
    @include scale($width-base, 0.258);
    background-image: url('./airlines-logo/aeroflot/large/en.png') !important;

    &.ru { background-image: url('./airlines-logo/aeroflot/large/ru.png') !important; }
    &.round { background-image: url('./airlines-logo/aeroflot/round.png') !important; }
  }

  &--HZ {
    @include scale(80px, 0.5556);
    background-image: url('./airlines-logo/aurora/large/en.svg') !important;

    &.ru { background-image: url('./airlines-logo/aurora/large/ru.svg') !important; }
    &.round { background-image: url('./airlines-logo/aurora/round.png') !important; }
  }

  &--F7 {
    @include scale($width-base, 0.258);
    background-image: url('./airlines-logo/aeroflot/large/en.png') !important;

    &.ru { background-image: url('./airlines-logo/aeroflot/large/ru.png') !important; }
    &.round { background-image: url('./airlines-logo/aeroflot/round.png') !important; }
  }

  &--FV {
    @include scale(90px, 0.1667);
    background-image: url('./airlines-logo/rossiya/large/en.svg') !important;

    &.ru { background-image: url('./airlines-logo/rossiya/large/ru.svg') !important; }
    &.round { background-image: url('./airlines-logo/rossiya/round.png') !important; }
  }

  &--RO {
    @include scale($width-base, 0.3334);
    background-image: url('./airlines-logo/tarom/large.png') !important;
    &.round { background-image: url('./airlines-logo/tarom/round.svg') !important; }
  }

  &--DP {
    @include scale($width-base, 0.1889);
    background-image: url('./airlines-logo/pobeda/large.svg') !important;
    &.round { background-image: url('./airlines-logo/pobeda/round.png') !important; }
  }

  &--OM {
    background-image: url('./airlines-logo/miat/large.svg') !important;
    &.round { background-image: url('./airlines-logo/miat/round.svg') !important; }
  }

  &--KL {
    @include scale($width-base, 0.4444);
    background-image: url('./airlines-logo/klm/large.png') !important;
    &.round { background-image: url('./airlines-logo/klm/round.png') !important; }
  }

  &--AY {
    background-image: url('./airlines-logo/finnair/large.svg') !important;
    &.round { background-image: url('./airlines-logo/finnair/round.png') !important; }
  }

  &--DL {
    background-image: url('./airlines-logo/delta/large.svg') !important;
    &.round { background-image: url('./airlines-logo/delta/round.png') !important; }
  }

  &--OK {
    background-image: url('./airlines-logo/czech-airline/large.png') !important;
    &.round { background-image: url('./airlines-logo/czech-airline/round.svg') !important; }
  }

  &--JU {
    background-image: url('./airlines-logo/air-serbia/large.svg') !important;
    &.round { background-image: url('./airlines-logo/air-serbia/round.svg') !important; }
  }

  &--UX {
    background-image: url('./airlines-logo/air-europa/large.svg') !important;
    &.round { background-image: url('./airlines-logo/air-europa/round.svg') !important; }
  }

  &--BT {
    background-image: url('./airlines-logo/air-baltic/large.svg') !important;
    &.round { background-image: url('./airlines-logo/air-baltic/round.svg') !important; }
  }

  &--AM {
    background-image: url('./airlines-logo/aeromexico/large.svg') !important;
    &.round { background-image: url('./airlines-logo/aeromexico/round.svg') !important; }
  }

  &--AR {
    background-image: url('./airlines-logo/aerolineas-argentinas/large.png') !important;
    &.round { background-image: url('./airlines-logo/aerolineas-argentinas/round.svg') !important; }
  }

  &--KM {
    background-image: url('./airlines-logo/airmalta/large.svg') !important;
  }

  &--AF {
    @include scale($width-base, 0.1222);
    background-image: url('./airlines-logo/airfrance/large.svg') !important;
  }

  &--AZ {
    @include scale($width-base, 0.2444);
    background-image: url('./airlines-logo/alitalia/large.svg') !important;
  }

  &--PG {
    background-image: url('./airlines-logo/bangkok-airways/large.png') !important;
  }

  &--SN {
    @include scale($width-base, 0.1667);
    background-image: url('./airlines-logo/brussels-airlines/large.png') !important;
  }

  &--FB {
    @include scale($width-base, 0.296);
    background-image: url('./airlines-logo/bulgaria-air/large.png') !important;
  }

  &--CI {
    @include scale($width-base, 0.1556);
    background-image: url('./airlines-logo/china-airlines/large.png') !important;
  }

  &--MU { background-image: url('./airlines-logo/china-eastern/large.svg') !important; }
  &--CZ { background-image: url('./airlines-logo/china-southern/large.svg') !important; }
  &--GA { background-image: url('./airlines-logo/garuda-indonesia/large.png') !important; }
  &--FI { background-image: url('./airlines-logo/icelandair/large.svg') !important; }
  &--KO { background-image: url('./airlines-logo/kenya-airways/large.svg') !important; }
  &--KE { background-image: url('./airlines-logo/korean-air/large.svg') !important; }
  &--JL { background-image: url('./airlines-logo/japan-airlines/large.svg') !important; }
  &--LO { background-image: url('./airlines-logo/polish-airlines/large.png') !important; }
  &--ME { background-image: url('./airlines-logo/mea/large.png') !important; }

  &--S7 {
    @include scale($width-base, 0.3333);
    background-image: url('./airlines-logo/s7/large.svg') !important;
  }

  &--SV { background-image: url('./airlines-logo/saudi-arabian-airlines/large.png') !important; }
  &--VN { background-image: url('./airlines-logo/vietnam-airlines/large.png') !important; }
  &--MF { background-image: url('./airlines-logo/vietnam-airlines/large.png') !important; }
}

.operator-logo__caption {
  font-size: 12px;
  color: #666;
  margin-bottom: 2px;
}
  • Step 3: Commit
git add src/features/online-board/components/BoardDetailsHeader/airlines-logo/ src/features/online-board/components/BoardDetailsHeader/OperatorLogo.scss
git commit -m "Copy airline logos and SCSS from Angular to React"

Task 8: OperatorLogo Component

Files:

  • Create: src/features/online-board/components/BoardDetailsHeader/OperatorLogo.tsx

  • Create: src/features/online-board/components/BoardDetailsHeader/OperatorLogo.test.tsx

  • Step 1: Write failing tests

// @vitest-environment jsdom
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { OperatorLogo } from "./OperatorLogo.js";
import type { ISimpleFlight } from "../../types.js";

vi.mock("@/i18n/provider.js", () => ({
  useTranslation: () => ({ t: (k: string) => k }),
}));

function makeFlight(carrier: string): ISimpleFlight {
  return {
    id: "X", routeType: "Direct", flyingTime: "1h", status: "Scheduled",
    flightId: { carrier, flightNumber: "0022", suffix: "", date: "20260417" },
    operatingBy: { carrier, flightNumber: "0022" },
    leg: {} as never,
  } as ISimpleFlight;
}

describe("OperatorLogo", () => {
  it("renders with company-logo and company-logo--SU classes", () => {
    render(<OperatorLogo flight={makeFlight("SU")} locale="en" />);
    const el = screen.getByTestId("operator-logo");
    expect(el.className).toContain("company-logo");
    expect(el.className).toContain("company-logo--SU");
  });

  it("adds ru class when locale is ru", () => {
    render(<OperatorLogo flight={makeFlight("SU")} locale="ru" />);
    const el = screen.getByTestId("operator-logo");
    expect(el.className).toContain("ru");
  });

  it("adds large class when large=true", () => {
    render(<OperatorLogo flight={makeFlight("SU")} locale="en" large />);
    const el = screen.getByTestId("operator-logo");
    expect(el.className).toContain("large");
  });

  it("adds round class when round=true", () => {
    render(<OperatorLogo flight={makeFlight("SU")} locale="en" round />);
    const el = screen.getByTestId("operator-logo");
    expect(el.className).toContain("round");
  });

  it("renders caption when caption=true", () => {
    render(<OperatorLogo flight={makeFlight("SU")} locale="en" caption />);
    expect(screen.getByText("SHARED.AVIACOMPANY")).toBeTruthy();
  });

  it("does not render caption by default", () => {
    render(<OperatorLogo flight={makeFlight("SU")} locale="en" />);
    expect(screen.queryByText("SHARED.AVIACOMPANY")).toBeNull();
  });
});
  • Step 2: Verify fail

Run: pnpm vitest run src/features/online-board/components/BoardDetailsHeader/OperatorLogo.test.tsx

Expected: FAIL.

  • Step 3: Create OperatorLogo.tsx
import type { FC } from "react";
import { useTranslation } from "@/i18n/provider.js";
import type { ISimpleFlight } from "../../types.js";
import "./OperatorLogo.scss";

export interface OperatorLogoProps {
  flight: ISimpleFlight;
  locale: string;
  large?: boolean;
  round?: boolean;
  caption?: boolean;
}

export const OperatorLogo: FC<OperatorLogoProps> = ({
  flight,
  locale,
  large,
  round,
  caption,
}) => {
  const { t } = useTranslation();
  const carrier = flight.operatingBy.carrier ?? flight.flightId.carrier;

  const classes = [
    "company-logo",
    `company-logo--${carrier}`,
    large ? "large" : "",
    round ? "round" : "",
    locale === "ru" ? "ru" : "",
  ]
    .filter(Boolean)
    .join(" ");

  return (
    <div>
      {caption && <div className="operator-logo__caption">{t("SHARED.AVIACOMPANY")}</div>}
      <div
        className={classes}
        data-testid="operator-logo"
        title={carrier}
      />
    </div>
  );
};
  • Step 4: Verify pass + commit

Run: pnpm vitest run src/features/online-board/components/BoardDetailsHeader/OperatorLogo.test.tsx

Expected: 6 tests pass.

git add src/features/online-board/components/BoardDetailsHeader/OperatorLogo.tsx src/features/online-board/components/BoardDetailsHeader/OperatorLogo.test.tsx
git commit -m "Add OperatorLogo component for airline branding"

Task 9: Action Button Shared Styles

Files:

  • Create: src/features/online-board/components/BoardDetailsHeader/actions.scss

  • Step 1: Create the shared stylesheet

// actions.scss
.flight-action-btn {
  padding: 8px 16px;
  border-radius: 6px;
  cursor: pointer;
  font-size: 14px;
  border: none;
  font-family: inherit;
  transition: opacity 0.15s, background-color 0.15s;

  &:disabled {
    opacity: 0.5;
    cursor: not-allowed;
  }

  &--orange {
    background: #ff9000;
    color: #fff;

    &:hover:not(:disabled) {
      background: #e68200;
    }
  }

  &--blue-light {
    background: #e3f0ff;
    color: #1a3a5c;

    &:hover:not(:disabled) {
      background: #c7dff5;
    }
  }

  &--transparent {
    background: transparent;
    padding: 8px;
    color: #2060c0;

    &:hover:not(:disabled) {
      background: rgba(32, 96, 192, 0.08);
    }
  }

  &--small {
    font-size: 12px;
    padding: 4px 8px;
  }
}
  • Step 2: Commit
git add src/features/online-board/components/BoardDetailsHeader/actions.scss
git commit -m "Add shared action button styles"

Task 10: BuyTicketButton Component

Files:

  • Create: src/features/online-board/components/BoardDetailsHeader/BuyTicketButton.tsx

  • Create: src/features/online-board/components/BoardDetailsHeader/BuyTicketButton.test.tsx

  • Step 1: Write failing tests

// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { BuyTicketButton } from "./BuyTicketButton.js";
import type { ISimpleFlight } from "../../types.js";

vi.mock("@/i18n/provider.js", () => ({
  useTranslation: () => ({ t: (k: string) => k }),
}));

function makeFlight(depUtc: string, dep: string, arr: string): ISimpleFlight {
  return {
    id: "X", routeType: "Direct", flyingTime: "1h", status: "Scheduled",
    flightId: { carrier: "SU", flightNumber: "0022", suffix: "", date: "20260417" },
    operatingBy: { carrier: "SU", flightNumber: "0022" },
    leg: {
      arrival: {
        scheduled: { airport: "", airportCode: arr, city: "", cityCode: "", countryCode: "" },
        latest: { airport: "", airportCode: arr, city: "", cityCode: "", countryCode: "" },
        dispatch: "", gate: "", terminal: "",
        times: { scheduledArrival: { dayChange: { value: 0, title: "" }, local: "", localTime: "", tzOffset: 0, utc: "" } },
      },
      dayChange: 0,
      departure: {
        scheduled: { airport: "", airportCode: dep, city: "", cityCode: "", countryCode: "" },
        latest: { airport: "", airportCode: dep, city: "", cityCode: "", countryCode: "" },
        dispatch: "", gate: "", terminal: "", checkingStatus: "Scheduled", parkingStand: "",
        times: { scheduledDeparture: { dayChange: { value: 0, title: "" }, local: "", localTime: "", tzOffset: 0, utc: depUtc } },
      },
      equipment: {},
      flags: { checkinAvailable: false, returnToAirport: false, routeChanged: false },
      flyingTime: "1h", index: 0, operatingBy: {}, status: "Scheduled", updated: "",
    },
  } as ISimpleFlight;
}

describe("BuyTicketButton", () => {
  let openSpy: ReturnType<typeof vi.fn>;

  beforeEach(() => {
    openSpy = vi.fn();
    Object.defineProperty(window, "open", { value: openSpy, writable: true });
  });

  it("renders the translated label", () => {
    render(<BuyTicketButton flight={makeFlight("2026-04-20T10:00:00Z", "SVO", "LED")} locale="ru" />);
    expect(screen.getByText("SHARED.BUY-TICKET")).toBeTruthy();
  });

  it("has data-testid=buy-ticket-button", () => {
    render(<BuyTicketButton flight={makeFlight("2026-04-20T10:00:00Z", "SVO", "LED")} locale="ru" />);
    expect(screen.getByTestId("buy-ticket-button")).toBeTruthy();
  });

  it("opens the Aeroflot booking URL on click", () => {
    render(<BuyTicketButton flight={makeFlight("2026-04-20T10:00:00Z", "SVO", "LED")} locale="ru" />);
    fireEvent.click(screen.getByTestId("buy-ticket-button"));
    expect(openSpy).toHaveBeenCalledTimes(1);
    const url = openSpy.mock.calls[0][0] as string;
    expect(url).toContain("aeroflot.ru/sb/app/ru-ru");
    expect(url).toContain("routes=SVO.20260420.LED");
    expect(url).toContain("autosearch=Y");
  });
});
  • Step 2: Verify fail

Run: pnpm vitest run src/features/online-board/components/BoardDetailsHeader/BuyTicketButton.test.tsx

Expected: FAIL.

  • Step 3: Implement
import type { FC } from "react";
import { parseISO, format } from "date-fns";
import { useTranslation } from "@/i18n/provider.js";
import type { ISimpleFlight } from "../../types.js";
import "./actions.scss";

export interface BuyTicketButtonProps {
  flight: ISimpleFlight;
  locale: string;
}

function buildBuyTicketUrl(flight: ISimpleFlight, locale: string): string {
  const legs = flight.routeType === "Direct" ? [flight.leg] : flight.legs;
  const firstLeg = legs[0]!;
  const lastLeg = legs[legs.length - 1]!;
  const dep = firstLeg.departure.scheduled.airportCode;
  const arr = lastLeg.arrival.scheduled.airportCode;
  const depDate = parseISO(firstLeg.departure.times.scheduledDeparture.utc);
  const date = format(depDate, "yyyyMMdd");
  return `https://www.aeroflot.ru/sb/app/${locale}-${locale}#/search?adults=1&cabin=economy&children=0&infants=0&routes=${dep}.${date}.${arr}&autosearch=Y`;
}

export const BuyTicketButton: FC<BuyTicketButtonProps> = ({ flight, locale }) => {
  const { t } = useTranslation();

  const handleClick = () => {
    const url = buildBuyTicketUrl(flight, locale);
    window.open(url, "_blank", "noopener,noreferrer");
  };

  return (
    <button
      type="button"
      className="flight-action-btn flight-action-btn--orange"
      data-testid="buy-ticket-button"
      onClick={handleClick}
    >
      {t("SHARED.BUY-TICKET")}
    </button>
  );
};
  • Step 4: Verify pass + commit

Run: pnpm vitest run src/features/online-board/components/BoardDetailsHeader/BuyTicketButton.test.tsx

Expected: 3 tests pass.

git add src/features/online-board/components/BoardDetailsHeader/BuyTicketButton.tsx src/features/online-board/components/BoardDetailsHeader/BuyTicketButton.test.tsx
git commit -m "Add BuyTicketButton component"

Task 11: RegistrationButton Component

Files:

  • Create: src/features/online-board/components/BoardDetailsHeader/RegistrationButton.tsx

  • Create: src/features/online-board/components/BoardDetailsHeader/RegistrationButton.test.tsx

  • Step 1: Write failing tests

// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { RegistrationButton } from "./RegistrationButton.js";
import type { ISimpleFlight } from "../../types.js";

vi.mock("@/i18n/provider.js", () => ({
  useTranslation: () => ({ t: (k: string) => k }),
}));

function makeFlight(carrier: string): ISimpleFlight {
  return {
    id: "X", routeType: "Direct", flyingTime: "1h", status: "Scheduled",
    flightId: { carrier, flightNumber: "0022", suffix: "", date: "20260417" },
    operatingBy: { carrier, flightNumber: "0022" },
    leg: {} as never,
  } as ISimpleFlight;
}

describe("RegistrationButton", () => {
  let openSpy: ReturnType<typeof vi.fn>;

  beforeEach(() => {
    openSpy = vi.fn();
    Object.defineProperty(window, "open", { value: openSpy, writable: true });
  });

  it("renders label", () => {
    render(<RegistrationButton flight={makeFlight("SU")} />);
    expect(screen.getByText("SHARED.ONLINE-REGISTRATION")).toBeTruthy();
  });

  it("has data-testid", () => {
    render(<RegistrationButton flight={makeFlight("SU")} />);
    expect(screen.getByTestId("registration-button")).toBeTruthy();
  });

  it("opens aeroflot.ru registration URL for SU", () => {
    render(<RegistrationButton flight={makeFlight("SU")} />);
    fireEvent.click(screen.getByTestId("registration-button"));
    expect(openSpy).toHaveBeenCalledTimes(1);
    expect(openSpy.mock.calls[0][0]).toContain("aeroflot.ru/sb/ckin");
  });

  it("does not open URL when carrier has no registrationUrl", () => {
    render(<RegistrationButton flight={makeFlight("AF")} />);
    fireEvent.click(screen.getByTestId("registration-button"));
    expect(openSpy).not.toHaveBeenCalled();
  });
});
  • Step 2: Verify fail

Run: pnpm vitest run src/features/online-board/components/BoardDetailsHeader/RegistrationButton.test.tsx

Expected: FAIL.

  • Step 3: Implement
import type { FC } from "react";
import { useTranslation } from "@/i18n/provider.js";
import type { ISimpleFlight } from "../../types.js";
import { AIRLINES } from "./airlines.js";
import "./actions.scss";

export interface RegistrationButtonProps {
  flight: ISimpleFlight;
}

export const RegistrationButton: FC<RegistrationButtonProps> = ({ flight }) => {
  const { t } = useTranslation();

  const handleClick = () => {
    const carrier = flight.operatingBy.carrier;
    if (!carrier) return;
    const config = AIRLINES[carrier];
    if (!config?.registrationUrl) return;
    window.open(config.registrationUrl, "_blank", "noopener,noreferrer");
  };

  return (
    <button
      type="button"
      className="flight-action-btn flight-action-btn--blue-light"
      data-testid="registration-button"
      onClick={handleClick}
    >
      {t("SHARED.ONLINE-REGISTRATION")}
    </button>
  );
};
  • Step 4: Verify pass + commit

Run: pnpm vitest run src/features/online-board/components/BoardDetailsHeader/RegistrationButton.test.tsx

Expected: 4 tests pass.

git add src/features/online-board/components/BoardDetailsHeader/RegistrationButton.tsx src/features/online-board/components/BoardDetailsHeader/RegistrationButton.test.tsx
git commit -m "Add RegistrationButton component"

Task 12: FlightStatusButton Component

Files:

  • Create: src/features/online-board/components/BoardDetailsHeader/FlightStatusButton.tsx

  • Create: src/features/online-board/components/BoardDetailsHeader/FlightStatusButton.test.tsx

  • Step 1: Write failing tests

// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { FlightStatusButton } from "./FlightStatusButton.js";
import type { ISimpleFlight } from "../../types.js";

vi.mock("@/i18n/provider.js", () => ({
  useTranslation: () => ({ t: (k: string) => k }),
}));

function makeFlight(carrier: string): ISimpleFlight {
  return {
    id: "X", routeType: "Direct", flyingTime: "1h", status: "Scheduled",
    flightId: { carrier, flightNumber: "0022", suffix: "", date: "20260417" },
    operatingBy: { carrier, flightNumber: "0022" },
    leg: {} as never,
  } as ISimpleFlight;
}

describe("FlightStatusButton", () => {
  let openSpy: ReturnType<typeof vi.fn>;

  beforeEach(() => {
    openSpy = vi.fn();
    Object.defineProperty(window, "open", { value: openSpy, writable: true });
  });

  it("renders label", () => {
    render(<FlightStatusButton flight={makeFlight("SU")} locale="ru" />);
    expect(screen.getByText("SHARED.DETAILS")).toBeTruthy();
  });

  it("has data-testid", () => {
    render(<FlightStatusButton flight={makeFlight("SU")} locale="ru" />);
    expect(screen.getByTestId("flight-status-button")).toBeTruthy();
  });

  it("opens native details URL for SU", () => {
    render(<FlightStatusButton flight={makeFlight("SU")} locale="ru" />);
    fireEvent.click(screen.getByTestId("flight-status-button"));
    expect(openSpy).toHaveBeenCalledTimes(1);
    const url = openSpy.mock.calls[0][0] as string;
    expect(url).toBe("/ru/onlineboard/SU0022-20260417");
  });

  it("opens external status URL for HZ (Aurora)", () => {
    render(<FlightStatusButton flight={makeFlight("HZ")} locale="ru" />);
    fireEvent.click(screen.getByTestId("flight-status-button"));
    expect(openSpy).toHaveBeenCalledTimes(1);
    expect(openSpy.mock.calls[0][0]).toContain("flyaurora");
  });

  it("applies small modifier when small=true", () => {
    render(<FlightStatusButton flight={makeFlight("SU")} locale="ru" small />);
    const el = screen.getByTestId("flight-status-button");
    expect(el.className).toContain("--small");
  });
});
  • Step 2: Verify fail

Run: pnpm vitest run src/features/online-board/components/BoardDetailsHeader/FlightStatusButton.test.tsx

Expected: FAIL.

  • Step 3: Implement
import type { FC } from "react";
import { useTranslation } from "@/i18n/provider.js";
import type { ISimpleFlight } from "../../types.js";
import { AIRLINES } from "./airlines.js";
import { buildOnlineBoardUrl } from "../../url.js";
import "./actions.scss";

export interface FlightStatusButtonProps {
  flight: ISimpleFlight;
  locale: string;
  small?: boolean;
}

export const FlightStatusButton: FC<FlightStatusButtonProps> = ({ flight, locale, small }) => {
  const { t } = useTranslation();

  const handleClick = () => {
    const carrier = flight.operatingBy.carrier;
    if (!carrier) return;
    const config = AIRLINES[carrier];
    if (!config) return;

    if (config.hasNativeStatus) {
      const path = buildOnlineBoardUrl({
        type: "details",
        carrier: flight.flightId.carrier,
        flightNumber: flight.flightId.flightNumber,
        ...(flight.flightId.suffix ? { suffix: flight.flightId.suffix } : {}),
        date: flight.flightId.date,
      });
      window.open(`/${locale}/${path}`, "_blank", "noopener,noreferrer");
    } else if (config.statusUrl) {
      window.open(config.statusUrl, "_blank", "noopener,noreferrer");
    }
  };

  const classes = [
    "flight-action-btn",
    "flight-action-btn--blue-light",
    small ? "flight-action-btn--small" : "",
  ]
    .filter(Boolean)
    .join(" ");

  return (
    <button
      type="button"
      className={classes}
      data-testid="flight-status-button"
      title={t("SHARED.DETAILS-TOOLTIP")}
      onClick={handleClick}
    >
      {t("SHARED.DETAILS")}
    </button>
  );
};
  • Step 4: Verify pass + commit

Run: pnpm vitest run src/features/online-board/components/BoardDetailsHeader/FlightStatusButton.test.tsx

Expected: 5 tests pass.

git add src/features/online-board/components/BoardDetailsHeader/FlightStatusButton.tsx src/features/online-board/components/BoardDetailsHeader/FlightStatusButton.test.tsx
git commit -m "Add FlightStatusButton component"

Task 13: SharePanel Component

Files:

  • Create: src/features/online-board/components/BoardDetailsHeader/SharePanel.tsx

  • Create: src/features/online-board/components/BoardDetailsHeader/SharePanel.test.tsx

  • Step 1: Write failing tests

// @vitest-environment jsdom
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { SharePanel } from "./SharePanel.js";

vi.mock("@/i18n/provider.js", () => ({
  useTranslation: () => ({ t: (k: string) => k }),
}));

describe("SharePanel", () => {
  it("renders Facebook, VK, Twitter links", () => {
    render(<SharePanel url="https://example.com/flight" locale="en" onClose={() => {}} />);
    expect(screen.getByTestId("share-facebook")).toBeTruthy();
    expect(screen.getByTestId("share-vk")).toBeTruthy();
    expect(screen.getByTestId("share-twitter")).toBeTruthy();
  });

  it("does not render Weibo for non-zh locale", () => {
    render(<SharePanel url="https://example.com/flight" locale="en" onClose={() => {}} />);
    expect(screen.queryByTestId("share-weibo")).toBeNull();
  });

  it("renders Weibo for zh locale", () => {
    render(<SharePanel url="https://example.com/flight" locale="zh" onClose={() => {}} />);
    expect(screen.getByTestId("share-weibo")).toBeTruthy();
  });

  it("Facebook link points to sharer.php with encoded URL", () => {
    render(<SharePanel url="https://example.com/flight" locale="en" onClose={() => {}} />);
    const a = screen.getByTestId("share-facebook") as HTMLAnchorElement;
    expect(a.href).toContain("facebook.com/sharer/sharer.php");
    expect(a.href).toContain("https%3A%2F%2Fexample.com%2Fflight");
  });

  it("copy button calls navigator.clipboard.writeText and onClose", async () => {
    const writeText = vi.fn().mockResolvedValue(undefined);
    Object.defineProperty(navigator, "clipboard", { value: { writeText }, writable: true });
    const onClose = vi.fn();
    render(<SharePanel url="https://example.com/flight" locale="en" onClose={onClose} />);
    fireEvent.click(screen.getByTestId("share-copy"));
    expect(writeText).toHaveBeenCalledWith("https://example.com/flight");
  });
});
  • Step 2: Verify fail

Run: pnpm vitest run src/features/online-board/components/BoardDetailsHeader/SharePanel.test.tsx

Expected: FAIL.

  • Step 3: Implement
import type { FC } from "react";
import { useTranslation } from "@/i18n/provider.js";
import "./actions.scss";

export interface SharePanelProps {
  url: string;
  locale: string;
  onClose: () => void;
}

export const SharePanel: FC<SharePanelProps> = ({ url, locale, onClose }) => {
  const { t } = useTranslation();
  const encoded = encodeURIComponent(url);

  const handleCopy = async () => {
    try {
      await navigator.clipboard.writeText(url);
      onClose();
    } catch {
      // ignore — copy failures are silent
    }
  };

  return (
    <div className="share-panel" data-testid="share-panel">
      <a
        data-testid="share-facebook"
        href={`https://www.facebook.com/sharer/sharer.php?u=${encoded}`}
        target="_blank"
        rel="noopener noreferrer"
      >
        Facebook
      </a>
      <a
        data-testid="share-vk"
        href={`https://vk.com/share.php?url=${encoded}`}
        target="_blank"
        rel="noopener noreferrer"
      >
        VK
      </a>
      <a
        data-testid="share-twitter"
        href={`https://twitter.com/share?text=${encodeURIComponent("My Flight")}&url=${encoded}`}
        target="_blank"
        rel="noopener noreferrer"
      >
        Twitter
      </a>
      {locale === "zh" && (
        <a
          data-testid="share-weibo"
          href={`https://service.weibo.com/share/share.php?url=${encoded}`}
          target="_blank"
          rel="noopener noreferrer"
        >
          Weibo
        </a>
      )}
      <button
        type="button"
        data-testid="share-copy"
        onClick={handleCopy}
      >
        {t("SHARED.COPY") || "Copy"}
      </button>
    </div>
  );
};
  • Step 4: Verify pass + commit

Run: pnpm vitest run src/features/online-board/components/BoardDetailsHeader/SharePanel.test.tsx

Expected: 5 tests pass.

git add src/features/online-board/components/BoardDetailsHeader/SharePanel.tsx src/features/online-board/components/BoardDetailsHeader/SharePanel.test.tsx
git commit -m "Add SharePanel component with social links and copy-to-clipboard"

Task 14: ShareButton Component

Files:

  • Create: src/features/online-board/components/BoardDetailsHeader/ShareButton.tsx

  • Create: src/features/online-board/components/BoardDetailsHeader/ShareButton.test.tsx

  • Step 1: Write failing tests

// @vitest-environment jsdom
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { ShareButton } from "./ShareButton.js";

vi.mock("@/i18n/provider.js", () => ({
  useTranslation: () => ({ t: (k: string) => k }),
}));

describe("ShareButton", () => {
  it("renders button with data-testid", () => {
    render(<ShareButton url="https://example.com" locale="en" />);
    expect(screen.getByTestId("share-button")).toBeTruthy();
  });

  it("panel is closed by default", () => {
    render(<ShareButton url="https://example.com" locale="en" />);
    expect(screen.queryByTestId("share-panel")).toBeNull();
  });

  it("opens panel on click", () => {
    render(<ShareButton url="https://example.com" locale="en" />);
    fireEvent.click(screen.getByTestId("share-button"));
    expect(screen.getByTestId("share-panel")).toBeTruthy();
  });

  it("closes panel when clicked again", () => {
    render(<ShareButton url="https://example.com" locale="en" />);
    fireEvent.click(screen.getByTestId("share-button"));
    fireEvent.click(screen.getByTestId("share-button"));
    expect(screen.queryByTestId("share-panel")).toBeNull();
  });
});
  • Step 2: Verify fail

Run: pnpm vitest run src/features/online-board/components/BoardDetailsHeader/ShareButton.test.tsx

Expected: FAIL.

  • Step 3: Implement
import { type FC, useState } from "react";
import { useTranslation } from "@/i18n/provider.js";
import { SharePanel } from "./SharePanel.js";
import "./actions.scss";

export interface ShareButtonProps {
  url: string;
  locale: string;
}

export const ShareButton: FC<ShareButtonProps> = ({ url, locale }) => {
  const { t } = useTranslation();
  const [open, setOpen] = useState(false);

  return (
    <div className="share-button-wrap">
      <button
        type="button"
        className="flight-action-btn flight-action-btn--transparent"
        data-testid="share-button"
        title={t("BOARD.SHARE")}
        onClick={() => setOpen((v) => !v)}
        aria-label={t("BOARD.SHARE")}
      >
        {/* Inline share icon */}
        <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
          <circle cx="18" cy="5" r="3" />
          <circle cx="6" cy="12" r="3" />
          <circle cx="18" cy="19" r="3" />
          <line x1="8.59" y1="13.51" x2="15.42" y2="17.49" />
          <line x1="15.41" y1="6.51" x2="8.59" y2="10.49" />
        </svg>
      </button>
      {open && <SharePanel url={url} locale={locale} onClose={() => setOpen(false)} />}
    </div>
  );
};
  • Step 4: Verify pass + commit

Run: pnpm vitest run src/features/online-board/components/BoardDetailsHeader/ShareButton.test.tsx

Expected: 4 tests pass.

git add src/features/online-board/components/BoardDetailsHeader/ShareButton.tsx src/features/online-board/components/BoardDetailsHeader/ShareButton.test.tsx
git commit -m "Add ShareButton component with toggle behavior"

Task 15: PrintButton Component (Stub)

Files:

  • Create: src/features/online-board/components/BoardDetailsHeader/PrintButton.tsx

  • Step 1: Create PrintButton

No failing test needed — this matches Angular's stub behavior (empty URL, hidden via print=false at the parent level).

// PrintButton.tsx
import type { FC } from "react";
import "./actions.scss";

export interface PrintButtonProps {
  flight: unknown;  // matches Angular prop but unused (URL is empty)
}

export const PrintButton: FC<PrintButtonProps> = () => {
  return (
    <a
      className="flight-action-btn flight-action-btn--transparent"
      data-testid="print-button"
      href=""
      aria-label="Print"
    >
      {/* Inline print icon */}
      <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
        <polyline points="6 9 6 2 18 2 18 9" />
        <path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2" />
        <rect x="6" y="14" width="12" height="8" />
      </svg>
    </a>
  );
};
  • Step 2: Commit
git add src/features/online-board/components/BoardDetailsHeader/PrintButton.tsx
git commit -m "Add PrintButton stub component (hidden on details page)"

Task 16: FlightActions Container

Files:

  • Create: src/features/online-board/components/BoardDetailsHeader/FlightActions.tsx

  • Create: src/features/online-board/components/BoardDetailsHeader/FlightActions.test.tsx

  • Step 1: Write failing tests

// @vitest-environment jsdom
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { FlightActions } from "./FlightActions.js";
import type { ISimpleFlight } from "../../types.js";

vi.mock("@/i18n/provider.js", () => ({
  useTranslation: () => ({ t: (k: string) => k }),
}));

// Mock visibility functions so we control what's shown
vi.mock("./visibility/buyTicketVisibility.js", () => ({ canBuyTicket: vi.fn(() => true) }));
vi.mock("./visibility/registrationVisibility.js", () => ({ canRegister: vi.fn(() => true) }));
vi.mock("./visibility/flightStatusVisibility.js", () => ({ canViewFlightStatus: vi.fn(() => true) }));

function makeFlight(): ISimpleFlight {
  return {
    id: "X", routeType: "Direct", flyingTime: "1h", status: "Scheduled",
    flightId: { carrier: "SU", flightNumber: "0022", suffix: "", date: "20260417" },
    operatingBy: { carrier: "SU", flightNumber: "0022" },
    leg: {
      departure: { scheduled: { airportCode: "SVO" } as never, latest: {} as never, times: { scheduledDeparture: { utc: "2026-04-20T10:00:00Z" } as never } as never, dispatch: "", gate: "", terminal: "", checkingStatus: "Scheduled", parkingStand: "" } as never,
      arrival: { scheduled: { airportCode: "LED" } as never, latest: {} as never, times: { scheduledArrival: {} as never } as never, dispatch: "", gate: "", terminal: "" } as never,
    } as never,
  } as ISimpleFlight;
}

describe("FlightActions", () => {
  it("renders all enabled buttons by default", () => {
    render(<FlightActions flight={makeFlight()} locale="ru" />);
    expect(screen.getByTestId("share-button")).toBeTruthy();
    expect(screen.getByTestId("buy-ticket-button")).toBeTruthy();
    expect(screen.getByTestId("registration-button")).toBeTruthy();
  });

  it("hides status button when showStatus=false", () => {
    render(<FlightActions flight={makeFlight()} locale="ru" showStatus={false} />);
    expect(screen.queryByTestId("flight-status-button")).toBeNull();
  });

  it("shows status button when showStatus=true", () => {
    render(<FlightActions flight={makeFlight()} locale="ru" showStatus={true} />);
    expect(screen.getByTestId("flight-status-button")).toBeTruthy();
  });

  it("hides share button when showShare=false", () => {
    render(<FlightActions flight={makeFlight()} locale="ru" showShare={false} />);
    expect(screen.queryByTestId("share-button")).toBeNull();
  });

  it("hides buy ticket when canBuyTicket returns false", async () => {
    const mod = await import("./visibility/buyTicketVisibility.js");
    vi.mocked(mod.canBuyTicket).mockReturnValue(false);
    render(<FlightActions flight={makeFlight()} locale="ru" />);
    expect(screen.queryByTestId("buy-ticket-button")).toBeNull();
  });
});
  • Step 2: Verify fail

Run: pnpm vitest run src/features/online-board/components/BoardDetailsHeader/FlightActions.test.tsx

Expected: FAIL.

  • Step 3: Implement
import type { FC } from "react";
import { useAppSettings } from "@/shared/hooks/useAppSettings.js";
import type { ISimpleFlight } from "../../types.js";
import { AIRLINES, AIRLINES_WITH_STATUS } from "./airlines.js";
import { canBuyTicket } from "./visibility/buyTicketVisibility.js";
import { canRegister } from "./visibility/registrationVisibility.js";
import { canViewFlightStatus } from "./visibility/flightStatusVisibility.js";
import { BuyTicketButton } from "./BuyTicketButton.js";
import { RegistrationButton } from "./RegistrationButton.js";
import { FlightStatusButton } from "./FlightStatusButton.js";
import { ShareButton } from "./ShareButton.js";
import { PrintButton } from "./PrintButton.js";

export interface FlightActionsProps {
  flight: ISimpleFlight;
  locale: string;
  showStatus?: boolean;    // default false
  showPrint?: boolean;     // default false
  showShare?: boolean;     // default true
  showRegister?: boolean;  // default true
  showBuy?: boolean;       // default true
}

export const FlightActions: FC<FlightActionsProps> = ({
  flight,
  locale,
  showStatus = false,
  showPrint = false,
  showShare = true,
  showRegister = true,
  showBuy = true,
}) => {
  const { flightStatusAvailableFromHours, buyTicketMinHours, buyTicketMaxHours } = useAppSettings();
  const now = new Date();

  const canBuy = showBuy && canBuyTicket(flight, now, buyTicketMinHours, buyTicketMaxHours);
  const canReg = showRegister && canRegister(flight, AIRLINES);
  const canStatus =
    showStatus &&
    canViewFlightStatus(flight, now, flightStatusAvailableFromHours, AIRLINES_WITH_STATUS);

  const shareUrl = typeof window !== "undefined" ? window.location.href : "";

  return (
    <div className="flight-actions" data-testid="flight-actions">
      {showPrint && <PrintButton flight={flight} />}
      {showShare && <ShareButton url={shareUrl} locale={locale} />}
      {canBuy && <BuyTicketButton flight={flight} locale={locale} />}
      {canReg && <RegistrationButton flight={flight} />}
      {canStatus && <FlightStatusButton flight={flight} locale={locale} />}
    </div>
  );
};
  • Step 4: Verify pass + commit

Run: pnpm vitest run src/features/online-board/components/BoardDetailsHeader/FlightActions.test.tsx

Expected: 5 tests pass.

git add src/features/online-board/components/BoardDetailsHeader/FlightActions.tsx src/features/online-board/components/BoardDetailsHeader/FlightActions.test.tsx
git commit -m "Add FlightActions container"

Task 17: DetailsHeaderBadge Component

Files:

  • Create: src/features/online-board/components/BoardDetailsHeader/DetailsHeaderBadge.tsx

  • Create: src/features/online-board/components/BoardDetailsHeader/DetailsHeaderBadge.test.tsx

  • Step 1: Write failing tests

// @vitest-environment jsdom
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { DetailsHeaderBadge } from "./DetailsHeaderBadge.js";
import type { ISimpleFlight } from "../../types.js";

vi.mock("@/i18n/provider.js", () => ({
  useTranslation: () => ({ t: (k: string) => k }),
}));

function makeDirect(carrier = "SU", num = "0022"): ISimpleFlight {
  return {
    id: "X", routeType: "Direct", flyingTime: "1h", status: "Scheduled",
    flightId: { carrier, flightNumber: num, suffix: "", date: "20260417" },
    operatingBy: { carrier, flightNumber: num },
    leg: {} as never,
  } as ISimpleFlight;
}

function makeMultiLeg(): ISimpleFlight {
  return {
    id: "Y", routeType: "MultiLeg", flyingTime: "2h", status: "Scheduled",
    flightId: { carrier: "SU", flightNumber: "0022", suffix: "", date: "20260417" },
    operatingBy: { carrier: "SU", flightNumber: "0022" },
    legs: [
      { operatingBy: { carrier: "AF", flightNumber: "209" } } as never,
      { operatingBy: { carrier: "UA", flightNumber: "456" } } as never,
    ],
  } as ISimpleFlight;
}

describe("DetailsHeaderBadge", () => {
  it("renders flight number 'SU 0022'", () => {
    render(<DetailsHeaderBadge flight={makeDirect("SU", "0022")} locale="ru" />);
    expect(screen.getByText(/SU\s*0022/)).toBeTruthy();
  });

  it("renders OperatorLogo", () => {
    render(<DetailsHeaderBadge flight={makeDirect()} locale="ru" />);
    expect(screen.getByTestId("operator-logo")).toBeTruthy();
  });

  it("renders codesharing list for multi-leg flight with distinct carriers", () => {
    render(<DetailsHeaderBadge flight={makeMultiLeg()} locale="ru" />);
    // Shows "AF 209, UA 456" or similar
    expect(screen.getByTestId("codesharing")).toBeTruthy();
  });

  it("does not render codesharing for a direct flight", () => {
    render(<DetailsHeaderBadge flight={makeDirect()} locale="ru" />);
    expect(screen.queryByTestId("codesharing")).toBeNull();
  });

  it("renders small status button when showStatus=true", () => {
    render(<DetailsHeaderBadge flight={makeDirect()} locale="ru" showStatus />);
    expect(screen.getByTestId("flight-status-button")).toBeTruthy();
  });
});
  • Step 2: Verify fail

Run: pnpm vitest run src/features/online-board/components/BoardDetailsHeader/DetailsHeaderBadge.test.tsx

Expected: FAIL.

  • Step 3: Implement
import type { FC } from "react";
import type { ISimpleFlight, IFlightLeg } from "../../types.js";
import { OperatorLogo } from "./OperatorLogo.js";
import { FlightStatusButton } from "./FlightStatusButton.js";

export interface DetailsHeaderBadgeProps {
  flight: ISimpleFlight;
  locale: string;
  large?: boolean;
  round?: boolean;
  showStatus?: boolean;
}

function getCodeshareLegs(flight: ISimpleFlight): IFlightLeg[] {
  if (flight.routeType !== "MultiLeg") return [];
  return flight.legs.filter((l) => l.operatingBy?.carrier && l.operatingBy.carrier !== flight.flightId.carrier);
}

export const DetailsHeaderBadge: FC<DetailsHeaderBadgeProps> = ({
  flight,
  locale,
  large = true,
  round = false,
  showStatus = false,
}) => {
  const codeshareLegs = getCodeshareLegs(flight);
  const primaryNumber = `${flight.flightId.carrier} ${flight.flightId.flightNumber}`;

  return (
    <div className="details-header-badge">
      <div className="details-header-badge__flight-number">
        <div className="details-header-badge__primary">{primaryNumber}</div>
        {codeshareLegs.length > 0 && (
          <div className="details-header-badge__codesharing" data-testid="codesharing">
            {codeshareLegs
              .map((l) => `${l.operatingBy.carrier} ${l.operatingBy.flightNumber}`)
              .join(", ")}
          </div>
        )}
      </div>
      <OperatorLogo flight={flight} locale={locale} large={large} round={round} />
      {showStatus && <FlightStatusButton flight={flight} locale={locale} small />}
    </div>
  );
};
  • Step 4: Verify pass + commit

Run: pnpm vitest run src/features/online-board/components/BoardDetailsHeader/DetailsHeaderBadge.test.tsx

Expected: 5 tests pass.

git add src/features/online-board/components/BoardDetailsHeader/DetailsHeaderBadge.tsx src/features/online-board/components/BoardDetailsHeader/DetailsHeaderBadge.test.tsx
git commit -m "Add DetailsHeaderBadge with flight number and codesharing"

Task 18: FlightEvents Component

Files:

  • Create: src/features/online-board/components/BoardDetailsHeader/FlightEvents.tsx

  • Create: src/features/online-board/components/BoardDetailsHeader/FlightEvents.test.tsx

  • Step 1: Write failing tests

// @vitest-environment jsdom
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { FlightEvents } from "./FlightEvents.js";

vi.mock("@/i18n/provider.js", () => ({
  useTranslation: () => ({ t: (k: string) => k }),
}));

describe("FlightEvents", () => {
  it("renders nothing when no events", () => {
    const { container } = render(<FlightEvents changeRoute={false} reroute={false} />);
    expect(container.firstChild).toBeNull();
  });

  it("renders route change icon when changeRoute=true", () => {
    render(<FlightEvents changeRoute={true} reroute={false} showDescription />);
    expect(screen.getByTestId("flight-event-change-route")).toBeTruthy();
    expect(screen.getByText("SHARED.ROUTE-CHANGE")).toBeTruthy();
  });

  it("renders reroute icon when reroute=true", () => {
    render(<FlightEvents changeRoute={false} reroute={true} showDescription />);
    expect(screen.getByTestId("flight-event-reroute")).toBeTruthy();
    expect(screen.getByText("SHARED.RETURN")).toBeTruthy();
  });

  it("renders both when both flags are set", () => {
    render(<FlightEvents changeRoute={true} reroute={true} showDescription />);
    expect(screen.getByTestId("flight-event-change-route")).toBeTruthy();
    expect(screen.getByTestId("flight-event-reroute")).toBeTruthy();
  });

  it("hides descriptions when showDescription=false", () => {
    render(<FlightEvents changeRoute={true} reroute={true} />);
    expect(screen.queryByText("SHARED.ROUTE-CHANGE")).toBeNull();
    expect(screen.queryByText("SHARED.RETURN")).toBeNull();
  });
});
  • Step 2: Verify fail

Run: pnpm vitest run src/features/online-board/components/BoardDetailsHeader/FlightEvents.test.tsx

Expected: FAIL.

  • Step 3: Implement
import type { FC } from "react";
import { useTranslation } from "@/i18n/provider.js";

export interface FlightEventsProps {
  changeRoute: boolean;
  reroute: boolean;
  showDescription?: boolean;
}

export const FlightEvents: FC<FlightEventsProps> = ({ changeRoute, reroute, showDescription }) => {
  const { t } = useTranslation();
  if (!changeRoute && !reroute) return null;

  return (
    <div className="flight-events">
      {changeRoute && (
        <div className="flight-events__event" data-testid="flight-event-change-route">
          {/* Change icon (ported from Angular) */}
          <svg xmlns="http://www.w3.org/2000/svg" width="20" height="12" viewBox="0 0 20 12" className="change-route-svg">
            <g fill="none" stroke="#f37b09" strokeLinecap="round" strokeLinejoin="round">
              <path d="M12.356 3.497H.5" />
              <path d="M3.29 6.286.5 3.496 3.29.707" />
              <path d="M6.864 9.076H18.72" />
              <path d="m15.93 6.286 2.79 2.79-2.79 2.79" />
            </g>
          </svg>
          {showDescription && (
            <span className="flight-events__description">{t("SHARED.ROUTE-CHANGE")}</span>
          )}
        </div>
      )}
      {reroute && (
        <div className="flight-events__event" data-testid="flight-event-reroute">
          {/* Return icon (ported from Angular) */}
          <svg xmlns="http://www.w3.org/2000/svg" width="13" height="6" viewBox="0 0 13 6" className="return-svg">
            <path
              d="M6.125.583A6.1 6.1 0 0 0 2.1 2.1L0 0v5.25h5.25L3.138 3.138a4.65 4.65 0 0 1 7.42 2.112l1.382-.455A6.133 6.133 0 0 0 6.125.583Z"
              fill="#c8102e"
            />
          </svg>
          {showDescription && (
            <span className="flight-events__description">{t("SHARED.RETURN")}</span>
          )}
        </div>
      )}
    </div>
  );
};
  • Step 4: Verify pass + commit

Run: pnpm vitest run src/features/online-board/components/BoardDetailsHeader/FlightEvents.test.tsx

Expected: 5 tests pass.

git add src/features/online-board/components/BoardDetailsHeader/FlightEvents.tsx src/features/online-board/components/BoardDetailsHeader/FlightEvents.test.tsx
git commit -m "Add FlightEvents component with route-change and reroute indicators"

Task 19: LastUpdate Component

Files:

  • Create: src/features/online-board/components/BoardDetailsHeader/LastUpdate.tsx

  • Create: src/features/online-board/components/BoardDetailsHeader/LastUpdate.test.tsx

  • Step 1: Write failing tests

// @vitest-environment jsdom
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { LastUpdate } from "./LastUpdate.js";
import type { ISimpleFlight } from "../../types.js";

vi.mock("@/i18n/provider.js", () => ({
  useTranslation: () => ({ t: (k: string) => k }),
}));

function makeFlight(updated: string): ISimpleFlight {
  return {
    id: "X", routeType: "Direct", flyingTime: "1h", status: "Scheduled",
    flightId: { carrier: "SU", flightNumber: "0022", suffix: "", date: "20260417" },
    operatingBy: { carrier: "SU", flightNumber: "0022" },
    leg: { updated } as never,
  } as ISimpleFlight;
}

describe("LastUpdate", () => {
  it("renders LAST-UPDATE label", () => {
    render(<LastUpdate flight={makeFlight("2026-04-17T10:30:00Z")} locale="en" />);
    expect(screen.getByText(/SHARED\.LAST-UPDATE/)).toBeTruthy();
  });

  it("renders timestamp in HH:mm DD.MM.YYYY format", () => {
    render(<LastUpdate flight={makeFlight("2026-04-17T10:30:00Z")} locale="en" />);
    expect(screen.getByTestId("last-update-timestamp")).toBeTruthy();
    const text = screen.getByTestId("last-update-timestamp").textContent ?? "";
    expect(text).toMatch(/\d{2}:\d{2}\s+\d{2}\.\d{2}\.\d{4}/);
  });

  it("renders nothing for timestamp when leg.updated is empty", () => {
    render(<LastUpdate flight={makeFlight("")} locale="en" />);
    const ts = screen.queryByTestId("last-update-timestamp");
    expect(ts?.textContent?.trim() ?? "").toBe("");
  });
});
  • Step 2: Verify fail

Run: pnpm vitest run src/features/online-board/components/BoardDetailsHeader/LastUpdate.test.tsx

Expected: FAIL.

  • Step 3: Implement
import type { FC } from "react";
import { parseISO, format, isValid } from "date-fns";
import { useTranslation } from "@/i18n/provider.js";
import type { ISimpleFlight } from "../../types.js";
import { ShareButton } from "./ShareButton.js";

export interface LastUpdateProps {
  flight: ISimpleFlight;
  locale: string;
}

function getUpdated(flight: ISimpleFlight): string | undefined {
  const leg = flight.routeType === "Direct" ? flight.leg : flight.legs[0];
  return leg?.updated;
}

function formatUpdated(updated: string | undefined): string {
  if (!updated) return "";
  const d = parseISO(updated);
  if (!isValid(d)) return "";
  return format(d, "HH:mm dd.MM.yyyy");
}

export const LastUpdate: FC<LastUpdateProps> = ({ flight, locale }) => {
  const { t } = useTranslation();
  const timestamp = formatUpdated(getUpdated(flight));
  const shareUrl = typeof window !== "undefined" ? window.location.href : "";

  return (
    <div className="last-update">
      <ShareButton url={shareUrl} locale={locale} />
      <span className="last-update__description">
        <span>{t("SHARED.LAST-UPDATE")}:</span>
        <span className="last-update__time" data-testid="last-update-timestamp">&nbsp;{timestamp}</span>
      </span>
    </div>
  );
};
  • Step 4: Verify pass + commit

Run: pnpm vitest run src/features/online-board/components/BoardDetailsHeader/LastUpdate.test.tsx

Expected: 3 tests pass.

git add src/features/online-board/components/BoardDetailsHeader/LastUpdate.tsx src/features/online-board/components/BoardDetailsHeader/LastUpdate.test.tsx
git commit -m "Add LastUpdate component with timestamp and mobile share"

Task 20: BoardDetailsHeader Orchestrator + index.ts

Files:

  • Create: src/features/online-board/components/BoardDetailsHeader/BoardDetailsHeader.tsx

  • Create: src/features/online-board/components/BoardDetailsHeader/BoardDetailsHeader.scss

  • Create: src/features/online-board/components/BoardDetailsHeader/BoardDetailsHeader.test.tsx

  • Create: src/features/online-board/components/BoardDetailsHeader/index.ts

  • Step 1: Write failing tests

// @vitest-environment jsdom
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { BoardDetailsHeader } from "./BoardDetailsHeader.js";
import type { ISimpleFlight } from "../../types.js";

vi.mock("@/i18n/provider.js", () => ({ useTranslation: () => ({ t: (k: string) => k }) }));
vi.mock("@/shared/hooks/useAppSettings.js", () => ({
  useAppSettings: () => ({
    onlineboardSearchFrom: 2, onlineboardSearchTo: 14,
    scheduleSearchFrom: 30, scheduleSearchTo: 30,
    flightStatusAvailableFromHours: 24,
    buyTicketMinHours: 2, buyTicketMaxHours: 72,
    loading: false, error: null,
  }),
}));

function makeFlight(flagsOverrides = {}): ISimpleFlight {
  return {
    id: "X", routeType: "Direct", flyingTime: "1h", status: "Scheduled",
    flightId: { carrier: "SU", flightNumber: "0022", suffix: "", date: "20260417" },
    operatingBy: { carrier: "SU", flightNumber: "0022" },
    leg: {
      departure: { scheduled: { airportCode: "SVO" } as never, latest: {} as never, times: { scheduledDeparture: { utc: "2026-04-20T10:00:00Z" } as never } as never, dispatch: "", gate: "", terminal: "", checkingStatus: "Scheduled", parkingStand: "" } as never,
      arrival: { scheduled: { airportCode: "LED" } as never, latest: {} as never, times: { scheduledArrival: {} as never } as never, dispatch: "", gate: "", terminal: "" } as never,
      updated: "2026-04-17T10:00:00Z",
      flags: { checkinAvailable: false, returnToAirport: false, routeChanged: false, ...flagsOverrides },
    } as never,
  } as ISimpleFlight;
}

describe("BoardDetailsHeader", () => {
  it("renders badge, actions, last-update", () => {
    render(<BoardDetailsHeader flight={makeFlight()} locale="ru" />);
    expect(screen.getByTestId("operator-logo")).toBeTruthy();
    expect(screen.getByTestId("flight-actions")).toBeTruthy();
    expect(screen.getByTestId("last-update-timestamp")).toBeTruthy();
  });

  it("renders change-route event when leg.flags.routeChanged=true", () => {
    render(<BoardDetailsHeader flight={makeFlight({ routeChanged: true })} locale="ru" />);
    expect(screen.getByTestId("flight-event-change-route")).toBeTruthy();
  });

  it("renders reroute event when leg.flags.returnToAirport=true", () => {
    render(<BoardDetailsHeader flight={makeFlight({ returnToAirport: true })} locale="ru" />);
    expect(screen.getByTestId("flight-event-reroute")).toBeTruthy();
  });

  it("has data-testid=board-details-header", () => {
    render(<BoardDetailsHeader flight={makeFlight()} locale="ru" />);
    expect(screen.getByTestId("board-details-header")).toBeTruthy();
  });
});
  • Step 2: Verify fail

Run: pnpm vitest run src/features/online-board/components/BoardDetailsHeader/BoardDetailsHeader.test.tsx

Expected: FAIL.

  • Step 3: Create BoardDetailsHeader.scss
// BoardDetailsHeader.scss
.board-details-header {
  display: grid;
  grid-template-columns: auto 1fr;
  align-items: center;
  gap: 16px;
  padding: 24px;
  background: #fff;
  border-radius: 8px;

  &__badge { grid-column: 1; }
  &__actions-row { grid-column: 2; display: flex; justify-content: flex-end; }
  &__events-row {
    grid-column: 1 / -1;
    display: flex;
    justify-content: space-between;
    align-items: center;
  }

  @media (max-width: 768px) {
    grid-template-columns: 1fr;
    padding: 16px;
  }
}

.details-header-badge {
  display: flex;
  align-items: center;
  gap: 12px;

  &__flight-number {
    display: flex;
    flex-direction: column;
  }

  &__primary {
    font-size: 24px;
    font-weight: 600;
    color: #1a3a5c;
  }

  &__codesharing {
    font-size: 12px;
    color: #666;
    margin-top: 2px;
  }
}

.flight-events {
  display: flex;
  gap: 12px;
  align-items: center;

  &__event {
    display: inline-flex;
    align-items: center;
    gap: 6px;
    padding: 4px 10px;
    border: 1px solid #ddd;
    border-radius: 14px;
    height: 28px;
    font-size: 12px;
  }
}

.last-update {
  display: flex;
  align-items: center;
  gap: 8px;

  &__description {
    font-size: 12px;
    color: #666;
  }

  .share-button-wrap {
    @media (min-width: 769px) {
      display: none;
    }
  }
}

.share-button-wrap {
  position: relative;

  .share-panel {
    position: absolute;
    top: 100%;
    right: 0;
    background: #fff;
    border: 1px solid #ddd;
    border-radius: 8px;
    padding: 8px;
    display: flex;
    flex-direction: column;
    gap: 4px;
    box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
    min-width: 140px;
    z-index: 10;

    a, button {
      padding: 6px 10px;
      font-size: 14px;
      color: #1a3a5c;
      text-decoration: none;
      background: none;
      border: none;
      text-align: left;
      cursor: pointer;

      &:hover {
        background: #f0f4f8;
      }
    }
  }
}

.flight-actions {
  display: flex;
  gap: 8px;
  align-items: center;
}
  • Step 4: Create BoardDetailsHeader.tsx
import type { FC } from "react";
import type { ISimpleFlight, IFlightLeg } from "../../types.js";
import { DetailsHeaderBadge } from "./DetailsHeaderBadge.js";
import { FlightActions } from "./FlightActions.js";
import { FlightEvents } from "./FlightEvents.js";
import { LastUpdate } from "./LastUpdate.js";
import "./BoardDetailsHeader.scss";

export interface BoardDetailsHeaderProps {
  flight: ISimpleFlight;
  locale: string;
}

function getLegs(flight: ISimpleFlight): IFlightLeg[] {
  if (flight.routeType === "Direct") return [flight.leg];
  return flight.legs;
}

function anyLegFlag(flight: ISimpleFlight, key: "routeChanged" | "returnToAirport"): boolean {
  return getLegs(flight).some((l) => l.flags[key]);
}

export const BoardDetailsHeader: FC<BoardDetailsHeaderProps> = ({ flight, locale }) => {
  const changeRoute = anyLegFlag(flight, "routeChanged");
  const reroute = anyLegFlag(flight, "returnToAirport");

  return (
    <div className="board-details-header" data-testid="board-details-header">
      <div className="board-details-header__badge">
        <DetailsHeaderBadge flight={flight} locale={locale} large />
      </div>
      <div className="board-details-header__actions-row">
        <FlightActions flight={flight} locale={locale} />
      </div>
      <div className="board-details-header__events-row">
        <FlightEvents changeRoute={changeRoute} reroute={reroute} showDescription />
        <LastUpdate flight={flight} locale={locale} />
      </div>
    </div>
  );
};
  • Step 5: Create index.ts
export { BoardDetailsHeader } from "./BoardDetailsHeader.js";
export type { BoardDetailsHeaderProps } from "./BoardDetailsHeader.js";
  • Step 6: Verify pass + commit

Run: pnpm vitest run src/features/online-board/components/BoardDetailsHeader/BoardDetailsHeader.test.tsx

Expected: 4 tests pass.

git add src/features/online-board/components/BoardDetailsHeader/BoardDetailsHeader.tsx src/features/online-board/components/BoardDetailsHeader/BoardDetailsHeader.scss src/features/online-board/components/BoardDetailsHeader/BoardDetailsHeader.test.tsx src/features/online-board/components/BoardDetailsHeader/index.ts
git commit -m "Add BoardDetailsHeader orchestrator component"

Task 21: Wire into OnlineBoardDetailsPage

Files:

  • Modify: src/features/online-board/components/OnlineBoardDetailsPage.tsx

  • Modify: src/features/online-board/components/OnlineBoardDetailsPage.test.tsx

  • Step 1: Add integration test

Append to the outer describe block in OnlineBoardDetailsPage.test.tsx:

describe("board details header integration", () => {
  it("renders BoardDetailsHeader at top of details", () => {
    mockState = { flight: mockFlight, allFlights: [mockFlight], daysOfFlight: ["20260416"], loading: false, error: null };
    render(<OnlineBoardDetailsPage flightId={mockFlightId} locale="ru" canonicalOrigin="https://example.com" />);
    expect(screen.getByTestId("board-details-header")).toBeTruthy();
  });
});
  • Step 2: Verify fail

Run: pnpm vitest run src/features/online-board/components/OnlineBoardDetailsPage.test.tsx

Expected: the new test fails.

  • Step 3: Wire in the component

Edit src/features/online-board/components/OnlineBoardDetailsPage.tsx. Add import near other component imports:

import { BoardDetailsHeader } from "./BoardDetailsHeader/index.js";

Inside the main return (happy path, after <div className="flight-details"> opens), replace the existing <div className="flight-details__header"> block with:

<BoardDetailsHeader flight={displayFlight} locale={locale} />
  • Step 4: Verify pass

Run: pnpm vitest run src/features/online-board/components/OnlineBoardDetailsPage.test.tsx

Expected: all tests pass.

  • Step 5: Full suite + typecheck

Run: pnpm test Run: pnpm typecheck

Expected: no new failures or type errors.

  • Step 6: Commit
git add src/features/online-board/components/OnlineBoardDetailsPage.tsx src/features/online-board/components/OnlineBoardDetailsPage.test.tsx
git commit -m "Wire BoardDetailsHeader into OnlineBoardDetailsPage"

Task 22: Manual Browser Verification

Files: None — observational.

  • Step 1: Ensure dev:full is running

Run pnpm dev:full. Verify /api/appSettings returns 200.

  • Step 2: Run Playwright verification script
cat << 'SCRIPT' | npx tsx --input-type=module -
import { chromium } from "@playwright/test";
import { mockAngularAPIs } from "./tests/e2e-angular/support/angular-api-mock.js";

const browser = await chromium.launch({ headless: true });
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
const page = await ctx.newPage();
await mockAngularAPIs(page);

// Mock a flight far enough in the future to trigger Buy Ticket visibility
const depUtc = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();
await page.route("**/onlineboard/details*", (route) => {
  route.fulfill({
    status: 200, contentType: "application/json",
    body: JSON.stringify({
      data: {
        partners: [],
        routes: [{
          id: "SU0022-X", routeType: "Direct", flyingTime: "1h 30m", status: "Scheduled",
          flightId: { carrier: "SU", flightNumber: "0022", suffix: "", date: "20260418", dateLT: "20260418" },
          operatingBy: { carrier: "SU", flightNumber: "0022" },
          leg: {
            index: 0, flyingTime: "1h 30m", status: "Scheduled", updated: "2026-04-17T10:00:00Z", dayChange: 0,
            flags: { checkinAvailable: false, returnToAirport: false, routeChanged: false },
            operatingBy: { carrier: "SU", flightNumber: "0022" },
            departure: {
              scheduled: { airport: "SVO", airportCode: "SVO", city: "MOW", cityCode: "MOW", countryCode: "RU" },
              latest: { airport: "SVO", airportCode: "SVO", city: "MOW", cityCode: "MOW", countryCode: "RU" },
              dispatch: "", gate: "", terminal: "", checkingStatus: "Scheduled", parkingStand: "",
              times: { scheduledDeparture: { dayChange: { value: 0, title: "" }, local: "10:00", localTime: "10:00", tzOffset: 3, utc: depUtc } },
            },
            arrival: {
              scheduled: { airport: "LED", airportCode: "LED", city: "SPB", cityCode: "LED", countryCode: "RU" },
              latest: { airport: "LED", airportCode: "LED", city: "SPB", cityCode: "LED", countryCode: "RU" },
              dispatch: "", gate: "", terminal: "",
              times: { scheduledArrival: { dayChange: { value: 0, title: "" }, local: "12:30", localTime: "12:30", tzOffset: 3, utc: "" } },
            },
            equipment: {},
          },
        }],
        daysOfFlight: ["20260418"],
      },
    }),
  });
});

await page.goto("http://localhost:8080/ru/onlineboard/SU0022-20260418", { waitUntil: "networkidle" });
await page.waitForTimeout(3000);

const header = await page.locator('[data-testid="board-details-header"]').count();
const logo = await page.locator('[data-testid="operator-logo"]').count();
const actions = await page.locator('[data-testid="flight-actions"]').count();
const buyBtn = await page.locator('[data-testid="buy-ticket-button"]').count();
const lastUpdate = await page.locator('[data-testid="last-update-timestamp"]').count();

console.log("header:", header, "logo:", logo, "actions:", actions, "buyBtn:", buyBtn, "lastUpdate:", lastUpdate);

// Click Share → panel appears
await page.locator('[data-testid="share-button"]').first().click();
await page.waitForTimeout(500);
const panel = await page.locator('[data-testid="share-panel"]').count();
console.log("share panel open:", panel);

await page.screenshot({ path: "/tmp/b4-header-verify.png", fullPage: true });
console.log("Screenshot: /tmp/b4-header-verify.png");

await browser.close();
SCRIPT

Expected: header: 1, logo: 1, actions: 1, buyBtn: 1, lastUpdate: 1, share panel open: 1.

  • Step 3: No commit — observational