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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user