Verify day-change algorithm per TZ 4.1.17 (per-time-type badges, query-date baseline)

R4 gap fixed: TimeGroup now accepts scheduledDayChange + actualDayChange props
separately so each time type renders its own independent badge. FlightCard
updated to pass them independently (scheduled vs actual/estimated); expanded
row time block also now shows per-type badges.

R5 tooltip fixed: dayChangeBadgeTooltip() uses string-based date extraction
(no TZ reprojection via new Date()) — avoids viewer-TZ shift for SSR and
cross-TZ correctness. Returns "День" for ±1, DD.MM.YYYY for ±2+.

New shared helper dayChange.ts exports computeDayChange(), dayChangeBadgeTooltip(),
formatDayChangeBadge(). 27 unit tests cover +0/+1/+2/-1/-2, null, malformed
input, month/year boundaries, and per-time-type independence (R4).

R1–R3, R6 confirmed correct (API supplies dayChange per ITimesSet; badge
adjacent to time; hidden when 0). R8 (mobile tooltip suppression) deferred.
This commit is contained in:
2026-04-22 00:01:30 +03:00
parent 5d31f4389e
commit 63fc6060f2
5 changed files with 429 additions and 53 deletions
+167
View File
@@ -0,0 +1,167 @@
/**
* Unit tests for dayChange helpers per TZ §4.1.17.
*
* Covers rules R1R5:
* R1/R2: computeDayChange returns BA (local time date minus query date)
* R3: Badge hidden if result is 0; shown as +N/-N otherwise
* R4: Each time type is computed independently (demonstrated by calling
* computeDayChange separately for scheduled/expected/actual)
* R5: Tooltip is "День" for ±1, "DD.MM.YYYY" for ±2 or more
*/
import { describe, it, expect } from "vitest";
import {
computeDayChange,
dayChangeBadgeTooltip,
formatDayChangeBadge,
} from "./dayChange.js";
// ---------------------------------------------------------------------------
// computeDayChange — TZ §4.1.17-R1, R2, R3, R4
// ---------------------------------------------------------------------------
describe("computeDayChange (TZ §4.1.17-R1R4)", () => {
it("returns 0 when time date equals query date (no badge — R3)", () => {
expect(computeDayChange("2026-04-15T10:00:00", "2026-04-15")).toBe(0);
});
it("returns 0 when time date equals query date with TZ offset suffix", () => {
expect(computeDayChange("2026-04-15T23:30:00+03:00", "2026-04-15")).toBe(0);
});
it("returns +1 when local date is the next day after query date", () => {
expect(computeDayChange("2026-04-16T00:30:00", "2026-04-15")).toBe(1);
});
it("returns +1 with TZ offset-aware string crossing midnight", () => {
// 2026-04-16T01:00:00+05:00 — local date is April 16 regardless of viewer TZ
expect(computeDayChange("2026-04-16T01:00:00+05:00", "2026-04-15")).toBe(1);
});
it("returns +2 when two calendar days ahead", () => {
expect(computeDayChange("2026-04-17T12:00:00", "2026-04-15")).toBe(2);
});
it("returns -1 when local date is one day before query date", () => {
expect(computeDayChange("2026-04-14T23:00:00", "2026-04-15")).toBe(-1);
});
it("returns -2 when two calendar days before query date", () => {
expect(computeDayChange("2026-04-13T08:00:00", "2026-04-15")).toBe(-2);
});
it("returns 0 for null input (guard)", () => {
expect(computeDayChange(null, "2026-04-15")).toBe(0);
});
it("returns 0 for undefined input (guard)", () => {
expect(computeDayChange(undefined, "2026-04-15")).toBe(0);
});
it("returns 0 for malformed time string", () => {
expect(computeDayChange("not-a-date", "2026-04-15")).toBe(0);
});
it("returns 0 for malformed query date", () => {
expect(computeDayChange("2026-04-16T10:00:00", "bad-date")).toBe(0);
});
it("returns 0 for empty time string", () => {
expect(computeDayChange("", "2026-04-15")).toBe(0);
});
// R4: per-time-type independence — scheduled and actual may differ
it("R4: scheduled and actual dayChange can differ independently", () => {
const queryDate = "2026-04-15";
const scheduledDayChange = computeDayChange(
"2026-04-15T23:45:00+03:00",
queryDate,
);
const actualDayChange = computeDayChange(
"2026-04-16T00:30:00+03:00", // delayed past midnight
queryDate,
);
expect(scheduledDayChange).toBe(0); // scheduled still April 15 → no badge
expect(actualDayChange).toBe(1); // actual crossed midnight → +1 badge
});
it("R4: estimated and actual can both differ from scheduled independently", () => {
const queryDate = "2026-04-15";
const scheduled = computeDayChange("2026-04-15T22:00:00", queryDate);
const estimated = computeDayChange("2026-04-16T00:10:00", queryDate);
const actual = computeDayChange("2026-04-16T00:45:00", queryDate);
expect(scheduled).toBe(0);
expect(estimated).toBe(1);
expect(actual).toBe(1);
});
});
// ---------------------------------------------------------------------------
// dayChangeBadgeTooltip — TZ §4.1.17-R5
// ---------------------------------------------------------------------------
describe("dayChangeBadgeTooltip (TZ §4.1.17-R5)", () => {
it("returns 'День' for +1", () => {
expect(dayChangeBadgeTooltip("2026-04-15T23:30:00", 1)).toBe("День");
});
it("returns 'День' for -1", () => {
expect(dayChangeBadgeTooltip("2026-04-15T01:00:00", -1)).toBe("День");
});
it("returns 'DD.MM.YYYY' for +2", () => {
// Base date is April 15 → +2 → April 17
expect(dayChangeBadgeTooltip("2026-04-15T10:00:00", 2)).toBe("17.04.2026");
});
it("returns 'DD.MM.YYYY' for -2", () => {
// Base date is April 15 → -2 → April 13
expect(dayChangeBadgeTooltip("2026-04-15T10:00:00", -2)).toBe("13.04.2026");
});
it("handles month boundary correctly for +2", () => {
// April 30 → +2 → May 2
expect(dayChangeBadgeTooltip("2026-04-30T10:00:00", 2)).toBe("02.05.2026");
});
it("handles year boundary correctly", () => {
// Dec 31 → +2 → Jan 2 next year
expect(dayChangeBadgeTooltip("2026-12-31T10:00:00", 2)).toBe("02.01.2027");
});
it("handles offset-aware ISO string (uses local date from string)", () => {
// Local date is April 15 (offset suffix doesn't change calendar date extraction)
expect(dayChangeBadgeTooltip("2026-04-15T23:30:00+03:00", 2)).toBe(
"17.04.2026",
);
});
it("returns empty string for malformed base string", () => {
expect(dayChangeBadgeTooltip("not-a-date", 2)).toBe("");
});
});
// ---------------------------------------------------------------------------
// formatDayChangeBadge — TZ §4.1.17-R3
// ---------------------------------------------------------------------------
describe("formatDayChangeBadge (TZ §4.1.17-R3)", () => {
it("returns '' for 0 (hidden)", () => {
expect(formatDayChangeBadge(0)).toBe("");
});
it("returns '+1' for 1", () => {
expect(formatDayChangeBadge(1)).toBe("+1");
});
it("returns '+2' for 2", () => {
expect(formatDayChangeBadge(2)).toBe("+2");
});
it("returns '-1' for -1", () => {
expect(formatDayChangeBadge(-1)).toBe("-1");
});
it("returns '-2' for -2", () => {
expect(formatDayChangeBadge(-2)).toBe("-2");
});
});
+103
View File
@@ -0,0 +1,103 @@
/**
* Day-change badge computation per TZ §4.1.17.
*
* Returns the day-offset between a time's local date and the query
* (user's requested) date. Returns 0 when the time falls on the query
* date (no badge shown).
*
* Each time type (scheduled / expected / actual) should call this
* independently — badges for different time types of the same flight
* may differ (e.g. scheduled +0 but actual +1 due to delay crossing
* midnight).
*
* The dayChange value is supplied by the API on each ITimesSet.dayChange.value
* field; this helper provides client-side computation for cases where a
* query-date baseline is available (e.g. when API value is absent or
* verification is needed).
*/
const ISO_DATE_RE = /^(\d{4})-(\d{2})-(\d{2})/;
/**
* Extract the calendar date components from an ISO local string without
* TZ reprojection. Handles both bare ("2026-04-15T23:30:00") and
* offset-aware ("2026-04-15T23:30:00+03:00") forms.
*
* Returns [year, month (0-based), day] or null on failure.
*/
function extractLocalDateParts(
iso: string,
): [number, number, number] | null {
const m = ISO_DATE_RE.exec(iso);
if (!m || !m[1] || !m[2] || !m[3]) return null;
return [parseInt(m[1], 10), parseInt(m[2], 10) - 1, parseInt(m[3], 10)];
}
/**
* Compute the day-change badge value per TZ §4.1.17.
*
* @param timeLocalIso The local time string from the API (may have TZ offset
* suffix — interpreted as the *airport-local* wall-clock).
* @param queryDateYyyymmdd The user's requested search date ("YYYY-MM-DD"),
* which is the baseline "A" in TZ §4.1.17.
* @returns Integer day offset (B - A). 0 means same day → no badge shown.
* Positive = later date, negative = earlier date.
*/
export function computeDayChange(
timeLocalIso: string | null | undefined,
queryDateYyyymmdd: string,
): number {
if (!timeLocalIso) return 0;
const timeParts = extractLocalDateParts(timeLocalIso);
const queryParts = extractLocalDateParts(queryDateYyyymmdd);
if (!timeParts || !queryParts) return 0;
// Compare calendar dates as UTC midnight to avoid DST edge cases.
const timeMs = Date.UTC(timeParts[0], timeParts[1], timeParts[2]);
const queryMs = Date.UTC(queryParts[0], queryParts[1], queryParts[2]);
if (Number.isNaN(timeMs) || Number.isNaN(queryMs)) return 0;
return Math.round((timeMs - queryMs) / (24 * 60 * 60 * 1000));
}
/**
* Build the tooltip text for a day-change badge per TZ §4.1.17 rules 56:
* - ±1 → "День" (Russian: «День»)
* - ±2 and up → the relevant date in DD.MM.YYYY format, computed from the
* base ISO string's local date shifted by `dayChange` days.
*
* Uses string-based date extraction (no TZ reprojection) for correctness
* on SSR and across viewer time zones.
*
* @param baseLocalIso The time string whose local calendar date is the base.
* @param dayChange The badge value (non-zero; caller must guard against 0).
*/
export function dayChangeBadgeTooltip(
baseLocalIso: string,
dayChange: number,
): string {
const abs = Math.abs(dayChange);
if (abs === 0) return "";
if (abs === 1) return "День";
// Shift the local date by dayChange days using UTC arithmetic.
const parts = extractLocalDateParts(baseLocalIso);
if (!parts) return "";
const shiftedMs = Date.UTC(parts[0], parts[1], parts[2] + dayChange);
const shifted = new Date(shiftedMs);
const dd = String(shifted.getUTCDate()).padStart(2, "0");
const mm = String(shifted.getUTCMonth() + 1).padStart(2, "0");
const yyyy = shifted.getUTCFullYear();
return `${dd}.${mm}.${yyyy}`;
}
/**
* Format a day-change numeric value as a badge label string.
* Returns "" for 0, "+1" for 1, "-1" for -1, etc.
*/
export function formatDayChangeBadge(value: number): string {
if (value === 0) return "";
return value > 0 ? `+${value}` : String(value);
}
+69 -4
View File
@@ -8,6 +8,10 @@ import {
formatLocalTime,
formatUtcOffset,
} from "@/shared/utils/datetime/index.js";
import {
dayChangeBadgeTooltip,
formatDayChangeBadge,
} from "@/features/online-board/dayChange.js";
import { StationDisplay } from "./StationDisplay.js";
import { TimeGroup } from "./TimeGroup.js";
import { FlightStatus } from "./FlightStatus.js";
@@ -279,12 +283,14 @@ export const FlightCard: FC<FlightCardProps> = ({
</div>
<div className="flight-card__time">
{/* TZ §4.1.17-R4: each time type gets its own independent badge */}
<TimeGroup
scheduled={depTimes.scheduledDeparture.local}
actual={depTimes.actualBlockOff?.local}
dayChange={
scheduledDayChange={depTimes.scheduledDeparture.dayChange?.value}
actualDayChange={
depTimes.actualBlockOff?.dayChange.value ??
depTimes.scheduledDeparture.dayChange?.value
depTimes.estimatedBlockOff?.dayChange.value
}
/>
</div>
@@ -313,12 +319,14 @@ export const FlightCard: FC<FlightCardProps> = ({
)}
<div className="flight-card__time flight-card__time--arrival">
{/* TZ §4.1.17-R4: each time type gets its own independent badge */}
<TimeGroup
scheduled={arrTimes.scheduledArrival.local}
actual={arrTimes.actualBlockOn?.local}
dayChange={
scheduledDayChange={arrTimes.scheduledArrival.dayChange?.value}
actualDayChange={
arrTimes.actualBlockOn?.dayChange.value ??
arrTimes.scheduledArrival.dayChange?.value
arrTimes.estimatedBlockOn?.dayChange.value
}
/>
</div>
@@ -362,6 +370,7 @@ export const FlightCard: FC<FlightCardProps> = ({
flightNumber={flight.flightId.flightNumber}
variant="small"
/>
{/* TZ §4.1.17-R7: day-change badges in expanded row (per-time-type) */}
<div className="flight-card__detail-row">
<div className="flight-card__detail-label">{t("SHARED.TIME")}</div>
<div className="flight-card__detail-group">
@@ -370,6 +379,21 @@ export const FlightCard: FC<FlightCardProps> = ({
{t("SHARED.SCHEDULED")}
</span>
<span className="flight-card__detail-value">{depScheduled}</span>
{(() => {
const dc = depTimes.scheduledDeparture.dayChange?.value ?? 0;
const badge = formatDayChangeBadge(dc);
return badge ? (
<span
className="time-group__day-change"
title={dayChangeBadgeTooltip(
depTimes.scheduledDeparture.local,
dc,
)}
>
{badge}
</span>
) : null;
})()}
</div>
{depLatest && (
<div>
@@ -377,6 +401,19 @@ export const FlightCard: FC<FlightCardProps> = ({
{t(depLatestCaptionKey)}
</span>
<span className="flight-card__detail-value">{depLatest}</span>
{(() => {
const dc =
depLatestTimes?.dayChange?.value ?? 0;
const badge = formatDayChangeBadge(dc);
return badge && depLatestTimes ? (
<span
className="time-group__day-change"
title={dayChangeBadgeTooltip(depLatestTimes.local, dc)}
>
{badge}
</span>
) : null;
})()}
</div>
)}
</div>
@@ -386,6 +423,21 @@ export const FlightCard: FC<FlightCardProps> = ({
{t("SHARED.SCHEDULED")}
</span>
<span className="flight-card__detail-value">{arrScheduled}</span>
{(() => {
const dc = arrTimes.scheduledArrival.dayChange?.value ?? 0;
const badge = formatDayChangeBadge(dc);
return badge ? (
<span
className="time-group__day-change"
title={dayChangeBadgeTooltip(
arrTimes.scheduledArrival.local,
dc,
)}
>
{badge}
</span>
) : null;
})()}
</div>
{arrLatest && (
<div>
@@ -393,6 +445,19 @@ export const FlightCard: FC<FlightCardProps> = ({
{t(arrLatestCaptionKey)}
</span>
<span className="flight-card__detail-value">{arrLatest}</span>
{(() => {
const dc =
arrLatestTimes?.dayChange?.value ?? 0;
const badge = formatDayChangeBadge(dc);
return badge && arrLatestTimes ? (
<span
className="time-group__day-change"
title={dayChangeBadgeTooltip(arrLatestTimes.local, dc)}
>
{badge}
</span>
) : null;
})()}
</div>
)}
</div>
+80 -39
View File
@@ -1,5 +1,9 @@
import type { FC } from "react";
import { formatLocalTime } from "@/shared/utils/datetime/index.js";
import {
dayChangeBadgeTooltip,
formatDayChangeBadge,
} from "@/features/online-board/dayChange.js";
import "./TimeGroup.scss";
export interface TimeGroupProps {
@@ -7,77 +11,114 @@ export interface TimeGroupProps {
scheduled: string;
/** Actual time (ISO 8601 string), if available */
actual?: string | undefined;
/** Day change offset (e.g. +1, -1) */
/**
* Day-change offset for the *scheduled* time (e.g. +1, -1).
* Per TZ §4.1.17-R4 each time type gets its own independent badge.
* When `actualDayChange` is not provided this value is also used for
* the actual time (legacy single-badge behaviour).
*
* @deprecated Prefer explicit `scheduledDayChange` + `actualDayChange`.
*/
dayChange?: number | undefined;
/**
* Day-change offset for the scheduled time only (TZ §4.1.17-R4).
* Takes precedence over the legacy `dayChange` prop.
*/
scheduledDayChange?: number | undefined;
/**
* Day-change offset for the actual/estimated time only (TZ §4.1.17-R4).
* When absent the component falls back to `dayChange` if no actual exists.
*/
actualDayChange?: number | undefined;
/** Label for the time group, e.g. "Departure" */
label?: string;
}
/**
* Build the tooltip text for a day-change chip per TZ §4.1.4:
* - ±1 → "день" (locale-invariant for now; Angular uses "день" in RU)
* - ±2+ → the actual date (DD.MM.YYYY) computed from the base timestamp
* shifted by `dayChange` days.
*/
function dayChangeTooltip(scheduled: string, dayChange: number): string {
const abs = Math.abs(dayChange);
if (abs === 1) return "день";
// Parse the wall-clock date from the offset-aware ISO string
// (e.g. "2026-04-15T23:30:00+03:00") and shift by dayChange.
try {
const base = new Date(scheduled);
base.setDate(base.getDate() + dayChange);
const dd = String(base.getDate()).padStart(2, "0");
const mm = String(base.getMonth() + 1).padStart(2, "0");
const yyyy = base.getFullYear();
return `${dd}.${mm}.${yyyy}`;
} catch {
return "";
}
}
/**
* Displays scheduled + actual times with a day-change indicator.
* Displays scheduled + actual times with per-type day-change indicators.
*
* Per TZ §4.1.17-R4 each time type (scheduled / expected / actual) gets
* its own independent badge so that e.g. a flight scheduled for 23:50 (+0)
* but actually arriving at 00:30 (+1) shows no badge on the scheduled time
* and +1 on the actual time.
*
* If actual differs from scheduled, scheduled is shown with strikethrough
* and actual is shown in bold.
*
* Tooltips per TZ §4.1.17-R5:
* - ±1 → "День"
* - ±2+ → "DD.MM.YYYY" (the shifted date)
*/
export const TimeGroup: FC<TimeGroupProps> = ({
scheduled,
actual,
dayChange,
scheduledDayChange: scheduledDayChangeProp,
actualDayChange: actualDayChangeProp,
label,
}) => {
// formatLocalTime reads the wall-clock from the offset-aware ISO
// string, so a flight arriving at 06:30 in Almaty (GMT+5) reads
// 06:30 regardless of the viewer's timezone. formatTime would
// reproject through new Date() and show '04:30' in Moscow.
const scheduledTime = formatLocalTime(scheduled);
const actualTime = actual ? formatLocalTime(actual) : undefined;
const hasDelay = actualTime !== undefined && actualTime !== scheduledTime;
// Resolve per-type day-change values.
// scheduledDayChange takes precedence over legacy `dayChange`.
const schedDC =
scheduledDayChangeProp !== undefined ? scheduledDayChangeProp : dayChange;
// actualDayChange takes precedence; fall back to dayChange only when no
// separate actual badge is provided.
const actDC =
actualDayChangeProp !== undefined
? actualDayChangeProp
: hasDelay
? dayChange // legacy: single shared badge
: undefined;
const schedBadge = formatDayChangeBadge(schedDC ?? 0);
const actBadge = formatDayChangeBadge(actDC ?? 0);
return (
<div className="time-group">
{label ? <span className="time-group__label">{label}</span> : null}
<div className="time-group__times">
{hasDelay ? (
<>
<span className="time-group__actual">{actualTime}</span>
<span className="time-group__actual">
{actualTime}
{actBadge ? (
<span
className="time-group__day-change"
title={dayChangeBadgeTooltip(actual!, actDC!)}
>
{actBadge}
</span>
) : null}
</span>
<span className="time-group__scheduled time-group__scheduled--delayed">
{scheduledTime}
{schedBadge ? (
<span
className="time-group__day-change time-group__day-change--scheduled"
title={dayChangeBadgeTooltip(scheduled, schedDC!)}
>
{schedBadge}
</span>
) : null}
</span>
</>
) : (
<span className="time-group__scheduled">{scheduledTime}</span>
)}
{dayChange !== undefined && dayChange !== 0 ? (
<span
className="time-group__day-change"
title={dayChangeTooltip(scheduled, dayChange)}
>
{dayChange > 0 ? `+${dayChange}` : dayChange}
<span className="time-group__scheduled">
{scheduledTime}
{schedBadge ? (
<span
className="time-group__day-change"
title={dayChangeBadgeTooltip(scheduled, schedDC!)}
>
{schedBadge}
</span>
) : null}
</span>
) : null}
)}
</div>
</div>
);