diff --git a/docs/superpowers/specs/2026-04-17-transfer-multi-leg-timeline-design.md b/docs/superpowers/specs/2026-04-17-transfer-multi-leg-timeline-design.md new file mode 100644 index 00000000..12ae9530 --- /dev/null +++ b/docs/superpowers/specs/2026-04-17-transfer-multi-leg-timeline-design.md @@ -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**: `` — `` — `` +- **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 +
+
+ {index + 1} + + {leg.flyingTime} + +
+
+``` + +`isSpecifying = leg.estimatedDuration?.isNegative === true` (only for canChange views). + +### TransferBar + +```tsx +
+
...
+
+ {t("SHARED.INTERMEDIATE-LANDING")} +
+ +
+
+ {arrivalLocal}{departureLocal} +
+ {stationChange !== "noChange" && ( +
+ +
+ )} +
+
+``` + +Times come from `viewType === "Schedule"` → always scheduled; else latest-with-fallback-to-scheduled. + +### Station + +```tsx +
+ {station.scheduled.city} + {station.scheduled.airportCode} + {station.terminal && T{station.terminal}} +
+``` + +### StationChange (4 branches) + +- `noChange` → `` (single display) +- `city` → `` (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" && ( + +)} + +{/* FlightLegs renders transfer bars between legs: */} + +// Internally: legs.map((leg, i) => <>{}{i < legs.length-1 && }) +``` + +## 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