Add flight display components and barrel exports
StationDisplay, TimeGroup, FlightStatus, DurationDisplay compose into FlightCard; FlightList renders a list of cards with skeleton loading. All components are props-driven with no data fetching.
This commit is contained in:
@@ -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<DurationDisplayProps> = ({
|
||||
minutes,
|
||||
locale = "en",
|
||||
}) => {
|
||||
return (
|
||||
<span className="duration-display">{formatDuration(minutes, locale)}</span>
|
||||
);
|
||||
};
|
||||
@@ -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<FlightCardProps> = ({ 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 (
|
||||
<div className="flight-card" data-flight-id={flight.id}>
|
||||
<div className="flight-card__number">{flightNumber}</div>
|
||||
|
||||
<div className="flight-card__route">
|
||||
<div className="flight-card__departure">
|
||||
<StationDisplay
|
||||
airportCode={depStation.scheduled.airportCode}
|
||||
airportName={depStation.scheduled.airport}
|
||||
cityName={depStation.scheduled.city}
|
||||
/>
|
||||
<TimeGroup
|
||||
scheduled={depTimes.scheduledDeparture.local}
|
||||
actual={depTimes.actualBlockOff?.local}
|
||||
dayChange={depTimes.actualBlockOff?.dayChange.value}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flight-card__duration">
|
||||
<DurationDisplay minutes={flyingTimeToMinutes(flight.flyingTime)} />
|
||||
</div>
|
||||
|
||||
<div className="flight-card__arrival">
|
||||
<StationDisplay
|
||||
airportCode={arrStation.scheduled.airportCode}
|
||||
airportName={arrStation.scheduled.airport}
|
||||
cityName={arrStation.scheduled.city}
|
||||
/>
|
||||
<TimeGroup
|
||||
scheduled={arrTimes.scheduledArrival.local}
|
||||
actual={arrTimes.actualBlockOn?.local}
|
||||
dayChange={arrTimes.actualBlockOn?.dayChange.value}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flight-card__status">
|
||||
<FlightStatus status={flight.status} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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<FlightListProps> = ({
|
||||
flights,
|
||||
loading = false,
|
||||
skeletonCount = 5,
|
||||
}) => {
|
||||
if (loading) {
|
||||
return <FlightListSkeleton count={skeletonCount} />;
|
||||
}
|
||||
|
||||
if (flights.length === 0) {
|
||||
return (
|
||||
<div className="flight-list flight-list--empty">
|
||||
<p className="flight-list__empty-message">No flights found</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flight-list">
|
||||
{flights.map((flight) => (
|
||||
<FlightCard key={flight.id} flight={flight} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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<FlightListSkeletonProps> = ({
|
||||
count = 5,
|
||||
}) => {
|
||||
return (
|
||||
<div className="flight-list-skeleton" aria-busy="true" aria-label="Loading flights">
|
||||
{Array.from({ length: count }, (_, i) => (
|
||||
<div key={i} className="flight-list-skeleton__row">
|
||||
<div className="flight-list-skeleton__cell flight-list-skeleton__cell--number" />
|
||||
<div className="flight-list-skeleton__cell flight-list-skeleton__cell--station" />
|
||||
<div className="flight-list-skeleton__cell flight-list-skeleton__cell--time" />
|
||||
<div className="flight-list-skeleton__cell flight-list-skeleton__cell--station" />
|
||||
<div className="flight-list-skeleton__cell flight-list-skeleton__cell--time" />
|
||||
<div className="flight-list-skeleton__cell flight-list-skeleton__cell--status" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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<FlightStatusType, string> = {
|
||||
Scheduled: "Scheduled",
|
||||
Sent: "Departed",
|
||||
InFlight: "In Flight",
|
||||
Landed: "Landed",
|
||||
Arrived: "Arrived",
|
||||
Delayed: "Delayed",
|
||||
Cancelled: "Cancelled",
|
||||
Unknown: "Unknown",
|
||||
};
|
||||
|
||||
const STATUS_CLASSES: Record<FlightStatusType, string> = {
|
||||
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<FlightStatusProps> = ({ status }) => {
|
||||
return (
|
||||
<span className={`flight-status ${STATUS_CLASSES[status]}`}>
|
||||
{STATUS_LABELS[status]}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
@@ -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<StationDisplayProps> = ({
|
||||
airportCode,
|
||||
airportName,
|
||||
cityName,
|
||||
}) => {
|
||||
const resolvedCity = cityName ?? useCityName(airportCode);
|
||||
|
||||
return (
|
||||
<div className="station-display">
|
||||
<span className="station-display__code">{airportCode}</span>
|
||||
{airportName ? (
|
||||
<span className="station-display__name">{airportName}</span>
|
||||
) : null}
|
||||
<span className="station-display__city">{resolvedCity}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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<TimeGroupProps> = ({
|
||||
scheduled,
|
||||
actual,
|
||||
dayChange,
|
||||
label,
|
||||
}) => {
|
||||
const scheduledTime = formatTime(scheduled);
|
||||
const actualTime = actual ? formatTime(actual) : undefined;
|
||||
const hasDelay = actualTime !== undefined && actualTime !== scheduledTime;
|
||||
|
||||
return (
|
||||
<div className="time-group">
|
||||
{label ? <span className="time-group__label">{label}</span> : null}
|
||||
<div className="time-group__times">
|
||||
{hasDelay ? (
|
||||
<>
|
||||
<span className="time-group__scheduled time-group__scheduled--delayed">
|
||||
{scheduledTime}
|
||||
</span>
|
||||
<span className="time-group__actual">{actualTime}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="time-group__scheduled">{scheduledTime}</span>
|
||||
)}
|
||||
{dayChange !== undefined && dayChange !== 0 ? (
|
||||
<span className="time-group__day-change">
|
||||
{dayChange > 0 ? `+${dayChange}` : dayChange}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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";
|
||||
+1
-1
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user