/** * 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)); }