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:
@@ -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({
|
||||
<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 — 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user