706b8f444b
- BuyTicketButton / FlightsMiniListItem: narrow firstLeg/lastLeg with
explicit null guards (throw / return '').
- FlightSchedule.tsx: `match?.[1] ?? iso` for the regex capture.
- OnlineBoardSearchPage + schedule/api: `split('T')[0] ?? iso` for the
date-prefix extraction.
- ServicesPanel: icon lookup uses a third '' fallback instead of `!`.
- buildCountryCityRows: explicit `break` if cities[i] is undefined.
- useAppSettings: `match?.[1]` null-check before parseInt.
- datetime/index.ts: guard bare HH:MM capture groups together.
- ScheduleDetailsCatchAllRoute: drop unused `t` + useTranslation import.
- ScheduleDetailsPage.tsx: prefix unused `getLegs` with underscore.
- 4 seo/json-ld tests: drop now-redundant eslint-disable comments.
- calendarRange.test + api.test: prefix unused helper names with `_`.
Warning count: 19 → 0. make check (typecheck + lint + test) exits 0.
149 lines
5.1 KiB
TypeScript
149 lines
5.1 KiB
TypeScript
/**
|
||
* Datetime utility functions.
|
||
*
|
||
* Pure functions ported from Angular pipes (DurationPipe, DatePipe).
|
||
* No Angular dependencies, no side effects.
|
||
*/
|
||
|
||
/** Match `ru`, `ru-RU`, `ru-ru`, `RU` — anything starting with `ru` */
|
||
function isRussianLocale(locale: string): boolean {
|
||
return locale.toLowerCase().startsWith("ru");
|
||
}
|
||
|
||
/**
|
||
* Format a duration given in total minutes into a human-readable string.
|
||
* Russian units mirror Angular's DurationPipe (SHORT-DAY='д.', SHORT-HOUR='ч.',
|
||
* SHORT-MIN='мин.') so values read as '1ч. 30мин.' not '1ч 30м'.
|
||
*
|
||
* Accepts either a short language code (`"ru"`) or a full BCP-47 locale
|
||
* (`"ru-ru"`).
|
||
*
|
||
* @example formatDuration(150) => "2h 30m"
|
||
* @example formatDuration(150, "ru") => "2ч. 30мин."
|
||
* @example formatDuration(150, "ru-ru") => "2ч. 30мин."
|
||
* @example formatDuration(0) => "0h 0m"
|
||
*/
|
||
export function formatDuration(
|
||
minutes: number,
|
||
locale: string = "en",
|
||
): string {
|
||
const ru = isRussianLocale(locale);
|
||
if (minutes < 0) return ru ? "Неизвестно" : "Unknown";
|
||
|
||
const days = Math.floor(minutes / (60 * 24));
|
||
const hours = Math.floor((minutes % (60 * 24)) / 60);
|
||
const mins = Math.floor(minutes % 60);
|
||
|
||
const units = ru
|
||
? { d: "д.", h: "ч.", m: "мин." }
|
||
: { d: "d", h: "h", m: "m" };
|
||
|
||
const daysPart = days > 0 ? `${days}${units.d} ` : "";
|
||
return `${daysPart}${hours}${units.h} ${mins}${units.m}`;
|
||
}
|
||
|
||
/**
|
||
* Format a date/ISO string into "HH:mm" time.
|
||
*
|
||
* @example formatTime("2025-01-15T10:30:00") => "10:30"
|
||
* @example formatTime(new Date(2025, 0, 15, 10, 30)) => "10:30"
|
||
*/
|
||
export function formatTime(date: string | Date): string {
|
||
const d = typeof date === "string" ? new Date(date) : date;
|
||
if (Number.isNaN(d.getTime())) return "";
|
||
|
||
const hours = String(d.getHours()).padStart(2, "0");
|
||
const minutes = String(d.getMinutes()).padStart(2, "0");
|
||
return `${hours}:${minutes}`;
|
||
}
|
||
|
||
/**
|
||
* Format a date into a localized date string.
|
||
*
|
||
* @example formatDate("2025-01-15", "ru") => "15 января 2025 г."
|
||
* @example formatDate("2025-01-15", "en") => "January 15, 2025"
|
||
*/
|
||
export function formatDate(
|
||
date: string | Date,
|
||
locale: string = "en",
|
||
): string {
|
||
const d = typeof date === "string" ? new Date(date) : date;
|
||
if (Number.isNaN(d.getTime())) return "";
|
||
|
||
return d.toLocaleDateString(isRussianLocale(locale) ? "ru-RU" : "en-US", {
|
||
year: "numeric",
|
||
month: "long",
|
||
day: "numeric",
|
||
});
|
||
}
|
||
|
||
const ISO_OFFSET_RE = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::\d{2})?(Z|[+-]\d{2}:?\d{2})?$/;
|
||
|
||
const BARE_HH_MM_RE = /^(\d{1,2}):(\d{2})(?::\d{2})?$/;
|
||
|
||
/**
|
||
* Return the HH:mm portion of an ISO string without reprojecting it to the
|
||
* browser's local timezone. The API sends strings like
|
||
* "2026-04-17T23:30:00+03:00"; `new Date(...)` would shift it to whatever
|
||
* locale the SSR/client runs in. We want the wall-clock value that was in
|
||
* the string. Plain "HH:mm" / "HH:mm:ss" inputs pass through unchanged.
|
||
*/
|
||
export function formatLocalTime(iso: string): string {
|
||
const full = ISO_OFFSET_RE.exec(iso);
|
||
if (full) return `${full[4]}:${full[5]}`;
|
||
const bare = BARE_HH_MM_RE.exec(iso);
|
||
if (bare?.[1] && bare[2]) return `${bare[1].padStart(2, "0")}:${bare[2]}`;
|
||
return formatTime(iso); // fall back to Date-based formatter
|
||
}
|
||
|
||
/**
|
||
* Extract the UTC offset (e.g. "+03:00", "-05:00", "UTC+00:00") from an
|
||
* ISO string. Returns "UTC±HH:mm" or "" if the string has no offset.
|
||
*/
|
||
export function formatUtcOffset(iso: string): string {
|
||
const m = ISO_OFFSET_RE.exec(iso);
|
||
const off = m?.[6];
|
||
if (!off) return "";
|
||
// Angular's captioned-time-group renders 'UTC {{ utc }}' — keep the
|
||
// non-breaking space so '15:30 UTC +03:00' reads the same across pages.
|
||
if (off === "Z") return "UTC\u00A0+00:00";
|
||
const normalized = off.includes(":") ? off : `${off.slice(0, 3)}:${off.slice(3)}`;
|
||
return `UTC\u00A0${normalized}`;
|
||
}
|
||
|
||
/**
|
||
* "DD.MM.YYYY" — matches the Angular board footer style.
|
||
*/
|
||
export function formatDayMonthYear(iso: string): string {
|
||
const m = ISO_OFFSET_RE.exec(iso);
|
||
if (m) return `${m[3]}.${m[2]}.${m[1]}`;
|
||
const d = new Date(iso);
|
||
if (Number.isNaN(d.getTime())) return "";
|
||
const day = String(d.getDate()).padStart(2, "0");
|
||
const month = String(d.getMonth() + 1).padStart(2, "0");
|
||
return `${day}.${month}.${d.getFullYear()}`;
|
||
}
|
||
|
||
/**
|
||
* Calculate the day offset between two dates (ignoring time).
|
||
* Returns 0 if same day, +1 if actual is the next day, etc.
|
||
*
|
||
* @example isDayChange("2025-01-15T23:00", "2025-01-16T01:00") => 1
|
||
*/
|
||
export function isDayChange(
|
||
scheduledDate: string | Date,
|
||
actualDate: string | Date,
|
||
): number {
|
||
const s = typeof scheduledDate === "string" ? new Date(scheduledDate) : scheduledDate;
|
||
const a = typeof actualDate === "string" ? new Date(actualDate) : actualDate;
|
||
|
||
if (Number.isNaN(s.getTime()) || Number.isNaN(a.getTime())) return 0;
|
||
|
||
// Compare calendar dates (ignoring time)
|
||
const sDay = new Date(s.getFullYear(), s.getMonth(), s.getDate());
|
||
const aDay = new Date(a.getFullYear(), a.getMonth(), a.getDate());
|
||
|
||
const diffMs = aDay.getTime() - sDay.getTime();
|
||
return Math.round(diffMs / (24 * 60 * 60 * 1000));
|
||
}
|