diff --git a/src/ui/flights/DurationDisplay.tsx b/src/ui/flights/DurationDisplay.tsx new file mode 100644 index 00000000..cb5e90a2 --- /dev/null +++ b/src/ui/flights/DurationDisplay.tsx @@ -0,0 +1,21 @@ +import type { FC } from "react"; +import { formatDuration } from "@/shared/utils/datetime/index.js"; + +export interface DurationDisplayProps { + /** Flight duration in total minutes */ + minutes: number; + /** Locale for formatting (default: "en") */ + locale?: string; +} + +/** + * Displays a flight duration in human-readable format. + */ +export const DurationDisplay: FC = ({ + minutes, + locale = "en", +}) => { + return ( + {formatDuration(minutes, locale)} + ); +}; diff --git a/src/ui/flights/FlightCard.tsx b/src/ui/flights/FlightCard.tsx new file mode 100644 index 00000000..4cfd87eb --- /dev/null +++ b/src/ui/flights/FlightCard.tsx @@ -0,0 +1,94 @@ +import type { FC } from "react"; +import type { ISimpleFlight, IFlightLeg } from "@/features/online-board/types.js"; +import { StationDisplay } from "./StationDisplay.js"; +import { TimeGroup } from "./TimeGroup.js"; +import { FlightStatus } from "./FlightStatus.js"; +import { DurationDisplay } from "./DurationDisplay.js"; + +export interface FlightCardProps { + flight: ISimpleFlight; +} + +/** Extract the primary leg from a flight (first leg for multi-leg) */ +function getPrimaryLeg(flight: ISimpleFlight): IFlightLeg { + if (flight.routeType === "Direct") return flight.leg; + const first = flight.legs[0]; + if (!first) throw new Error("Multi-leg flight has no legs"); + return first; +} + +/** Extract the final leg (last leg for multi-leg, same as primary for direct) */ +function getFinalLeg(flight: ISimpleFlight): IFlightLeg { + if (flight.routeType === "Direct") return flight.leg; + const last = flight.legs[flight.legs.length - 1]; + if (!last) throw new Error("Multi-leg flight has no legs"); + return last; +} + +/** Parse flyingTime "HH:mm" string to total minutes */ +function flyingTimeToMinutes(flyingTime: string): number { + const parts = flyingTime.split(":"); + if (parts.length !== 2) return 0; + const hours = parseInt(parts[0] ?? "0", 10); + const minutes = parseInt(parts[1] ?? "0", 10); + return hours * 60 + minutes; +} + +/** + * A single flight row in search results. + * + * Composes StationDisplay + TimeGroup + FlightStatus + DurationDisplay. + */ +export const FlightCard: FC = ({ flight }) => { + const departureLeg = getPrimaryLeg(flight); + const arrivalLeg = getFinalLeg(flight); + + const depStation = departureLeg.departure; + const arrStation = arrivalLeg.arrival; + const depTimes = depStation.times; + const arrTimes = arrStation.times; + + const flightNumber = `${flight.flightId.carrier} ${flight.flightId.flightNumber}`; + + return ( +
+
{flightNumber}
+ +
+
+ + +
+ +
+ +
+ +
+ + +
+
+ +
+ +
+
+ ); +}; diff --git a/src/ui/flights/FlightList.tsx b/src/ui/flights/FlightList.tsx new file mode 100644 index 00000000..4f9395bc --- /dev/null +++ b/src/ui/flights/FlightList.tsx @@ -0,0 +1,45 @@ +import type { FC } from "react"; +import type { ISimpleFlight } from "@/features/online-board/types.js"; +import { FlightCard } from "./FlightCard.js"; +import { FlightListSkeleton } from "./FlightListSkeleton.js"; + +export interface FlightListProps { + /** Array of flights to display */ + flights: ISimpleFlight[]; + /** Whether the list is in a loading state */ + loading?: boolean; + /** Number of skeleton rows when loading (default: 5) */ + skeletonCount?: number; +} + +/** + * List of FlightCards with loading skeleton support. + * + * Shows FlightListSkeleton when loading, the flight list otherwise. + * Displays an empty-state message when flights array is empty and not loading. + */ +export const FlightList: FC = ({ + flights, + loading = false, + skeletonCount = 5, +}) => { + if (loading) { + return ; + } + + if (flights.length === 0) { + return ( +
+

No flights found

+
+ ); + } + + return ( +
+ {flights.map((flight) => ( + + ))} +
+ ); +}; diff --git a/src/ui/flights/FlightListSkeleton.tsx b/src/ui/flights/FlightListSkeleton.tsx new file mode 100644 index 00000000..aada5ea9 --- /dev/null +++ b/src/ui/flights/FlightListSkeleton.tsx @@ -0,0 +1,29 @@ +import type { FC } from "react"; + +export interface FlightListSkeletonProps { + /** Number of skeleton rows to display (default: 5) */ + count?: number; +} + +/** + * Loading placeholder for a flight list. + * Renders N animated placeholder rows. + */ +export const FlightListSkeleton: FC = ({ + count = 5, +}) => { + return ( +
+ {Array.from({ length: count }, (_, i) => ( +
+
+
+
+
+
+
+
+ ))} +
+ ); +}; diff --git a/src/ui/flights/FlightStatus.tsx b/src/ui/flights/FlightStatus.tsx new file mode 100644 index 00000000..5da6c58b --- /dev/null +++ b/src/ui/flights/FlightStatus.tsx @@ -0,0 +1,39 @@ +import type { FC } from "react"; +import type { FlightStatus as FlightStatusType } from "@/features/online-board/types.js"; + +export interface FlightStatusProps { + status: FlightStatusType; +} + +const STATUS_LABELS: Record = { + Scheduled: "Scheduled", + Sent: "Departed", + InFlight: "In Flight", + Landed: "Landed", + Arrived: "Arrived", + Delayed: "Delayed", + Cancelled: "Cancelled", + Unknown: "Unknown", +}; + +const STATUS_CLASSES: Record = { + Scheduled: "flight-status--scheduled", + Sent: "flight-status--departed", + InFlight: "flight-status--in-flight", + Landed: "flight-status--landed", + Arrived: "flight-status--arrived", + Delayed: "flight-status--delayed", + Cancelled: "flight-status--cancelled", + Unknown: "flight-status--unknown", +}; + +/** + * Flight status badge with semantic CSS class for styling. + */ +export const FlightStatus: FC = ({ status }) => { + return ( + + {STATUS_LABELS[status]} + + ); +}; diff --git a/src/ui/flights/StationDisplay.tsx b/src/ui/flights/StationDisplay.tsx new file mode 100644 index 00000000..7c2b319e --- /dev/null +++ b/src/ui/flights/StationDisplay.tsx @@ -0,0 +1,34 @@ +import type { FC } from "react"; +import { useCityName } from "@/shared/hooks/useDictionaries.js"; + +export interface StationDisplayProps { + /** IATA airport code, e.g. "SVO" */ + airportCode: string; + /** Airport name (optional, displayed if provided) */ + airportName?: string; + /** City name override (falls back to useCityName hook) */ + cityName?: string; +} + +/** + * Renders an airport IATA code with city name. + * + * Layout: IATA code (bold) + city name below. + */ +export const StationDisplay: FC = ({ + airportCode, + airportName, + cityName, +}) => { + const resolvedCity = cityName ?? useCityName(airportCode); + + return ( +
+ {airportCode} + {airportName ? ( + {airportName} + ) : null} + {resolvedCity} +
+ ); +}; diff --git a/src/ui/flights/TimeGroup.tsx b/src/ui/flights/TimeGroup.tsx new file mode 100644 index 00000000..3d0d7e29 --- /dev/null +++ b/src/ui/flights/TimeGroup.tsx @@ -0,0 +1,53 @@ +import type { FC } from "react"; +import { formatTime } from "@/shared/utils/datetime/index.js"; + +export interface TimeGroupProps { + /** Scheduled time (ISO 8601 string) */ + scheduled: string; + /** Actual time (ISO 8601 string), if available */ + actual?: string | undefined; + /** Day change offset (e.g. +1, -1) */ + dayChange?: number | undefined; + /** Label for the time group, e.g. "Departure" */ + label?: string; +} + +/** + * Displays scheduled + actual times with a day-change indicator. + * + * If actual differs from scheduled, scheduled is shown with strikethrough + * and actual is shown in bold. + */ +export const TimeGroup: FC = ({ + scheduled, + actual, + dayChange, + label, +}) => { + const scheduledTime = formatTime(scheduled); + const actualTime = actual ? formatTime(actual) : undefined; + const hasDelay = actualTime !== undefined && actualTime !== scheduledTime; + + return ( +
+ {label ? {label} : null} +
+ {hasDelay ? ( + <> + + {scheduledTime} + + {actualTime} + + ) : ( + {scheduledTime} + )} + {dayChange !== undefined && dayChange !== 0 ? ( + + {dayChange > 0 ? `+${dayChange}` : dayChange} + + ) : null} +
+
+ ); +}; diff --git a/src/ui/flights/index.ts b/src/ui/flights/index.ts new file mode 100644 index 00000000..80bff118 --- /dev/null +++ b/src/ui/flights/index.ts @@ -0,0 +1,23 @@ +// Public barrel for flight display components. +// Feature code imports these via "@/ui" or "@/ui/flights". + +export { StationDisplay } from "./StationDisplay.js"; +export type { StationDisplayProps } from "./StationDisplay.js"; + +export { TimeGroup } from "./TimeGroup.js"; +export type { TimeGroupProps } from "./TimeGroup.js"; + +export { FlightStatus } from "./FlightStatus.js"; +export type { FlightStatusProps } from "./FlightStatus.js"; + +export { DurationDisplay } from "./DurationDisplay.js"; +export type { DurationDisplayProps } from "./DurationDisplay.js"; + +export { FlightCard } from "./FlightCard.js"; +export type { FlightCardProps } from "./FlightCard.js"; + +export { FlightListSkeleton } from "./FlightListSkeleton.js"; +export type { FlightListSkeletonProps } from "./FlightListSkeleton.js"; + +export { FlightList } from "./FlightList.js"; +export type { FlightListProps } from "./FlightList.js"; diff --git a/src/ui/index.ts b/src/ui/index.ts index c041cb42..7d727e94 100644 --- a/src/ui/index.ts +++ b/src/ui/index.ts @@ -1,3 +1,3 @@ // Public barrel for the UI adapter layer. See frozen-barrels.md. // Feature code imports UI primitives exclusively through this barrel. -export {}; +export * from "./flights/index.js";