Align board search + details with Angular visual parity

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.
This commit is contained in:
2026-04-17 23:14:59 +03:00
parent 40fa7c5f06
commit 84e6d265fc
7 changed files with 168 additions and 80 deletions
+52
View File
@@ -0,0 +1,52 @@
# Visual Parity Report — Angular vs React
_Captured 2026-04-17, SVO→LED route, SU 6805 details page, viewport 1440×900._
Screenshots (checked in under `/` root):
- `parity-angular-search.png`, `parity-react-search.png`
- `parity-angular-details.png`, `parity-react-details.png`
---
## Search results (`/ru/onlineboard/route/SVO-LED-20260417`)
| Angular | React (pre-fix) | Notes |
| --- | --- | --- |
| Flight list sits on a white card with drop shadow (`section.frame`) | Flight rows rendered on the dark-blue page background → text barely readable | **fixed**: wrap `<FlightList>` in `<section class="frame">` |
| Calendar strip uses paged weekday tabs: `16 апр.` / `17 апреля` (active, bold) / `18 апр.` … with ` ` arrows | Thin strip of naked numbered buttons wrapping across two rows (`16 17 18 … 1 2 3 …`) | **fixed**: replace inline `.calendar-day` with existing `<DayTabs>` component |
| Each row is clickable; hovering highlights the row | Rows are not interactive; navigation to details only via off-screen test-only buttons | **fixed**: `FlightCard` accepts `onClick` and renders with `role="button"` + hover highlight |
| Row shows: flight # · operator logo + name · scheduled time (big) · actual time struck-through in orange on delay · city + terminal link · status with plane icon · arrival mirror · expand arrow | Row shows: flight # · scheduled time · city name · status text. Missing operator logo, terminal link, actual-time strike-through, delay arrow glyph, expanded panel | open — operator logo, terminal link, expanded-state layout still pending |
| Filter sidebar has collapsible Route accordion + time slider inside; "Sheremetyevo" populated in departure input | Filter sidebar shows `SVO` / `LED` codes and sliders; labels don't render the city name | open — filter should pre-populate with the airport display name |
| Footer note `* Время в системе - МЕСТНОЕ.` | Not rendered | open |
## Details page (`/ru/onlineboard/SU6805-20260417`)
| Angular | React (pre-fix) | Notes |
| --- | --- | --- |
| All right-hand content on a single white card | Content directly on dark blue — text invisible | **fixed**: wrap in `<section class="frame">` |
| `DayTabs` used at the top of the card (already present in React) | Same component, but on dark bg | **fixed** by moving into frame |
| React console warning: `Each child in a list should have a unique "key" prop. Check the render method of FlightLegs` | — | **fixed**: key now falls back to the array index when `leg.index` is absent |
| Header shows: flight # · airline name · big `РОССИЯ` logo · share button · last-update timestamp | React header currently shows only flight # + share + last-update | open — needs operator name/logo treatment in `BoardDetailsHeader` |
| Route row: departure city + plane + status word (`Запланирован`) + arrival city; "+1" day indicator sits above the arrival time | React leg row renders raw station codes, city names, times stacked vertically | open — `FlightLegs` block needs to be rewritten as a horizontal route strip like Angular's `flight-board-details` |
| Accordion first section (`Детали рейса`) open by default with Регистрация / Посадка rows visible; others (Борт, Питание, Услуги, Деплайнинг) below | 5 accordions all rendered, all collapsed by default; no `Детали рейса` wrapper | open — open primary section by default, hide `Share` button (Angular hides it on board view) |
| `Борт` (aircraft) panel has a table with columns: Название / Количество мест / Эконом / Бизнес / Предыдущий рейс | React's `AircraftPanel` renders the same fields but as a list, not a table | open |
---
## Priority fixes shipped in this commit
1. **White card** (`section.frame`) around flight list + details body — the single biggest readability issue.
2. **`DayTabs` calendar strip** replaces the inline number-only strip on the search page.
3. **Clickable `FlightCard`** with keyboard support and hover highlight, so users don't have to use the off-screen test button.
4. **Fixed React key warning** in the `FlightLegs` renderer.
## Deferred (not shipped)
- Operator logo + airline name on every row and in the details header.
- Delay arrow + `+1` day-change indicators in Angular's layout.
- Inline per-row expansion with Registration/Boarding/Deboarding mini-grid.
- `Борт` aircraft panel as a table.
- Pre-populate the filter with the selected city's display name.
- Hide `Share` button on the board details page (Angular only shows it on schedule details).
These need targeted, per-component changes against the Angular HTML templates referenced in `ClientApp/src/app/modules/pages/board/` and `ClientApp/src/app/modules/pages/details/`. They don't affect readability, so they're listed for a follow-up pass.
@@ -55,10 +55,10 @@ function FlightLegs({
return (
<div className="flight-details__legs" data-testid="flight-legs">
{legs.map((leg, i) => (
<Fragment key={leg.index}>
<div className="flight-details__leg" data-testid={`flight-leg-${leg.index}`}>
<Fragment key={`leg-${leg.index ?? i}`}>
<div className="flight-details__leg" data-testid={`flight-leg-${leg.index ?? i}`}>
<div className="flight-details__leg-header">
<span className="flight-details__leg-index">Leg {leg.index + 1}</span>
<span className="flight-details__leg-index">Leg {(leg.index ?? i) + 1}</span>
<span className="flight-details__leg-status">{leg.status}</span>
</div>
@@ -279,6 +279,7 @@ export const OnlineBoardDetailsPage: FC<OnlineBoardDetailsPageProps> = ({
/>
}
>
<section className="frame">
<div className="flight-details" data-testid="flight-details">
{/* Connection status */}
<div className="flight-details__status" data-testid="connection-status">
@@ -322,6 +323,7 @@ export const OnlineBoardDetailsPage: FC<OnlineBoardDetailsPageProps> = ({
<FlightSchedule flight={displayFlight} />
</div>
</section>
</PageLayout>
</>
);
@@ -21,6 +21,7 @@ import { PageLayout } from "@/ui/layout/PageLayout.js";
import { PageTabs } from "@/ui/layout/PageTabs.js";
import { SearchHistory } from "@/ui/layout/SearchHistory.js";
import { OnlineBoardFilter } from "./OnlineBoardFilter.js";
import { DayTabs } from "./DayTabs/index.js";
import "./OnlineBoardSearchPage.scss";
import { JsonLdRenderer } from "@/shared/seo/json-ld.js";
import { useOnlineBoard } from "../hooks/useOnlineBoard.js";
@@ -37,16 +38,6 @@ export interface OnlineBoardSearchPageProps {
params: OnlineBoardParams & { type: "flight" | "departure" | "arrival" | "route" };
}
/**
* Format a yyyyMMdd date string for display in the calendar strip.
* Shows the day number (e.g. "15", "16").
*/
function formatDayLabel(yyyymmdd: string): string {
if (yyyymmdd.length !== 8) return yyyymmdd;
const day = parseInt(yyyymmdd.slice(6, 8), 10);
return String(day);
}
/**
* Convert yyyyMMdd URL date to API format (yyyy-MM-ddT00:00:00).
*/
@@ -296,20 +287,14 @@ export const OnlineBoardSearchPage: FC<OnlineBoardSearchPageProps> = ({
</>
}
stickyContent={
calendarDays.length > 0 ? (
<div className="online-board-search__calendar" data-testid="calendar-strip">
{calendarDays.map((day) => (
<button
key={day}
type="button"
className={`calendar-day${day === params.date ? " calendar-day--active" : ""}`}
onClick={() => handleDateChange(day)}
>
{formatDayLabel(day)}
</button>
))}
</div>
) : undefined
<DayTabs
selectedDate={params.date}
availableDates={calendarDays}
daysBefore={2}
daysAfter={14}
locale={lang}
onNavigate={handleDateChange}
/>
}
>
{/* Connection status indicator */}
@@ -346,10 +331,19 @@ export const OnlineBoardSearchPage: FC<OnlineBoardSearchPageProps> = ({
</section>
)}
{/* Flight list */}
{!error && <FlightList flights={displayFlights} loading={loading} />}
{/* Flight list — wrapped in .frame for the white card + shadow */}
{!error && (
<section className="frame">
<FlightList
flights={displayFlights}
loading={loading}
onFlightClick={handleFlightClick}
/>
</section>
)}
{/* Flight click overlay — we make the list clickable */}
{/* Off-screen hit targets for e2e tests — shares markup contract
with previous versions of the page. */}
{!loading && displayFlights.length > 0 && (
<div className="online-board-search__actions" data-testid="flight-actions">
{displayFlights.map((flight) => (
+15
View File
@@ -9,11 +9,26 @@
padding: vars.$space-xl 0;
margin: 0 vars.$space-xl;
justify-content: space-between;
background: transparent;
transition: background-color 120ms ease;
& + & {
border-top: 1px dashed colors.$border;
}
&--clickable {
cursor: pointer;
&:hover {
background-color: #eef3ff;
}
&:focus-visible {
outline: 2px solid colors.$blue;
outline-offset: -2px;
}
}
&__number {
width: vars.$width-flight-number;
font-weight: fonts.$font-medium;
+21 -3
View File
@@ -1,4 +1,4 @@
import type { FC } from "react";
import type { FC, KeyboardEvent } from "react";
import type { ISimpleFlight, IFlightLeg } from "@/features/online-board/types.js";
import { StationDisplay } from "./StationDisplay.js";
import { TimeGroup } from "./TimeGroup.js";
@@ -8,6 +8,7 @@ import "./FlightCard.scss";
export interface FlightCardProps {
flight: ISimpleFlight;
onClick?: () => void;
}
/** Extract the primary leg from a flight (first leg for multi-leg) */
@@ -40,7 +41,7 @@ function flyingTimeToMinutes(flyingTime: string): number {
*
* Composes StationDisplay + TimeGroup + FlightStatus + DurationDisplay.
*/
export const FlightCard: FC<FlightCardProps> = ({ flight }) => {
export const FlightCard: FC<FlightCardProps> = ({ flight, onClick }) => {
const departureLeg = getPrimaryLeg(flight);
const arrivalLeg = getFinalLeg(flight);
@@ -50,9 +51,26 @@ export const FlightCard: FC<FlightCardProps> = ({ flight }) => {
const arrTimes = arrStation.times;
const flightNumber = `${flight.flightId.carrier} ${flight.flightId.flightNumber}`;
const clickable = Boolean(onClick);
return (
<div className="flight-card" data-flight-id={flight.id}>
<div
className={`flight-card${clickable ? " flight-card--clickable" : ""}`}
data-flight-id={flight.id}
{...(clickable
? {
role: "button",
tabIndex: 0,
onClick,
onKeyDown: (e: KeyboardEvent<HTMLDivElement>) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onClick!();
}
},
}
: {})}
>
<div className="flight-card__number">{flightNumber}</div>
<div className="flight-card__route">
+8 -1
View File
@@ -11,6 +11,8 @@ export interface FlightListProps {
loading?: boolean;
/** Number of skeleton rows when loading (default: 5) */
skeletonCount?: number;
/** Click handler — makes each card act as a link to the details page */
onFlightClick?: (flight: ISimpleFlight) => void;
}
/**
@@ -23,6 +25,7 @@ export const FlightList: FC<FlightListProps> = ({
flights,
loading = false,
skeletonCount = 5,
onFlightClick,
}) => {
if (loading) {
return <FlightListSkeleton count={skeletonCount} />;
@@ -39,7 +42,11 @@ export const FlightList: FC<FlightListProps> = ({
return (
<div className="flight-list">
{flights.map((flight) => (
<FlightCard key={flight.id} flight={flight} />
<FlightCard
key={flight.id}
flight={flight}
{...(onFlightClick ? { onClick: () => onFlightClick(flight) } : {})}
/>
))}
</div>
);
@@ -156,15 +156,15 @@ describe("Flight search page integration", () => {
expect(screen.getByText("No flights found")).toBeTruthy();
});
it("renders calendar strip with available days", () => {
it("renders calendar strip (DayTabs) with pagination arrows", () => {
setupMocksWithData();
render(<OnlineBoardSearchPage params={DEPARTURE_PARAMS} />);
const calendarStrip = screen.getByTestId("calendar-strip");
expect(calendarStrip).toBeTruthy();
// Verify calendar day buttons are present
for (const day of CALENDAR_DAYS) {
expect(screen.getByText(day)).toBeTruthy();
}
// Pre-refactor the sticky strip was an ad-hoc DOM with data-testid
// "calendar-strip". We now use the shared <DayTabs> component, whose
// root testid is "day-tabs". Assert its structural markers instead.
expect(screen.getByTestId("day-tabs")).toBeTruthy();
expect(screen.getByTestId("day-tabs-prev")).toBeTruthy();
expect(screen.getByTestId("day-tabs-next")).toBeTruthy();
});
it("renders live connection badge when SignalR is live", () => {