Replace the inline 'Invalid parameters' fallbacks and the framework's
default '404' text with the existing Aeroflot 404 screen. Unknown
locale, malformed flight/route/station params, and unmatched URLs
(including bad paths like onlineboard//route/...) now all land on the
same ErrorPage component.
Each schedule + onlineboard search now records itself into the
existing useSearchHistory localStorage hook, with a structured
params payload (departure/arrival/dates/flightNumber). The
SearchHistory sidebar renders the rich Angular layout: clock or
plane icon, optional sub-title (e.g. "Расписание рейсов, в одну
сторону"), city pair, and date range, with inbound dates appended
for round-trip searches.
Schedule flight cards now expand into the rich Angular layout instead
of the online-board time/transition rows. Mirrors connecting-flight-
body / multi-flight-body: horizontal timeline summary, per-leg card
with section number + flight number + operator + aircraft + dep/arr
times + leg duration + stations, transfer-inline-extended pill
between legs (Пересадка, ground time, transit city), and the actions
row (share, Купить, Детали рейса).
Wired via a renderExpandedBody render prop on FlightCard/FlightList so
ui/flights doesn't need to know about schedule-specific bodies.
- Connecting (multi-leg via transit) flights are now folded into a
synthetic MultiLeg shape with combined flight numbers (SU 6188,
SU 6233) and per-leg airline logos, matching Angular's
schedule-list-flight-header.
- Schedule grid now uses Angular's 8-column layout
(80/120/100/240/100/100/240/16). The middle status icon is
replaced by a duration column with the blue clock icon and
'3ч. 48мин.' / '4h 19m' formatting.
- Multi-leg airline logos use the round badge variant (separate
round.png assets) so two carriers fit side-by-side without overlap.
- Action buttons removed from collapsed rows — Angular only shows
flight-actions in the expanded body. Added chevron column for
every schedule card and made schedule cards expandable by default.
- Removed 'Туда: MOW → KUF' subhead from outbound section, matching
Angular's bare flight list under the column header.
Match Angular's flight-actions layout — schedule rows now show the
orange Buy and outlined Status рейса buttons inline at the right edge
of the row instead of inside the expanded panel.
- FlightCard: when the flight is multi-leg, render one OperatorLogo
per leg in the header so code-share / multi-carrier journeys show
both airline brands (Angular's `operator-logo-and-model x N` row).
Direct flights keep the single logo.
- FlightCard: add an orange "Купить" (buy ticket) link rendered next
to "Детали рейса" when the card is in the schedule context. Links
to aeroflot.ru's booking flow per Angular's flight-actions wiring.
- Reverted earlier per-leg flight-number stack — IFlightLeg in React
doesn't carry a per-leg flightId, so the parent SU number is the
authoritative label. The Angular dual-number stack belongs to the
ConnectingFlight shape (separate from MultiLeg) which the React
code already renders flat.
- flights-map: default departure to Москва (MOW) when geolocation
doesn't yield a city. Mirrors Angular which seeds the orange
marker on Moscow regardless of geo permission. Hook now has two
effects — a synchronous MOW fallback that fires once dictionaries
load, and the existing geo callback that may upgrade to a closer
city when permission is granted.
- Schedule: introduce DayGroupedFlightList. Buckets the flat result
list by scheduled-departure date and renders each group under a
`Воскресенье 19 Апреля`-style header (Intl-driven, weekday +
genitive month). Single-day result skips the grouping noise.
- Schedule: introduce WeekTabs. Replaces the daily DayTabs in the
schedule sticky-content with Monday-anchored 7-day windows like
`13 апр - 19 апр`, matching Angular's week-tabs component.
handleWeekChange recomputes both dateFrom (Monday) and dateTo
(Sunday) when the tab changes.
- Schedule: aircraft model now visible in the collapsed FlightCard
row when `direction === "schedule"` (Sukhoi SuperJet 100 / Airbus
A321 etc., per Angular's operator-logo-and-model column).
- FlightCard / FlightList: extend `direction` union with `"schedule"`.
Tests updated: useGeolocationDefault tests now assert the MOW
fallback fires when permission is denied / API missing / arrival
already set (was previously expected to no-op).
- OperatorLogo: accept BCP-47 codes (`ru-ru`) by trimming to first 2
chars before picking the en/ru asset variant. Fixes the Russian
flight-details page rendering ROSSIYA (Latin) instead of РОССИЯ.
- FlightCard / FlightList: thread `direction` from the search page so
arrival results show Высадка (deboarding) instead of Посадка
(boarding) — Angular parity. The arrival side reads from
arrivalLeg.transition.deboarding when direction === 'arrival'.
- OnlineBoardFilter:
- Дата рейса starts blank with `ДД.ММ.ГГГГ` placeholder; submit
handler defaults to today on empty.
- Город вылета / Город прилета placeholders flip to
`Все направления` when the opposite-direction field is filled.
- Filter content row now flows with $space-l vertical gap to match
Angular's accordion-content rhythm (was ~6 px tighter).
- FlightsMiniList: `display: none` on mobile. Avoids the duplicate
summary card that was floating above the main details on small
viewports — Angular hides the sidebar mini-list there.
- FlightsMap calendar trigger: override PrimeReact's filled-blue
button to a transparent outline so it reads as a glyph (matches
Angular's outline calendar icon).
Pixel-mismatch results (re-diffed via scripts/visual-diff.mjs):
en-onlineboard-route 5.50% → 4.62%
onlineboard-arrival 5.53% → 4.63%
onlineboard-departure 5.92% → 5.03%
onlineboard-route 5.16% → 4.78%
mobile-onlineboard-start 23.51% → 20.37%
mobile-flight-details 18.82% → 17.92%
flight-details carrier-logo verified visually; pixel
count unchanged (height delta dominates)
onlineboard-start 14.56% → 14.52%
Larger remaining mismatches (schedule-route 14%, flights-map 34%,
flight-details 11%) are dominated by structural Angular features the
React port doesn't yet ship (day grouping, code-share bundling on
schedule; geo-driven origin marker on map; height-delta on details).
Tracked as P1 follow-ups in the comparison report.
- URL surface now matches Angular: `/ru-ru/`, `/en-us/`, `/zh-cn/`, …
(BCP-47). Bare short codes still work — the [lang]/layout auto-
promotes them with a replace navigation. Internally everything that
needs the short language (i18n file lookup, API path segment,
Accept-Language header, dictionary `title[lang]` key, Intl
formatters) reads it through the new `useLocale()` hook, which
returns both `locale` (BCP-47) and `language` (short).
- ApiClient.locale is now mutable and is updated from the [lang]
layout whenever the URL locale changes — was hard-coded to "ru" in
the root layout before, so backend responses for /en/... still came
back in Russian. Cities / airports / flight statuses now arrive in
the active language.
- All 21 empty EN translation keys filled in (AIRPLANE.*, BOARD.
PREVIOUS-FLIGHT, SCHEDULE.FILE-NAME, SEO.SCHEDULE.*, SEO.FLIGHTS-
MAP.*, SHARED.FLIGHT-TRANSFER-PLURAL-*, SHARED.WEEK_FORMAT-WRONG)
so /en-us renders without falling back to raw keys.
- Added BOARD.LOAD-FAILED-TITLE / -MESSAGE keys (RU + EN) and removed
the three hardcoded Russian error strings from the search-page
error card.
- FlightStatus now reads `FLIGHT-STATUSES.{Status}` from i18n instead
of hardcoding the Russian labels.
- FlightCard's OperatorLogo now picks the en/ru carrier-logo variant
from `useLocale().language` instead of always passing "ru" — the
Aeroflot/Rossiya logos display in the active language where
variants exist.
- registerPrimeLocales(): all 9 supported languages get a PrimeReact
`addLocale` entry at module load (RU + EN hand-curated, others built
from Intl). Calendar/AutoComplete widgets switch with the URL.
- ErrorBoundary catches outside the i18n provider, so it now ships
its own minimal localised string table keyed off the URL locale —
no more "Something went wrong" leaking on the Russian site.
- Hreflang URLs now emit BCP-47 (`/en-us/...`) while `hreflang="en"`
stays the short Google-friendly form.
- Datetime helpers accept either short or BCP-47 locale (`isRussianLocale`)
so callers can pass through whatever the route hands them.
- SharePanel: fix wrong i18n key (SHARED.COPY → SHARE.COPY) and switch
to the brand-icon-on-top + translated-label layout that Angular uses
(renders as untranslated raw key + plain text list before).
- LastUpdate: stamp now reflects when the client received the data, not
the API record's mutation timestamp — Angular sets `flight.lastUpdate
= new Date()` in populate.logic.ts; we mirror that behavior so users
no longer see stale 'updated' values from cached API rows.
- FlightCard: keep operator logo + plane icon + status text on mobile
(previously hidden via display:none); regrid to 3-col layout so the
card mirrors Angular's mobile pattern. Boarding status row gains the
leading colour-coded dot Angular ships ('Уточняется' grey).
- OnlineBoardSearchPage: H1 for /flight/... search now reads
'Рейс: SU 6497, Сегодня' instead of 'Номер рейса: SU6497' (matches
Angular's title.service); add the '* Время в системе - МЕСТНОЕ.'
footer note Angular's <page-footer-notes> renders.
- FlightsMap filter: drop the React-only 'Найдите свой маршрут'
header; replace the horizontal swap glyph with vertical blue arrows
(Angular rotates the same SVG 90deg); add Leaflet city-tooltip
styling so labels render text-only with a white text-shadow halo
rather than as PrimeReact-default white pills.
- DayQuickPick: new mobile-only 3-day quick-pick row above the manual
date input on both onlineboard and flights-map filters, mirroring
Angular's calendar-input.component .calendar--mobile block. Uses
Intl.DateTimeFormat formatToParts to get the genitive month form.
Angular's sprite has the plane nose-right, but the inline 24×24 path
bundled with FlightStatus is nose-up. Transform-rotate matches the
Angular direction without swapping the SVG asset.
Angular's expanded FlightCard bottom bar has a share button on the
left next to the 'Детали рейса' action on the right. Render an icon
button backed by /assets/img/share.svg with the same
justify-content: space-between layout. Click uses the Web Share API
when available and falls back to writing the current URL to the
clipboard.
Render the latest time on top (30px/light/#333) with the crossed-out
scheduled time below (12px/#f37b09/line-through), mirroring Angular's
'time' vs 'oldTime' pattern instead of the inverted order React had.
Drop the local .flight-card__time font-size override so TimeGroup owns
its own typography.
In the expanded panel, show both scheduled and latest time columns.
Angular falls back to estimatedBlockOff/On when actual is absent and
labels the column 'Ожидаемое' vs 'Фактическое' accordingly — mirror
that logic so flights with only an ETA surface it.
Drop the 8px margin below the day-tabs strip so the sticky card touches
the results frame, matching Angular's layout.
Add page-layout__scroll-overlay — a fixed 25px dark-blue strip across
the top of the viewport — so flight rows scrolling past the 20px-sticky
date row don't peek through the gap above it.
Realign DayTabs range to match Angular's boardSearchFrom=1 /
boardSearchTo=7 defaults (today-1 through today+7). Previous 2/14
window left today off-center in the 7-tab page.
Rebuild DayTabs to mirror Angular's flat single-line tab strip: one label
per tab ('17 апр.' siblings, '19 апреля' active), 48px tall, 7 tabs per
page, brand blue label on light-blue background with white active cell.
Single Intl.DateTimeFormat call keeps Russian months in genitive case.
Drop the 60px sticky-content offset to 20px so the date strip aligns
with the left filter column (both already sticky at top:20px).
Correct SEO.FLIGHTS_MAP translation key to SEO.FLIGHTS-MAP.MAIN — the
underscored path never existed in the locale files, so the browser tab
title on /{lang}/flights-map fell back to the raw key.
Angular's route/departure/arrival search result list picks a 'current
flight' on load and auto-expands + scrolls it into view — the flight
whose dep/arr time is closest to 'now' for today's searches, or the
first/last flight when the search is for a future/past day. React was
always rendering the list scrolled to the top, so on today's route
search the user sees flights from 00:30 onwards instead of landing on
whatever is departing right now.
- Add features/online-board/closestFlight.ts with a React-flavored port
of find-closest-flight.ts (plus a today-guard that reuses the same
'yyyymmdd' shape the URL parser produces).
- FlightList takes an optional initialCurrentFlightId, attaches a ref
to each card, and scrollIntoView's it on mount / list change.
- FlightCard takes an initialExpanded prop and seeds its useState so
the selected flight lands expanded, matching the Angular 'expanded:
true' assignment after setCurrentFlight.
- OnlineBoardSearchPage computes the id via findClosestFlightId using
the current params (type + date) and forwards it to FlightList.
On /ru/onlineboard/SU6272-20260418 the document title was blank and the
meta description carried a literal '{{ flightNumber }}' placeholder.
Two root causes:
1. Translation values carried Angular ngx-translate syntax {{ var }} but
the React app uses i18next-icu (single-brace {var}). Interpolation
never fired, so SEO strings served as-is. Rewrite every {{ var }}
(and {{var}}) occurrence to {var} across ru/en locales.
2. <SeoHead> was rendered inside the lazy-loaded OnlineBoardDetailsPage.
The SSR response streams the Suspense fallback before the lazy
bundle resolves, so <title>/<meta> never land in the <head>. Move
SeoHead to the route page (src/routes/.../page.tsx) where it
renders synchronously from URL-derived data, and drop the inner
duplicate. Add buildFlightDetailsSeoFromId for the URL-only path.
formatDateForSeo now handles both 'yyyyMMdd' (URL) and
'yyyy-MM-dd' (API) so both entry points produce '18.04.2026'.
3. React 18 doesn't auto-hoist <title> inside body to document.head —
add a useEffect in SeoHead that also writes document.title on the
client. SSR still emits the <title> element for crawlers.
Angular renders the breadcrumb trail on its own row above the H1 title.
React had them in the same flex row with justify-content:space-between,
which squeezed the breadcrumb column and forced 'Главная / Онлайн-Табло'
to wrap onto two lines. Switch the header-right container to column
layout so breadcrumbs and title stack vertically regardless of width.
Clicking a row on the board search results page now toggles an inline
details panel instead of immediately navigating away. The layout
matches Angular's board-flight-header:
- Aircraft model ('Sukhoi SuperJet 100') appears below the flight
number when expanded.
- 'Время' detail row: По расписанию / Фактическое times with UTC
offsets for both the departure and the arrival sides.
- 'Посадка' detail row: boarding status (через the
BOARDING-STATUSES.* keys), start and end times.
- 'Детали рейса' button (blue) in the bottom-right navigates to the
full details page.
- Active rows get a blue left border + light-blue background.
- Chevron icon on the right rotates on expand.
Wire-up: FlightCard has two new props (expandable, onViewDetails).
FlightList automatically passes expandable=true when a click handler
is provided. Added SHARED.BOARDING-START / SHARED.BOARDING-END keys
across all nine locales for the time captions.
1. Route heading uses airport name when a code maps only to an airport
(SVO → 'Шереметьево') but prefers the city when the code is a city
too (LED → 'Санкт-Петербург', not 'Пулково'). Angular does the
same. Apply the new lookup order in both the onlineboard and
schedule search pages.
2. Append ', Сегодня' (or 'DD.MM.YYYY' for other dates) to the board
search heading, matching Angular.
3. Render the '+1' day-change marker on FlightCard even when only
scheduled times are known. Previously the fallback pulled the value
from `actualBlockOff/On.dayChange`, which is undefined for
scheduled-only flights — so overnight flights like SU 6805
(23:30 → 00:55 +1) showed no indicator. Read
`scheduledDeparture/Arrival.dayChange.value` when the actual block
time is missing.
4. Localize the PrimeReact Calendar widget: register a Russian locale
in [lang]/layout.tsx and set the active one on every locale change,
so 'Choose Date' reads 'Выбрать дату' and month/day names localize.
formatTime runs new Date(iso).getHours() which reprojects the
timestamp through the browser's local timezone. For a flight arriving
at 06:30 in Almaty (GMT+5) a viewer in Moscow saw '04:30'. Switch the
TimeGroup component to formatLocalTime which reads the wall-clock
directly out of the offset-aware ISO string, matching the rest of the
details/timetable views.
Six more aria-labels that still read in English:
- CityAutocomplete clear button ('clear')
- CityAutocomplete picker trigger ('open regional picker')
- Breadcrumbs nav landmark ('breadcrumb')
- Timeline prev/next buttons ('Previous legs' / 'Next legs')
Routed through new SHARED.A11Y-* keys (translated into all nine
locales). This is screen-reader-only text but part of the parity
budget.
Batch of fixes identified by the comparison audit:
Schedule search page (ScheduleSearchPage):
- Resolve IATA codes to city/airport names, so the H1 reads
'Маршрут: Шереметьево - Санкт-Петербург' instead of 'SVO - LED'.
- Breadcrumb trail now includes the human-friendly route as its
last entry.
Details page (OnlineBoardDetailsPage):
- Hide the 'Перелет N' leg header for single-leg flights (Angular
parity — that label is only meaningful for multi-leg routes).
- Translate the leg status through FLIGHT-STATUSES.* instead of
emitting the raw enum ('Cancelled' → 'Отменен', etc.).
- Humanize leg and total flying time through formatDuration so the
page reads '1ч 25м' rather than '01:25:00'.
Details meal panel (MealPanel):
- Use the same FOOD.* translation keys as Angular, so labels become
'Эконом класс / Комфорт класс / Бизнес класс / Специальное
питание'.
- Add the Special-meal icon + link (was stubbed out previously).
Accessibility:
- Route the English aria-labels through new SHARED.A11Y-* keys in
DayTabs pagination, FlightListSkeleton, ScrollUpButton and
PrintButton.
Breadcrumbs:
- Render the 'Главная' crumb as a link even when it's the only /
last item (it was dropping to plain text on start pages). Angular
always links it to aeroflot.ru.
Tests updated to assert the new translated labels and duration
formatting; 1258 tests passing.
Two bugs prevented the popular-requests click from filling the filter:
1. OnlineBoardFilter seeded its fields from initial* props via
useState(...), which only runs once. When a user clicked a popular
request the parent pushed ?departure=SVO&arrival=LED into the URL
and re-rendered with new initial* props, but the sidebar fields
kept their previous empty values. Add an effect that diffs the
initial* props against a ref and pushes the changes into local
state, matching Angular's ngOnChanges behaviour.
2. CityAutocomplete's selectedCity only looked the value up in
cityByCode. Airport codes like SVO aren't cities, so the header
code label stayed blank. Fall back to airportByCode → city_code so
the top-right code renders as 'MOW' when the input shows
'Шереметьево'.
End-to-end behaviour now matches Angular: clicking
'Маршрут: Шереметьево - Санкт-Петербург' on the start page updates
the URL, populates 'Шереметьево' / 'Санкт-Петербург' in the inputs,
shows 'MOW' / 'LED' codes in the labels.
- FlightList empty-state, Operated-by label, details/schedule error
and not-found messages now route through i18n instead of hardcoded
English. Added BOARD.FLIGHT-NOT-FOUND, BOARD.LOAD-FAILED,
BOARD.OPERATED-BY, SHARED.RETRY to all nine locales.
- FlightStatus label now picks up the same colour as the plane icon
(red for Cancelled, green for Arrived/Landed, blue for In Flight,
orange for Delayed) — matches Angular's flight-status text treatment
so 'Отменен' reads at a glance.
- Tests updated to expect the translation keys under the mocked `t`.
Search page:
- Title and breadcrumb now read the station dictionaries and render the
human-friendly route heading (e.g. 'Маршрут: Шереметьево - Пулково')
for route/departure/arrival/flight search URLs, mirroring Angular.
Details page:
- Main H1 becomes 'Информация о рейсе: SU 6805, Москва - Санкт-Петербург'
(carrier + flight number + origin/destination cities), not a bare
flight number.
- Add 'Детали рейса' section header above the accordion to match
Angular's flight-details-wrapper layout.
- Promote the airline block in BoardDetailsHeader: drop the legacy
OperatorLogo copy with broken asset paths and hand off to the shared
<OperatorLogo> under src/ui/flights. Render it with the
'авиакомпания' caption beside the enlarged flight number.
- Replace hardcoded English 'Leg' / 'Total flying time' / 'Aircraft:'
with i18n keys, added to all nine locale files.
Test harness:
- Add vi.mock for useDictionaries in the three suites that render
OnlineBoardSearchPage (the new heading helper calls the hook and
crashed without ApiClientProvider). 1256 tests passing.
Search row now shows the full Angular header layout: flight number,
operator logo, scheduled/actual departure time, departure city +
terminal, plane icon with status label, mirrored arrival block. The
city input in the filter sidebar now shows the city name
('Шереметьево') instead of the IATA code.
Details page: expand the first accordion panel by default (Angular
parity), hide Print/Share on the board details view, and rewrite the
Aircraft panel as a property table with total/economy/comfort/business
seat counts and the previous-flight identifier — all pulled from the
real API shape, which is `{ seats: [{type, count}] }` rather than the
legacy string config.
Supporting work:
- New <OperatorLogo> component with the full carrier → asset mapping
ported from ClientApp/src/styles (SU, FV, HZ, S7, …).
- Extend StationDisplay with a cityFirst variant for row usage.
- New FlightStatus icon-over-label layout, translated labels.
- Update IEquipmentFull types: configuration is an object with seats[],
plus scheduled/actual/previousFlight; new IOperatingBy union.
- Tests + fixtures updated for the new shapes; 1262 passing.
Both pages were rendering content directly on the dark-blue page
background, which made the flight list and details card effectively
invisible. Angular wraps the same content in a white card (section.frame)
with drop shadow.
Changes:
- Wrap FlightList in <section class='frame'> on the search page and wrap
the details body the same way.
- Replace the inline numbered .calendar-day strip on the search page
with the existing <DayTabs> component — the same one the details page
already uses (weekday + day + month labels, ‹/› paging).
- Pass onFlightClick through FlightList into FlightCard so whole rows
are keyboard-accessible buttons, matching Angular's row-level click.
The off-screen data-testid='flight-link-*' buttons stay for e2e.
- Fix 'Leg NaN' header + the React key warning in FlightLegs when
the API returns a Direct leg without an index field.
- Update the existing flight-search integration test to target the
DayTabs testid instead of the old ad-hoc calendar-strip one.
A docs/parity-report-2026-04-17.md file captures the diffs I applied
and a punch list of the remaining parity gaps (operator logo on rows,
delay/day-change glyphs, Share button visibility on board details, the
aircraft panel as a table). Those need per-component work against the
Angular templates and will follow in a separate pass.
Match Angular's CityAutocompleteItemComponent: each suggestion is either
a city row (bold name, country in gray) or an indented airport row. Port
CitiesSearchService search (starts-with → includes → by-airport-name,
cap at 10 cities, then insert each city's other airports). Airport
selections resolve to the owning city code, matching Angular behavior
where typing 'SVO' or clicking the Sheremetyevo row sets city = MOW.
Error page: add search input bar, align flex/spacing to Angular SCSS mixins,
match button display and illustration flex.
Online board filter: default to "route" tab expanded (Angular defaults to
route, not flight number).
Start pages: remove extra breadcrumb items — Angular start pages show only
"Главная", not the page title.
PageLayout: hide FeedbackButton — Angular gates it behind
FEEDBACK_BUTTON_AVAILABLE feature flag (off by default).
Replace 4-radio-button filter with PrimeNG-style accordion (2 tabs: Flight Number, Route).
Add swap button between departure/arrival in route filter, clear button on flight number input,
time selector in route filter, flight number validation with error tooltip.
Add SearchHistory component below filter, Breadcrumbs in page header, FeedbackButton stub,
ScrollUpButton for scroll-to-top. SeoHead already wired on start page route.
All tests updated to match new accordion structure.
PageTabs now reads the FEATURE_FLIGHTS_MAP flag directly via useFeatureFlag
instead of relying on a prop default, matching the Angular page-tabs pattern.
FlightsMapFilter uses PrimeReact AutoComplete and Calendar instead of plain
HTML inputs, with i18n labels. MapCanvas init effect uses refs to avoid
React exhaustive-deps warnings. Root layout imports leaflet CSS and
PrimeReact theme globally. Env schema accepts NODE_ENV "test" for vitest.
Enable flights-map tab by default (showFlightsMap=true) to match Angular
production config where flightsMap feature flag is true. The other three
items (tile icons, body background, popular-requests panel) were already
ported identically in the React SCSS.
Add blue background/hover styles to the search button matching Angular's
.color.blue button pattern. Fix page title width to calc(100% - 120px)
matching Angular layout. Conditionally render sticky-content wrapper
to avoid empty DOM nodes.
Ported Angular SCSS for station, time-group, flight-status, duration,
flight-card, flight-list, and flight-list-skeleton to React equivalents.
Aligned class names in JSX with Angular BEM conventions and added SCSS
imports to all flight display components.
Port the Angular page-layout wrapper and flights-page-tabs navigation
to React, preserving identical DOM structure and CSS class names so
global SCSS styles apply without modification.
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.