diff --git a/src/features/online-board/components/OnlineBoardDetailsPage.tsx b/src/features/online-board/components/OnlineBoardDetailsPage.tsx
index cf78e438..54d6f560 100644
--- a/src/features/online-board/components/OnlineBoardDetailsPage.tsx
+++ b/src/features/online-board/components/OnlineBoardDetailsPage.tsx
@@ -37,6 +37,7 @@ import {
formatDayMonthYear,
formatDuration,
} from "@/shared/utils/datetime/index.js";
+import { computeTimelineCalc, formatTimelineDuration } from "../timelineTime.js";
/**
* Parse "HH:mm" / "HH:mm:ss" / "H:mm" into total minutes, then humanize
@@ -118,24 +119,17 @@ function LegRoute({
// as well as Departed/Sent once the plane has left the gate.
const isInFlight = status === "InFlight";
- // Angular's leg.flightPercent / remainingFlightDuration are precomputed on
- // the model. React derives them from the scheduled/actual timestamps, so we
- // compute a simple elapsed % between departure and scheduled arrival.
- let flightPercent = 0;
- let elapsedMinutes = 0;
- let remainingMinutes = 0;
- if (depActual?.local) {
- const depMs = Date.parse(depActual.local);
- const arrMs = Date.parse(arrSched.local);
- const now = Date.now();
- if (!Number.isNaN(depMs) && !Number.isNaN(arrMs) && arrMs > depMs) {
- const total = arrMs - depMs;
- const elapsed = Math.max(0, Math.min(total, now - depMs));
- flightPercent = Math.round((elapsed / total) * 100);
- elapsedMinutes = Math.round(elapsed / 60000);
- remainingMinutes = Math.max(0, Math.round((arrMs - now) / 60000));
- }
- }
+ // §4.1.15.7 R94–R97: compute elapsed / remaining / position via shared helper.
+ // Arrival time priority: actual > estimated > scheduled (R94).
+ const tlCalc = computeTimelineCalc({
+ depActualUtc: depActual?.utc ?? null,
+ arrScheduledUtc: arrSched.utc,
+ arrEstimatedUtc: arr.times.estimatedBlockOn?.utc ?? null,
+ arrActualUtc: arrActual?.utc ?? null,
+ });
+ let flightPercent = tlCalc.positionPercent;
+ let elapsedMinutes = tlCalc.elapsedMinutes;
+ let remainingMinutes = tlCalc.remainingMinutes;
if (isFinished) {
flightPercent = 100;
remainingMinutes = 0;
@@ -193,13 +187,13 @@ function LegRoute({
{t("SHARED.TRAVEL-TIME")}
- {formatDuration(elapsedMinutes, "ru")}
+ {formatTimelineDuration(elapsedMinutes, "ru")}
{t("SHARED.TIME-LEFT")}
- {formatDuration(remainingMinutes, "ru")}
+ {formatTimelineDuration(remainingMinutes, "ru")}
diff --git a/src/features/online-board/timelineTime.test.ts b/src/features/online-board/timelineTime.test.ts
new file mode 100644
index 00000000..980facfc
--- /dev/null
+++ b/src/features/online-board/timelineTime.test.ts
@@ -0,0 +1,228 @@
+/**
+ * Unit tests for TZ §4.1.15.7 timeline time-calculation algorithm.
+ *
+ * @vitest-environment node
+ */
+
+import { describe, it, expect } from "vitest";
+import { computeTimelineCalc, formatTimelineDuration } from "./timelineTime.js";
+import type { TimelineInputs } from "./timelineTime.js";
+
+// ---------------------------------------------------------------------------
+// formatTimelineDuration — R98
+// ---------------------------------------------------------------------------
+
+describe("formatTimelineDuration (R98)", () => {
+ it("45 min → only minutes part (no leading hours)", () => {
+ expect(formatTimelineDuration(45, "ru")).toBe("45мин.");
+ });
+
+ it("0 min → 0мин.", () => {
+ expect(formatTimelineDuration(0, "ru")).toBe("0мин.");
+ });
+
+ it("60 min → 1ч. 0мин.", () => {
+ expect(formatTimelineDuration(60, "ru")).toBe("1ч. 0мин.");
+ });
+
+ it("90 min → 1ч. 30мин.", () => {
+ expect(formatTimelineDuration(90, "ru")).toBe("1ч. 30мин.");
+ });
+
+ it("1440 min (1 day) → 1д. 0ч. 0мин.", () => {
+ expect(formatTimelineDuration(1440, "ru")).toBe("1д. 0ч. 0мин.");
+ });
+
+ it("1505 min (1d 1h 5m) → 1д. 1ч. 5мин.", () => {
+ expect(formatTimelineDuration(1505, "ru")).toBe("1д. 1ч. 5мин.");
+ });
+
+ it("1445 min (1d 0h 5m) → 1д. 0ч. 5мин. (hours shown when days present)", () => {
+ expect(formatTimelineDuration(1445, "ru")).toBe("1д. 0ч. 5мин.");
+ });
+
+ it("English locale uses en units", () => {
+ expect(formatTimelineDuration(90, "en")).toBe("1h 30m");
+ });
+
+ it("English with days", () => {
+ expect(formatTimelineDuration(1505, "en")).toBe("1d 1h 5m");
+ });
+
+ it("negative input treated as 0", () => {
+ expect(formatTimelineDuration(-10, "ru")).toBe("0мин.");
+ });
+
+ it("fractional minutes are floored", () => {
+ expect(formatTimelineDuration(90.9, "ru")).toBe("1ч. 30мин.");
+ });
+});
+
+// ---------------------------------------------------------------------------
+// computeTimelineCalc — R94–R97
+// ---------------------------------------------------------------------------
+
+// Fixed reference timestamps for deterministic tests
+const DEP_UTC = "2026-04-21T10:00:00Z"; // 10:00 UTC departure (actual)
+const ARR_SCHED_UTC = "2026-04-21T12:00:00Z"; // 12:00 UTC scheduled arrival (120 min total)
+const ARR_EST_UTC = "2026-04-21T12:30:00Z"; // 12:30 UTC estimated arrival (150 min total)
+const ARR_ACTUAL_UTC = "2026-04-21T11:50:00Z"; // 11:50 UTC actual arrival (110 min total)
+
+// Now = 11:00 UTC → 60 min elapsed, 60/120 = 50% (vs scheduled)
+const NOW_INFLIGHT = Date.parse("2026-04-21T11:00:00Z");
+// Now = 12:30 → already past scheduled arrival
+const NOW_PAST = Date.parse("2026-04-21T12:30:00Z");
+
+describe("computeTimelineCalc — no actual departure", () => {
+ const inputs: TimelineInputs = {
+ depActualUtc: null,
+ arrScheduledUtc: ARR_SCHED_UTC,
+ };
+
+ it("returns zeros when depActualUtc is missing", () => {
+ const result = computeTimelineCalc(inputs, NOW_INFLIGHT);
+ expect(result).toEqual({
+ totalMinutes: 0,
+ elapsedMinutes: 0,
+ remainingMinutes: 0,
+ positionPercent: 0,
+ });
+ });
+});
+
+describe("computeTimelineCalc — R94 arrival time priority", () => {
+ it("uses actual arrival over estimated when both present", () => {
+ const inputs: TimelineInputs = {
+ depActualUtc: DEP_UTC,
+ arrScheduledUtc: ARR_SCHED_UTC,
+ arrEstimatedUtc: ARR_EST_UTC,
+ arrActualUtc: ARR_ACTUAL_UTC,
+ };
+ // actual arrival = 11:50 → total = 110 min
+ const result = computeTimelineCalc(inputs, NOW_INFLIGHT);
+ expect(result.totalMinutes).toBe(110);
+ });
+
+ it("uses estimated arrival when no actual arrival", () => {
+ const inputs: TimelineInputs = {
+ depActualUtc: DEP_UTC,
+ arrScheduledUtc: ARR_SCHED_UTC,
+ arrEstimatedUtc: ARR_EST_UTC,
+ arrActualUtc: null,
+ };
+ // estimated = 12:30 → total = 150 min
+ const result = computeTimelineCalc(inputs, NOW_INFLIGHT);
+ expect(result.totalMinutes).toBe(150);
+ });
+
+ it("falls back to scheduled arrival when no actual or estimated", () => {
+ const inputs: TimelineInputs = {
+ depActualUtc: DEP_UTC,
+ arrScheduledUtc: ARR_SCHED_UTC,
+ };
+ // scheduled = 12:00 → total = 120 min
+ const result = computeTimelineCalc(inputs, NOW_INFLIGHT);
+ expect(result.totalMinutes).toBe(120);
+ });
+});
+
+describe("computeTimelineCalc — R95 elapsed (В пути)", () => {
+ it("returns 60 elapsed minutes when now is 60 min after departure", () => {
+ const inputs: TimelineInputs = {
+ depActualUtc: DEP_UTC,
+ arrScheduledUtc: ARR_SCHED_UTC,
+ };
+ const result = computeTimelineCalc(inputs, NOW_INFLIGHT);
+ expect(result.elapsedMinutes).toBe(60);
+ });
+
+ it("clamps elapsed to totalMinutes when now is past arrival", () => {
+ const inputs: TimelineInputs = {
+ depActualUtc: DEP_UTC,
+ arrScheduledUtc: ARR_SCHED_UTC,
+ };
+ const result = computeTimelineCalc(inputs, NOW_PAST);
+ expect(result.elapsedMinutes).toBe(120); // clamped to total
+ });
+});
+
+describe("computeTimelineCalc — R96 remaining (До прилета)", () => {
+ it("returns 60 remaining minutes when now is 60 min before arrival", () => {
+ const inputs: TimelineInputs = {
+ depActualUtc: DEP_UTC,
+ arrScheduledUtc: ARR_SCHED_UTC,
+ };
+ const result = computeTimelineCalc(inputs, NOW_INFLIGHT);
+ expect(result.remainingMinutes).toBe(60);
+ });
+
+ it("returns 0 when now is past arrival", () => {
+ const inputs: TimelineInputs = {
+ depActualUtc: DEP_UTC,
+ arrScheduledUtc: ARR_SCHED_UTC,
+ };
+ const result = computeTimelineCalc(inputs, NOW_PAST);
+ expect(result.remainingMinutes).toBe(0);
+ });
+});
+
+describe("computeTimelineCalc — R97 position percent", () => {
+ it("returns 50% when halfway through 120-min flight", () => {
+ const inputs: TimelineInputs = {
+ depActualUtc: DEP_UTC,
+ arrScheduledUtc: ARR_SCHED_UTC,
+ };
+ const result = computeTimelineCalc(inputs, NOW_INFLIGHT);
+ expect(result.positionPercent).toBe(50);
+ });
+
+ it("returns 100% when now is past arrival", () => {
+ const inputs: TimelineInputs = {
+ depActualUtc: DEP_UTC,
+ arrScheduledUtc: ARR_SCHED_UTC,
+ };
+ const result = computeTimelineCalc(inputs, NOW_PAST);
+ expect(result.positionPercent).toBe(100);
+ });
+
+ it("returns 0% before departure (elapsed clamped to 0)", () => {
+ const beforeDep = Date.parse("2026-04-21T09:30:00Z");
+ const inputs: TimelineInputs = {
+ depActualUtc: DEP_UTC,
+ arrScheduledUtc: ARR_SCHED_UTC,
+ };
+ const result = computeTimelineCalc(inputs, beforeDep);
+ expect(result.positionPercent).toBe(0);
+ expect(result.elapsedMinutes).toBe(0);
+ });
+});
+
+describe("computeTimelineCalc — invalid timestamps", () => {
+ it("returns zeros when depActualUtc is not a valid date", () => {
+ const inputs: TimelineInputs = {
+ depActualUtc: "not-a-date",
+ arrScheduledUtc: ARR_SCHED_UTC,
+ };
+ const result = computeTimelineCalc(inputs, NOW_INFLIGHT);
+ expect(result).toEqual({
+ totalMinutes: 0,
+ elapsedMinutes: 0,
+ remainingMinutes: 0,
+ positionPercent: 0,
+ });
+ });
+
+ it("returns zeros when arr is before dep (invalid range)", () => {
+ const inputs: TimelineInputs = {
+ depActualUtc: ARR_SCHED_UTC, // swap: dep after arr
+ arrScheduledUtc: DEP_UTC,
+ };
+ const result = computeTimelineCalc(inputs, NOW_INFLIGHT);
+ expect(result).toEqual({
+ totalMinutes: 0,
+ elapsedMinutes: 0,
+ remainingMinutes: 0,
+ positionPercent: 0,
+ });
+ });
+});
diff --git a/src/features/online-board/timelineTime.ts b/src/features/online-board/timelineTime.ts
new file mode 100644
index 00000000..0f50666c
--- /dev/null
+++ b/src/features/online-board/timelineTime.ts
@@ -0,0 +1,156 @@
+/**
+ * Timeline time-calculation algorithm per TZ §4.1.15.7.
+ *
+ * Rules (R94–R98):
+ * R94 — Total minutes = arrTime (actual ?? estimated ?? scheduled) UTC
+ * − depTime (actual) UTC
+ * R95 — Elapsed minutes «В пути» = now UTC − depTime UTC
+ * R96 — Remaining minutes «До прилета» = arrTime UTC − now UTC
+ * R97 — Aircraft position % = clamp(elapsed / total, 0, 1) × 100
+ * R98 — Display format: omit zero leading units
+ * «45мин.» / «2ч. 15мин.» / «1д. 3ч. 5мин.»
+ *
+ * @module
+ */
+
+// ---------------------------------------------------------------------------
+// Types
+// ---------------------------------------------------------------------------
+
+/** UTC ISO timestamp (or any string parseable by Date.parse) */
+type ISOString = string;
+
+/**
+ * Time inputs for a single flight leg, all in UTC.
+ * Names match the API field names used in IArrivalStationTimes.
+ */
+export interface TimelineInputs {
+ /** UTC timestamp of actual block-off (wheels-up). Required for in-flight calc. */
+ depActualUtc: ISOString | null | undefined;
+ /** UTC timestamp of scheduled arrival. Always present. */
+ arrScheduledUtc: ISOString;
+ /** UTC timestamp of estimated (expected) arrival. Present during flight. */
+ arrEstimatedUtc?: ISOString | null;
+ /** UTC timestamp of actual arrival (block-on). Present after landing. */
+ arrActualUtc?: ISOString | null;
+}
+
+/** Result of the §4.1.15.7 algorithm for one leg. */
+export interface TimelineCalc {
+ /**
+ * Total scheduled flight duration in minutes.
+ * Uses actual ?? estimated ?? scheduled arrival per R94.
+ * 0 if departure actual is missing or timestamps are invalid.
+ */
+ totalMinutes: number;
+ /**
+ * Minutes elapsed since departure (В пути) per R95.
+ * Clamped to [0, totalMinutes].
+ */
+ elapsedMinutes: number;
+ /**
+ * Minutes remaining until arrival (До прилета) per R96.
+ * Clamped to [0, ∞).
+ */
+ remainingMinutes: number;
+ /**
+ * Aircraft position on the timeline as a percentage [0, 100] per R97.
+ */
+ positionPercent: number;
+}
+
+// ---------------------------------------------------------------------------
+// Display format (R98)
+// ---------------------------------------------------------------------------
+
+const RU_UNITS = { d: "д.", h: "ч.", m: "мин." };
+const EN_UNITS = { d: "d", h: "h", m: "m" };
+
+/**
+ * Format total minutes into a human-readable duration per TZ §4.1.15.7 R98.
+ *
+ * Zero leading units are omitted:
+ * - 45 min → «45мин.»
+ * - 90 min → «1ч. 30мин.»
+ * - 1505 min (1d 1h 5m) → «1д. 1ч. 5мин.»
+ *
+ * Minutes are always included (even if 0), matching the TZ format examples.
+ *
+ * @param minutes - Total duration in minutes (non-negative integer)
+ * @param locale - Any locale string; strings starting with "ru" use Cyrillic units
+ */
+export function formatTimelineDuration(minutes: number, locale = "en"): string {
+ const units = locale.toLowerCase().startsWith("ru") ? RU_UNITS : EN_UNITS;
+
+ const totalMins = Math.max(0, Math.floor(minutes));
+ const days = Math.floor(totalMins / (60 * 24));
+ const hours = Math.floor((totalMins % (60 * 24)) / 60);
+ const mins = totalMins % 60;
+
+ const parts: string[] = [];
+ if (days > 0) parts.push(`${days}${units.d}`);
+ if (hours > 0 || days > 0) parts.push(`${hours}${units.h}`);
+ parts.push(`${mins}${units.m}`);
+
+ return parts.join(" ");
+}
+
+// ---------------------------------------------------------------------------
+// Core algorithm
+// ---------------------------------------------------------------------------
+
+/**
+ * Pick the best arrival UTC timestamp for the total-minutes calculation.
+ *
+ * Per R94: use actual if available, then estimated, then scheduled.
+ */
+function bestArrivalUtc(inputs: TimelineInputs): ISOString {
+ return inputs.arrActualUtc || inputs.arrEstimatedUtc || inputs.arrScheduledUtc;
+}
+
+/**
+ * Compute timeline values for a single leg per TZ §4.1.15.7.
+ *
+ * Pass `nowMs` (milliseconds since epoch) to enable deterministic tests;
+ * defaults to `Date.now()`.
+ *
+ * Returns zeros for all fields when:
+ * - `depActualUtc` is absent (flight not yet departed),
+ * - any timestamp is invalid / unparseable.
+ */
+export function computeTimelineCalc(
+ inputs: TimelineInputs,
+ nowMs: number = Date.now(),
+): TimelineCalc {
+ const zero: TimelineCalc = {
+ totalMinutes: 0,
+ elapsedMinutes: 0,
+ remainingMinutes: 0,
+ positionPercent: 0,
+ };
+
+ if (!inputs.depActualUtc) return zero;
+
+ const depMs = Date.parse(inputs.depActualUtc);
+ const arrMs = Date.parse(bestArrivalUtc(inputs));
+
+ if (Number.isNaN(depMs) || Number.isNaN(arrMs)) return zero;
+ if (arrMs <= depMs) return zero;
+
+ // R94: total duration
+ const totalMs = arrMs - depMs;
+ const totalMinutes = Math.round(totalMs / 60_000);
+
+ // R95: elapsed (В пути)
+ const elapsedMs = Math.max(0, Math.min(totalMs, nowMs - depMs));
+ const elapsedMinutes = Math.round(elapsedMs / 60_000);
+
+ // R96: remaining (До прилета)
+ const remainingMs = Math.max(0, arrMs - nowMs);
+ const remainingMinutes = Math.round(remainingMs / 60_000);
+
+ // R97: position %
+ const positionPercent = Math.round((elapsedMs / totalMs) * 100);
+
+ return { totalMinutes, elapsedMinutes, remainingMinutes, positionPercent };
+}