From b3ab73253d05087cbb6f7f06d2e28aedd085cdd3 Mon Sep 17 00:00:00 2001 From: gnezim Date: Wed, 15 Apr 2026 07:55:00 +0300 Subject: [PATCH] Add data model types, datetime utils, and dictionary hook Port Angular flight types (ISimpleFlight, IFlightLeg, ITimesSet, etc.) to minimal React-friendly interfaces. Add formatDuration/formatTime/ formatDate/isDayChange as pure functions. Stub useCityName hook as passthrough pending customer dictionary API endpoint. --- src/features/online-board/types.test.ts | 208 +++++++++++++++++++++ src/features/online-board/types.ts | 184 ++++++++++++++++++ src/shared/hooks/useDictionaries.test.ts | 18 ++ src/shared/hooks/useDictionaries.ts | 22 +++ src/shared/utils/datetime/datetime.test.ts | 138 ++++++++++++++ src/shared/utils/datetime/index.ts | 90 +++++++++ 6 files changed, 660 insertions(+) create mode 100644 src/features/online-board/types.test.ts create mode 100644 src/features/online-board/types.ts create mode 100644 src/shared/hooks/useDictionaries.test.ts create mode 100644 src/shared/hooks/useDictionaries.ts create mode 100644 src/shared/utils/datetime/datetime.test.ts create mode 100644 src/shared/utils/datetime/index.ts diff --git a/src/features/online-board/types.test.ts b/src/features/online-board/types.test.ts new file mode 100644 index 00000000..441a663e --- /dev/null +++ b/src/features/online-board/types.test.ts @@ -0,0 +1,208 @@ +import { describe, it, expect } from "vitest"; +import type { + FlightStatus, + FlightRequestType, + ITimesSet, + IAirportInfo, + IFlightLeg, + IFlightLegDepartureStation, + IFlightLegArrivalStation, + IParsedFlightId, + IDirectFlight, + IMultiLegFlight, + ISimpleFlight, + IBoardResponse, + IDaysResponse, +} from "./types.js"; + +/** + * Type-level satisfaction tests. These verify that our interfaces + * accept the shapes the API actually sends. If the types are wrong, + * these assignments fail at compile time. + */ + +describe("online-board types", () => { + it("FlightStatus accepts all known API values", () => { + const statuses: FlightStatus[] = [ + "Scheduled", + "Sent", + "InFlight", + "Landed", + "Arrived", + "Delayed", + "Cancelled", + "Unknown", + ]; + expect(statuses).toHaveLength(8); + }); + + it("FlightRequestType accepts all search modes", () => { + const modes: FlightRequestType[] = [ + "flight", + "departure", + "arrival", + "route", + ]; + expect(modes).toHaveLength(4); + }); + + it("ITimesSet satisfies the API shape", () => { + const times: ITimesSet = { + dayChange: { value: 0, title: "" }, + local: "2025-01-15T10:30:00", + localTime: "10:30", + tzOffset: 3, + utc: "2025-01-15T07:30:00Z", + }; + expect(times.localTime).toBe("10:30"); + }); + + it("IAirportInfo satisfies the API shape", () => { + const info: IAirportInfo = { + airport: "Sheremetyevo", + airportCode: "SVO", + city: "Moscow", + cityCode: "MOW", + countryCode: "RU", + }; + expect(info.airportCode).toBe("SVO"); + }); + + it("IParsedFlightId holds parsed URL params", () => { + const id: IParsedFlightId = { + carrier: "SU", + flightNumber: "100", + date: "20250115", + }; + expect(id.carrier).toBe("SU"); + }); + + it("IDirectFlight satisfies a direct flight shape", () => { + const depStation: IFlightLegDepartureStation = { + scheduled: { + airport: "Sheremetyevo", + airportCode: "SVO", + city: "Moscow", + cityCode: "MOW", + countryCode: "RU", + }, + checkingStatus: "Scheduled", + times: { + scheduledDeparture: { + dayChange: { value: 0, title: "" }, + local: "2025-01-15T10:30:00", + localTime: "10:30", + tzOffset: 3, + utc: "2025-01-15T07:30:00Z", + }, + }, + }; + + const arrStation: IFlightLegArrivalStation = { + scheduled: { + airport: "Pulkovo", + airportCode: "LED", + city: "St Petersburg", + cityCode: "LED", + countryCode: "RU", + }, + times: { + scheduledArrival: { + dayChange: { value: 0, title: "" }, + local: "2025-01-15T12:00:00", + localTime: "12:00", + tzOffset: 3, + utc: "2025-01-15T09:00:00Z", + }, + }, + }; + + const leg: IFlightLeg = { + arrival: arrStation, + dayChange: 0, + departure: depStation, + equipment: { name: "Airbus A320", code: "320" }, + flags: { + checkinAvailable: false, + returnToAirport: false, + routeChanged: false, + }, + flyingTime: "01:30", + index: 0, + operatingBy: { carrier: "SU", flightNumber: "100" }, + status: "Scheduled", + updated: "2025-01-15T10:00:00Z", + }; + + const flight: IDirectFlight = { + flightId: { + carrier: "SU", + date: "2025-01-15", + flightNumber: "100", + suffix: "", + }, + flyingTime: "01:30", + operatingBy: { carrier: "SU", flightNumber: "100" }, + id: "su-100-20250115", + status: "Scheduled", + routeType: "Direct", + leg, + }; + + expect(flight.routeType).toBe("Direct"); + }); + + it("IMultiLegFlight satisfies a multi-leg shape", () => { + const flight: IMultiLegFlight = { + flightId: { + carrier: "SU", + date: "2025-01-15", + flightNumber: "200", + suffix: "", + }, + flyingTime: "05:00", + operatingBy: {}, + id: "su-200-20250115", + status: "Scheduled", + routeType: "MultiLeg", + legs: [], + }; + expect(flight.routeType).toBe("MultiLeg"); + }); + + it("ISimpleFlight is a union of direct and multi-leg", () => { + const direct: ISimpleFlight = { + flightId: { + carrier: "SU", + date: "2025-01-15", + flightNumber: "100", + suffix: "", + }, + flyingTime: "01:30", + operatingBy: {}, + id: "su-100", + status: "Scheduled", + routeType: "Direct", + leg: {} as IFlightLeg, + }; + expect(direct.status).toBe("Scheduled"); + }); + + it("IBoardResponse matches API response shape", () => { + const resp: IBoardResponse = { + data: { + partners: ["SU"], + routes: [], + daysOfFlight: ["2025-01-15"], + }, + }; + expect(resp.data.partners).toContain("SU"); + }); + + it("IDaysResponse matches API response shape", () => { + const resp: IDaysResponse = { + days: "1,2,3,4,5", + }; + expect(resp.days).toBeTruthy(); + }); +}); diff --git a/src/features/online-board/types.ts b/src/features/online-board/types.ts new file mode 100644 index 00000000..3660d849 --- /dev/null +++ b/src/features/online-board/types.ts @@ -0,0 +1,184 @@ +/** + * Data model types for the Online Board feature. + * + * Ported from Angular typings (ClientApp/src/typings/) — flattened into + * minimal, UI-oriented interfaces with no Angular dependencies. + */ + +// --------------------------------------------------------------------------- +// Enums & literals +// --------------------------------------------------------------------------- + +/** Flight status values returned by the API */ +export type FlightStatus = + | "Scheduled" + | "Sent" + | "InFlight" + | "Landed" + | "Arrived" + | "Delayed" + | "Cancelled" + | "Unknown"; + +/** Route shape discriminator */ +export type RouteType = "Direct" | "MultiLeg" | "Connecting"; + +/** Search request type — how the user is searching */ +export type FlightRequestType = "flight" | "departure" | "arrival" | "route"; + +// --------------------------------------------------------------------------- +// Time & location primitives +// --------------------------------------------------------------------------- + +/** Day-change indicator (e.g. +1, -1 day vs scheduled) */ +export interface IDayChange { + value: number; + title: string; +} + +/** A single point-in-time with timezone info, as returned by the API */ +export interface ITimesSet { + dayChange: IDayChange; + local: string; + localTime: string; + tzOffset: number; + utc: string; +} + +/** Airport/city info from the API */ +export interface IAirportInfo { + airport: string; + airportCode: string; + city: string; + cityCode: string; + countryCode: string; + country?: string; +} + +// --------------------------------------------------------------------------- +// Station (departure / arrival) +// --------------------------------------------------------------------------- + +export interface IFlightLegStation { + scheduled: IAirportInfo; + latest?: IAirportInfo; + dispatch?: string; + gate?: string; + terminal?: string; +} + +export interface IDepartureStationTimes { + scheduledDeparture: ITimesSet; + estimatedBlockOff?: ITimesSet; + actualBlockOff?: ITimesSet; +} + +export interface IArrivalStationTimes { + scheduledArrival: ITimesSet; + estimatedBlockOn?: ITimesSet; + actualBlockOn?: ITimesSet; +} + +export interface IFlightLegDepartureStation extends IFlightLegStation { + checkingStatus: string; + parkingStand?: string; + times: IDepartureStationTimes; +} + +export interface IFlightLegArrivalStation extends IFlightLegStation { + times: IArrivalStationTimes; + bagBelt?: string; +} + +// --------------------------------------------------------------------------- +// Flight leg +// --------------------------------------------------------------------------- + +export interface IFlightLegFlags { + checkinAvailable: boolean; + returnToAirport: boolean; + routeChanged: boolean; +} + +export interface IFlightLeg { + arrival: IFlightLegArrivalStation; + dayChange: number; + departure: IFlightLegDepartureStation; + equipment: { name?: string; code?: string }; + flags: IFlightLegFlags; + flyingTime: string; + index: number; + operatingBy: { carrier?: string; flightNumber?: string }; + status: FlightStatus; + updated: string; +} + +// --------------------------------------------------------------------------- +// Flight ID +// --------------------------------------------------------------------------- + +export interface IFlightId { + carrier: string; + date: string; + flightNumber: string; + suffix: string; + dateLT?: string; +} + +export interface IParsedFlightId { + carrier: string; + flightNumber: string; + suffix?: string; + date: string; +} + +// --------------------------------------------------------------------------- +// Flight (union of route shapes) +// --------------------------------------------------------------------------- + +interface IFlightBase { + flightId: IFlightId; + flyingTime: string; + operatingBy: { carrier?: string; flightNumber?: string }; + id: string; + status: FlightStatus; +} + +export interface IDirectFlight extends IFlightBase { + routeType: "Direct"; + leg: IFlightLeg; +} + +export interface IMultiLegFlight extends IFlightBase { + routeType: "MultiLeg"; + legs: IFlightLeg[]; +} + +export interface IConnectingFlight { + flights: ISimpleFlight[]; + routeType: "Connecting"; + flyingTime: string; + status: FlightStatus; +} + +/** A single flight (direct or multi-leg) — the main UI display unit */ +export type ISimpleFlight = IDirectFlight | IMultiLegFlight; + +/** Any flight shape including connecting */ +export type IFlight = ISimpleFlight | IConnectingFlight; + +// --------------------------------------------------------------------------- +// API response shapes +// --------------------------------------------------------------------------- + +export interface IBoardResponse { + data: { + partners: string[]; + routes: ISimpleFlight[]; + daysOfFlight: string[]; + }; +} + +export interface IDaysResponse { + days: string; +} diff --git a/src/shared/hooks/useDictionaries.test.ts b/src/shared/hooks/useDictionaries.test.ts new file mode 100644 index 00000000..47a55cb9 --- /dev/null +++ b/src/shared/hooks/useDictionaries.test.ts @@ -0,0 +1,18 @@ +import { describe, it, expect } from "vitest"; +import { useCityName } from "./useDictionaries.js"; + +describe("useCityName", () => { + it("returns the code itself (Phase 2 passthrough)", () => { + expect(useCityName("SVO")).toBe("SVO"); + }); + + it("returns the code for any input", () => { + expect(useCityName("LED")).toBe("LED"); + expect(useCityName("MOW")).toBe("MOW"); + expect(useCityName("JFK")).toBe("JFK"); + }); + + it("handles empty string", () => { + expect(useCityName("")).toBe(""); + }); +}); diff --git a/src/shared/hooks/useDictionaries.ts b/src/shared/hooks/useDictionaries.ts new file mode 100644 index 00000000..760f0eb6 --- /dev/null +++ b/src/shared/hooks/useDictionaries.ts @@ -0,0 +1,22 @@ +/** + * Airport/city dictionary hooks. + * + * Phase 2 stub: returns the IATA code itself as the city name. + * The real implementation will call the customer's dictionary API + * (similar to Angular DictionariesService.getCityOrAirport). + * + * TODO: Replace passthrough with actual API call once the dictionary + * endpoint is provided by the customer. The Angular app loads dictionaries + * via networkService.getDictionary('cities') / getDictionary('airports') + * and builds an in-memory Map. + */ + +/** + * Returns the city name for a given IATA airport/city code. + * + * Phase 2: passthrough — returns the code itself. + */ +export function useCityName(code: string): string { + // TODO: Look up from dictionary cache/API when available + return code; +} diff --git a/src/shared/utils/datetime/datetime.test.ts b/src/shared/utils/datetime/datetime.test.ts new file mode 100644 index 00000000..bbcca745 --- /dev/null +++ b/src/shared/utils/datetime/datetime.test.ts @@ -0,0 +1,138 @@ +import { describe, it, expect } from "vitest"; +import { formatDuration, formatTime, formatDate, isDayChange } from "./index.js"; + +describe("formatDuration", () => { + it("formats zero minutes", () => { + expect(formatDuration(0)).toBe("0h 0m"); + }); + + it("formats minutes only", () => { + expect(formatDuration(45)).toBe("0h 45m"); + }); + + it("formats hours and minutes", () => { + expect(formatDuration(150)).toBe("2h 30m"); + }); + + it("formats days, hours, and minutes", () => { + // 1 day + 5 hours + 12 minutes = 1572 minutes + expect(formatDuration(1572)).toBe("1d 2h 12m"); + }); + + it("formats in Russian locale", () => { + expect(formatDuration(150, "ru")).toBe("2ч 30м"); + }); + + it("formats days in Russian locale", () => { + expect(formatDuration(1572, "ru")).toBe("1д 2ч 12м"); + }); + + it("returns 'Unknown' for negative values", () => { + expect(formatDuration(-1)).toBe("Unknown"); + }); + + it("returns 'Неизвестно' for negative values in Russian", () => { + expect(formatDuration(-1, "ru")).toBe("Неизвестно"); + }); + + it("handles exact hour boundaries", () => { + expect(formatDuration(60)).toBe("1h 0m"); + expect(formatDuration(120)).toBe("2h 0m"); + }); + + it("handles exact day boundaries", () => { + expect(formatDuration(1440)).toBe("1d 0h 0m"); + }); +}); + +describe("formatTime", () => { + it("formats ISO string to HH:mm", () => { + // Use a fixed date with explicit time to avoid timezone issues + const d = new Date(2025, 0, 15, 10, 30); + expect(formatTime(d)).toBe("10:30"); + }); + + it("pads single-digit hours and minutes", () => { + const d = new Date(2025, 0, 15, 3, 5); + expect(formatTime(d)).toBe("03:05"); + }); + + it("handles midnight", () => { + const d = new Date(2025, 0, 15, 0, 0); + expect(formatTime(d)).toBe("00:00"); + }); + + it("returns empty string for invalid date", () => { + expect(formatTime("not-a-date")).toBe(""); + }); + + it("formats Date objects", () => { + const d = new Date(2025, 0, 15, 14, 45); + expect(formatTime(d)).toBe("14:45"); + }); +}); + +describe("formatDate", () => { + it("formats a date in English", () => { + const result = formatDate(new Date(2025, 0, 15), "en"); + expect(result).toContain("January"); + expect(result).toContain("15"); + expect(result).toContain("2025"); + }); + + it("formats a date in Russian", () => { + const result = formatDate(new Date(2025, 0, 15), "ru"); + expect(result).toContain("15"); + expect(result).toContain("2025"); + // Russian month name for January contains "январ" + expect(result.toLowerCase()).toContain("январ"); + }); + + it("returns empty string for invalid date", () => { + expect(formatDate("invalid")).toBe(""); + }); + + it("defaults to English locale", () => { + const result = formatDate(new Date(2025, 0, 15)); + expect(result).toContain("January"); + }); +}); + +describe("isDayChange", () => { + it("returns 0 for same day", () => { + expect( + isDayChange("2025-01-15T10:00:00", "2025-01-15T23:00:00"), + ).toBe(0); + }); + + it("returns 1 for next day", () => { + expect( + isDayChange( + new Date(2025, 0, 15, 23, 0), + new Date(2025, 0, 16, 1, 0), + ), + ).toBe(1); + }); + + it("returns -1 for previous day", () => { + expect( + isDayChange( + new Date(2025, 0, 16, 1, 0), + new Date(2025, 0, 15, 23, 0), + ), + ).toBe(-1); + }); + + it("returns 0 for invalid dates", () => { + expect(isDayChange("invalid", "also-invalid")).toBe(0); + }); + + it("returns 2 for two days later", () => { + expect( + isDayChange( + new Date(2025, 0, 15), + new Date(2025, 0, 17), + ), + ).toBe(2); + }); +}); diff --git a/src/shared/utils/datetime/index.ts b/src/shared/utils/datetime/index.ts new file mode 100644 index 00000000..11b14fdf --- /dev/null +++ b/src/shared/utils/datetime/index.ts @@ -0,0 +1,90 @@ +/** + * Datetime utility functions. + * + * Pure functions ported from Angular pipes (DurationPipe, DatePipe). + * No Angular dependencies, no side effects. + */ + +/** + * Format a duration given in total minutes into a human-readable string. + * + * @example formatDuration(150) => "2h 30m" + * @example formatDuration(150, "ru") => "2ч 30м" + * @example formatDuration(0) => "0h 0m" + */ +export function formatDuration( + minutes: number, + locale: string = "en", +): string { + if (minutes < 0) return locale === "ru" ? "Неизвестно" : "Unknown"; + + const days = Math.floor(minutes / (60 * 24)); + const hours = Math.floor((minutes % (60 * 24)) / 60); + const mins = Math.floor(minutes % 60); + + const units = + locale === "ru" + ? { d: "д", h: "ч", m: "м" } + : { d: "d", h: "h", m: "m" }; + + const daysPart = days > 0 ? `${days}${units.d} ` : ""; + return `${daysPart}${hours}${units.h} ${mins}${units.m}`; +} + +/** + * Format a date/ISO string into "HH:mm" time. + * + * @example formatTime("2025-01-15T10:30:00") => "10:30" + * @example formatTime(new Date(2025, 0, 15, 10, 30)) => "10:30" + */ +export function formatTime(date: string | Date): string { + const d = typeof date === "string" ? new Date(date) : date; + if (Number.isNaN(d.getTime())) return ""; + + const hours = String(d.getHours()).padStart(2, "0"); + const minutes = String(d.getMinutes()).padStart(2, "0"); + return `${hours}:${minutes}`; +} + +/** + * Format a date into a localized date string. + * + * @example formatDate("2025-01-15", "ru") => "15 января 2025 г." + * @example formatDate("2025-01-15", "en") => "January 15, 2025" + */ +export function formatDate( + date: string | Date, + locale: string = "en", +): string { + const d = typeof date === "string" ? new Date(date) : date; + if (Number.isNaN(d.getTime())) return ""; + + return d.toLocaleDateString(locale === "ru" ? "ru-RU" : "en-US", { + year: "numeric", + month: "long", + day: "numeric", + }); +} + +/** + * Calculate the day offset between two dates (ignoring time). + * Returns 0 if same day, +1 if actual is the next day, etc. + * + * @example isDayChange("2025-01-15T23:00", "2025-01-16T01:00") => 1 + */ +export function isDayChange( + scheduledDate: string | Date, + actualDate: string | Date, +): number { + const s = typeof scheduledDate === "string" ? new Date(scheduledDate) : scheduledDate; + const a = typeof actualDate === "string" ? new Date(actualDate) : actualDate; + + if (Number.isNaN(s.getTime()) || Number.isNaN(a.getTime())) return 0; + + // Compare calendar dates (ignoring time) + const sDay = new Date(s.getFullYear(), s.getMonth(), s.getDate()); + const aDay = new Date(a.getFullYear(), a.getMonth(), a.getDate()); + + const diffMs = aDay.getTime() - sDay.getTime(); + return Math.round(diffMs / (24 * 60 * 60 * 1000)); +}