Add transfer bar + multi-leg timeline (B.5) design spec
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user