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:
@@ -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) => (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user