Add transfer bar + multi-leg timeline (B.5) design spec

This commit is contained in:
2026-04-17 02:19:33 +03:00
parent 6fd42585c1
commit c08e7c3c3b
@@ -0,0 +1,284 @@
# Transfer Bar + Multi-Leg Full-Route Timeline (B.5)
## Goal
Add Angular-parity multi-leg visualization to the React Online Board details page: a carousel-style `FullRouteTimeline` at the top of multi-leg flights showing 2 consecutive legs at a time, and a `TransferBar` between every pair of legs showing layover duration, station change info, and arrival/departure times.
## Scope
Sub-feature **B.5** of the Flight Details parity work. Online Board details page only. Triggers for multi-leg flights (`routeType === "MultiLeg"`). Direct flights see no changes.
## Current State
The React details page has no multi-leg visualization. `FlightLegs` iterates legs without showing transfers or a top-level route timeline.
## Approach
Two new component families:
1. **`FullRouteTimeline`** — wrapper + inner `Timeline` component. Timeline shows 2 legs at a time with prev/next carousel buttons. Uses flexbox + CSS dividers (no SVG). Hidden on mobile (<768px).
2. **`TransferBar`** — rendered between consecutive legs. Displays a horizontal info bar with layover duration, arrival/departure times, and station-change indicator.
Supporting leaves: `Station`, `StationChange`, `TransferTime`. Pure helpers: `detectStationChange`, `computeTransferMinutes`, `formatMinutesAsDuration`.
## Types Extension
```typescript
// src/features/online-board/types.ts additions
export interface IDuration {
days: number;
hours: number;
minutes: number;
isNegative?: boolean;
}
export interface IFlightLeg {
// ... existing fields ...
estimatedDuration?: IDuration;
scheduledDuration?: IDuration;
}
```
Both duration fields are optional. React continues to use `flyingTime: string` for primary display; the Duration fields power the "specifying" orange-color state (when `estimatedDuration.isNegative === true`) in the timeline.
## Architecture
### File structure
```
src/features/online-board/components/
├── FullRouteTimeline/
│ ├── FullRouteTimeline.tsx
│ ├── FullRouteTimeline.scss
│ ├── FullRouteTimeline.test.tsx
│ ├── Timeline.tsx
│ ├── Timeline.test.tsx
│ ├── Station.tsx
│ ├── Station.test.tsx
│ ├── StationChange.tsx
│ ├── StationChange.test.tsx
│ ├── detectStationChange.ts
│ ├── detectStationChange.test.ts
│ └── index.ts
└── TransferBar/
├── TransferBar.tsx
├── TransferBar.scss
├── TransferBar.test.tsx
├── TransferTime.tsx
├── TransferTime.test.tsx
├── computeTransferTime.ts
├── computeTransferTime.test.ts
└── index.ts
```
### Component contracts
```typescript
// FullRouteTimeline — wrapper; renders Timeline when legs.length > 1
interface FullRouteTimelineProps {
legs: IFlightLeg[];
viewType: "Onlineboard" | "Schedule";
}
// Timeline — carousel state management, renders 2 legs at a time
interface TimelineProps {
legs: IFlightLeg[];
canChange: boolean; // maps to viewType === "Onlineboard"
}
// TransferBar — between two consecutive legs
interface TransferBarProps {
leg: IFlightLeg; // the leg with arrival
nextLeg: IFlightLeg; // the leg with departure
viewType: "Onlineboard" | "Schedule";
}
// Station — single station display
interface StationProps {
station: IFlightLegStation;
align?: "left" | "right" | "center";
size?: "small" | "medium" | "large";
}
// StationChange — renders one of 4 visual branches
interface StationChangeProps {
from: IFlightLegStation;
to: IFlightLegStation;
}
// TransferTime — layover duration display
interface TransferTimeProps {
arrivalUtc: string;
departureUtc: string;
}
```
### Pure helpers
```typescript
// detectStationChange.ts
export type StationChange = "city" | "airport" | "terminal" | "noChange";
export function detectStationChange(
from: IFlightLegStation,
to: IFlightLegStation,
): StationChange;
// computeTransferTime.ts
/** UTC diff in minutes between departure and arrival. Returns null for invalid input. */
export function computeTransferMinutes(
arrivalUtc: string | undefined,
departureUtc: string | undefined,
): number | null;
/** Format minutes as "Nh Mm" (or "Mm" for <1h). */
export function formatMinutesAsDuration(minutes: number): string;
```
## Rendering Details
### Timeline (2 legs at a time)
Given an internal `index` state (0..legs.length-2):
- **Times row**: `depTime(legs[index])` — section separator — `arrTime(legs[index])` — middle transfer separator — `depTime(legs[index+1])` — section separator — `arrTime(legs[index+1])`
- **Stations row**: `<Station dep-of-legs[index]>``<StationChange from=arr-of-legs[index] to=dep-of-legs[index+1]>``<Station arr-of-legs[index+1]>`
- **Navigation**: prev button when `index > 0`, next button when `index < legs.length - 2`. Both are circular buttons with / chevrons, disabled at boundaries (visually opacity 0.3).
### Time source logic (matches Angular `canChange` flag)
```typescript
function depTime(leg: IFlightLeg, canChange: boolean): string {
const t = leg.departure.times;
if (canChange) return t.actualBlockOff?.local ?? t.scheduledDeparture.local;
return t.scheduledDeparture.local;
}
function arrTime(leg: IFlightLeg, canChange: boolean): string {
const t = leg.arrival.times;
if (canChange) return t.actualBlockOn?.local ?? t.scheduledArrival.local;
return t.scheduledArrival.local;
}
```
### Section label (number + duration badge)
```tsx
<div className="timeline-section">
<div className="timeline-section__separator" />
<span className="timeline-section__number">{index + 1}</span>
<span className={`timeline-section__duration ${isSpecifying ? "timeline-section__duration--specifying" : ""}`}>
{leg.flyingTime}
</span>
<div className="timeline-section__separator" />
</div>
```
`isSpecifying = leg.estimatedDuration?.isNegative === true` (only for canChange views).
### TransferBar
```tsx
<div className={`transfer-bar ${stationChange === "noChange" ? "" : "transfer-bar--separated"}`}>
<div className="transfer-bar__icon"><svg>...</svg></div>
<div className="transfer-bar__content">
<span className="transfer-bar__type">{t("SHARED.INTERMEDIATE-LANDING")}</span>
<div className="transfer-bar__duration">
<TransferTime arrivalUtc={arrivalUtc} departureUtc={departureUtc} />
</div>
<div className="transfer-bar__times">
<span>{arrivalLocal}</span><span> </span><span>{departureLocal}</span>
</div>
{stationChange !== "noChange" && (
<div className="transfer-bar__station-change">
<StationChange from={leg.arrival} to={nextLeg.departure} />
</div>
)}
</div>
</div>
```
Times come from `viewType === "Schedule"` → always scheduled; else latest-with-fallback-to-scheduled.
### Station
```tsx
<div className={`station station--${align} station--${size}`}>
<span className="station__city">{station.scheduled.city}</span>
<span className="station__code">{station.scheduled.airportCode}</span>
{station.terminal && <span className="station__terminal">T{station.terminal}</span>}
</div>
```
### StationChange (4 branches)
- `noChange``<Station station={from} />` (single display)
- `city``<Station from> → <Station to>` (both cities with arrow)
- `airport``city | {from.airportCode}/{from.terminal} → {to.airportCode}/{to.terminal}` (same city, different airport)
- `terminal` → same as airport branch but only terminal differs
### Integration into `OnlineBoardDetailsPage`
```tsx
{displayFlight.routeType === "MultiLeg" && (
<FullRouteTimeline
legs={displayFlight.legs}
viewType="Onlineboard"
/>
)}
{/* FlightLegs renders transfer bars between legs: */}
<FlightLegs legs={legs} viewType="Onlineboard" />
// Internally: legs.map((leg, i) => <>{<Leg>}{i < legs.length-1 && <TransferBar leg={leg} nextLeg={legs[i+1]} viewType="Onlineboard" />}</>)
```
## Styling
See the SCSS blocks presented in the design (Timeline flex layout, station-change arrow indicator, transfer-bar colored box). Mobile breakpoint at 768px hides the Timeline entirely. TransferBar stacks content vertically on mobile.
## Testing
### Helpers (pure)
- `detectStationChange.test.ts` — 4 cases: same station, city change, airport change, terminal change
- `computeTransferTime.test.ts` — positive diff, zero, negative returns null, invalid inputs return null, formatting "1h 30m" / "30m"
### Components
- `Station.test.tsx` — renders city, code, terminal; alignment & size modifiers applied
- `StationChange.test.tsx` — 4 branches render correctly (`noChange`, `city`, `airport`, `terminal`)
- `TransferTime.test.tsx` — renders formatted duration; returns null for negative/invalid
- `TransferBar.test.tsx` — renders icon + type label + duration + times + station change for city case; renders without station change for noChange
- `Timeline.test.tsx` — 2 legs visible, prev disabled on index=0, next disabled on last pair, prev/next change index, specifying class applied when `estimatedDuration.isNegative`
- `FullRouteTimeline.test.tsx` — returns null when `legs.length < 2`, renders Timeline otherwise
### Integration
- `OnlineBoardDetailsPage.test.tsx``FullRouteTimeline` renders for MultiLeg flights, not for Direct. `TransferBar` rendered between legs.
## i18n
Existing keys reused:
- `SHARED.INTERMEDIATE-LANDING`
- `SHARED.CONNECTING` (if needed for Connecting flights; multi-leg uses intermediate)
- `SHARED.TRANSFER`
No new keys needed.
## Out of Scope
- Schedule feature details variant — reuses existing `viewType="Schedule"` prop semantics but no schedule-specific wiring in this spec
- Connecting flights (separate flights booked together) — the `TransferBar` supports `viewType` switching but the Connecting route type is its own feature area, not wired into the React details page yet
- Timeline animation / transition effects between index changes
- Sprite-based icons — we use inline SVG for transfer icon
## Files Touched
### New
- All files under `src/features/online-board/components/FullRouteTimeline/` and `src/features/online-board/components/TransferBar/` (as listed in file structure)
### Modified
- `src/features/online-board/types.ts` — add `IDuration`, extend `IFlightLeg`
- `src/features/online-board/types.test.ts` — add test cases
- `src/features/online-board/components/OnlineBoardDetailsPage.tsx` — add `FullRouteTimeline`; update `FlightLegs` to interleave `TransferBar` between legs
- `src/features/online-board/components/OnlineBoardDetailsPage.test.tsx` — add integration tests