Add timeline time-calculation algorithm per TZ §4.1.15.7

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.
This commit is contained in:
2026-04-22 00:06:21 +03:00
parent 63fc6060f2
commit 877cd87162
3 changed files with 398 additions and 20 deletions
@@ -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 R94R97: 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({
<span className="leg-route__progress-label leg-route__progress-label--left">
{t("SHARED.TRAVEL-TIME")}
<span className="leg-route__progress-value">
{formatDuration(elapsedMinutes, "ru")}
{formatTimelineDuration(elapsedMinutes, "ru")}
</span>
</span>
<span className="leg-route__progress-label leg-route__progress-label--right">
{t("SHARED.TIME-LEFT")}
<span className="leg-route__progress-value">
{formatDuration(remainingMinutes, "ru")}
{formatTimelineDuration(remainingMinutes, "ru")}
</span>
</span>
</div>
@@ -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 — R94R97
// ---------------------------------------------------------------------------
// 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,
});
});
});
+156
View File
@@ -0,0 +1,156 @@
/**
* Timeline time-calculation algorithm per TZ §4.1.15.7.
*
* Rules (R94R98):
* 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 };
}