import i18n from "../i18n"; export function relativeTime(iso: string | null): string { if (!iso) return ""; return relativeFromMs(new Date(iso).getTime()); } /** Human file size, binary units (1024). e.g. 501747 -> "490 KB", 5368709120 -> "5.0 GB". */ export function formatBytes(bytes: number | null | undefined): string { const n = Number(bytes); if (!n || n < 0) return "0 B"; const units = ["B", "KB", "MB", "GB", "TB"]; let i = 0; let v = n; while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; } return `${v >= 100 || i === 0 ? Math.round(v) : v.toFixed(1)} ${units[i]}`; } /** Download speed from bytes/sec, e.g. "2.4 MB/s". */ export function formatSpeed(bps: number | null | undefined): string { if (!bps) return ""; return `${formatBytes(bps)}/s`; } /** Relative-time label from an epoch-millis timestamp (the ms-based half of relativeTime, for * client-side notifications whose timestamps are already numbers). One implementation, one * set of `time.*` strings — shared instead of re-implemented per component. */ export function relativeFromMs(ms: number): string { const secs = Math.max(0, (Date.now() - ms) / 1000); const units: [number, string][] = [ [60, "time.secondsAgo"], [3600, "time.minutesAgo"], [86400, "time.hoursAgo"], [604800, "time.daysAgo"], [2592000, "time.weeksAgo"], [31536000, "time.monthsAgo"], ]; if (secs < 60) return i18n.t("time.justNow"); for (let i = 0; i < units.length - 1; i++) { const [, key] = units[i]; const next = units[i + 1][0]; if (secs < next) { const v = Math.floor(secs / units[i][0]); return i18n.t(key, { count: v }); } } const years = Math.floor(secs / 31536000); if (years >= 1) return i18n.t("time.yearsAgo", { count: years }); const months = Math.floor(secs / 2592000); return i18n.t("time.monthsAgo", { count: months }); } // The canonical YouTube URL for a channel: its @handle when known (nicer), else the // /channel/ form. One place so every channel link across the app stays consistent. export function channelYouTubeUrl(id: string, handle?: string | null): string { return handle ? `https://www.youtube.com/@${handle.replace(/^@/, "")}` : `https://www.youtube.com/channel/${id}`; } export function formatDuration(sec: number | null): string { if (sec == null) return ""; const h = Math.floor(sec / 3600); const m = Math.floor((sec % 3600) / 60); const s = Math.floor(sec % 60); const pad = (n: number) => String(n).padStart(2, "0"); return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`; } // Human label for a canonical quota-action key (see backend app.quota.QuotaAction). Resolved // from i18n (quotaActions.) so it's translated in all UI languages; falls back to the raw // key for any unmapped/legacy value. export function quotaActionLabel(action: string): string { return i18n.t(`quotaActions.${action}`, { defaultValue: action }); } /** Coarse, human ETA for a remaining duration in seconds ("~3 hours", "~2 days"). Translated * via the `time.eta.*` keys (was hardcoded English, violating the trilingual rule). */ export function formatEta(seconds: number): string { if (seconds <= 0) return i18n.t("time.eta.done"); const hours = seconds / 3600; if (hours < 1) return i18n.t("time.eta.lessThanHour"); if (hours < 24) return i18n.t("time.eta.hours", { count: Math.round(hours) }); return i18n.t("time.eta.days", { count: Math.round(hours / 24) }); } /** Compact whole-channel total length ("12 h", "45 m"), hours-scale so H:MM:SS isn't huge. */ export function formatTotalHours(sec: number): string { if (!sec) return "—"; const h = Math.round(sec / 3600); return h >= 1 ? `${h.toLocaleString()} h` : `${Math.max(1, Math.round(sec / 60))} m`; } export function formatViews(n: number | null): string { if (n == null) return ""; if (n >= 1e9) return `${(n / 1e9).toFixed(1).replace(/\.0$/, "")}B`; if (n >= 1e6) return `${(n / 1e6).toFixed(1).replace(/\.0$/, "")}M`; if (n >= 1e3) return `${(n / 1e3).toFixed(1).replace(/\.0$/, "")}K`; return String(n); } // A count for a right-aligned table cell: the humanized number, or an em-dash when unknown (null). export function formatCountOrDash(n: number | null | undefined): string { return n != null ? formatViews(n) : "—"; }