From 877cd871621601f476abb2d37e9b87f8c1984643 Mon Sep 17 00:00:00 2001 From: gnezim Date: Wed, 22 Apr 2026 00:06:21 +0300 Subject: [PATCH] =?UTF-8?q?Add=20timeline=20time-calculation=20algorithm?= =?UTF-8?q?=20per=20TZ=20=C2=A74.1.15.7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Creates timelineTime.ts with computeTimelineCalc (R94–R97: total/elapsed/ remaining minutes + aircraft position %) and formatTimelineDuration (R98: omit zero leading units — «45мин.» not «0ч. 45мин.»). Wires into OnlineBoardDetailsPage: arrival time now uses actual > estimated > scheduled priority (R94), and В пути / До прилета labels use the new formatter. 24 unit tests cover all branches. --- .../components/OnlineBoardDetailsPage.tsx | 34 ++- .../online-board/timelineTime.test.ts | 228 ++++++++++++++++++ src/features/online-board/timelineTime.ts | 156 ++++++++++++ 3 files changed, 398 insertions(+), 20 deletions(-) create mode 100644 src/features/online-board/timelineTime.test.ts create mode 100644 src/features/online-board/timelineTime.ts 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 }; +}