plan/react-rewrite #1

Merged
gnezim merged 138 commits from plan/react-rewrite into main 2026-04-15 12:21:16 +03:00
9 changed files with 339 additions and 1 deletions
Showing only changes of commit 7d8cb63600 - Show all commits
+21
View File
@@ -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>
);
};
+94
View File
@@ -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>
);
};
+45
View File
@@ -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>
);
};
+29
View File
@@ -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>
);
};
+39
View File
@@ -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>
);
};
+34
View File
@@ -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>
);
};
+53
View File
@@ -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>
);
};
+23
View File
@@ -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
View File
@@ -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";