/** * Formatação pura (sem dependências pi): datas em BRT, barras de progresso, * números e custos. Todas as funções são determinísticas dado (valor, nowMs). */ export const BRT_LABEL = "BRT"; /** Offset em ms do timezone informado num instante dado (via Intl). */ export function timeZoneOffsetMs(timeZone: string, date: Date): number { const dtf = new Intl.DateTimeFormat("en-US", { timeZone, year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit", hourCycle: "h23", }); const parts: Record = {}; for (const p of dtf.formatToParts(date)) parts[p.type] = p.value; const asUtc = Date.UTC( Number(parts.year), Number(parts.month) - 1, Number(parts.day), Number(parts.hour), Number(parts.minute), Number(parts.second), ); return asUtc - date.getTime(); } /** Componentes da data local no timezone para um instante (ms epoch UTC). */ export function zonedDateParts(ms: number, timeZone: string): { y: number; m: number; d: number } { const dtf = new Intl.DateTimeFormat("en-US", { timeZone, year: "numeric", month: "2-digit", day: "2-digit", }); const parts: Record = {}; for (const p of dtf.formatToParts(new Date(ms))) parts[p.type] = p.value; return { y: Number(parts.year), m: Number(parts.month), d: Number(parts.day) }; } /** Início do dia local (00:00 no timezone) em ms epoch UTC. */ export function localMidnightMs(ms: number, timeZone: string): number { const { y, m, d } = zonedDateParts(ms, timeZone); const guess = Date.UTC(y, m - 1, d); // Ajusta pelo offset vigente perto da meia-noite local. return guess - timeZoneOffsetMs(timeZone, new Date(guess)); } /** Formata instante como dd/MM/yyyy HH:mm(:ss) no timezone. */ export function formatZoned(ms: number, timeZone: string, withSeconds = false): string { const dtf = new Intl.DateTimeFormat("pt-BR", { timeZone, day: "2-digit", month: "2-digit", year: "numeric", hour: "2-digit", minute: "2-digit", ...(withSeconds ? { second: "2-digit" as const } : {}), hourCycle: "h23", }); return dtf.format(new Date(ms)).replace(",", ""); } /** Parse de ISO 8601 (ou ms epoch numérico) para ms; null se inválido. */ export function parseInstantMs(value: unknown): number | null { if (typeof value === "number" && Number.isFinite(value)) return value; if (typeof value === "string" && value.trim()) { const ms = Date.parse(value); if (Number.isFinite(ms)) return ms; } return null; } /** "em 2d 3h" / "em 5h 12min" / "em 40min" / "já resetou" relativo a nowMs. */ export function formatRelative(targetMs: number, nowMs: number): string { const delta = targetMs - nowMs; if (delta < 0) return "já resetou"; const minutes = Math.floor(delta / 60_000); if (minutes < 60) return `em ${minutes}min`; const hours = Math.floor(minutes / 60); if (hours < 24) return `em ${hours}h ${minutes % 60}min`; const days = Math.floor(hours / 24); return `em ${days}d ${hours % 24}h`; } /** Data + relativo, ex.: "28/07/2026 14:30 (em 2h 10min)". */ export function formatResetTime(value: unknown, timeZone: string, nowMs: number): string { const ms = parseInstantMs(value); if (ms === null) return "N/A"; return `${formatZoned(ms, timeZone)} (${formatRelative(ms, nowMs)})`; } /** Barra de progresso ASCII; fração 0..1. Sem limite (0) → "[sem limite]". */ export function progressBar(used: number, limit: number, width = 30): string { if (!Number.isFinite(limit) || limit <= 0) return "[sem limite]"; const pct = Math.min(100, Math.max(0, (used / limit) * 100)); const filled = Math.round((pct / 100) * width); const bar = "█".repeat(filled) + "░".repeat(Math.max(0, width - filled)); const icon = pct >= 90 ? "🔴" : pct >= 70 ? "🟡" : "🟢"; return `${icon} [${bar}] ${pct.toFixed(1)}% (${formatInt(used)}/${formatInt(limit)})`; } export function formatInt(value: number): string { if (!Number.isFinite(value)) return "0"; return Math.round(value).toLocaleString("en-US"); } export function formatCompact(value: number): string { if (!Number.isFinite(value)) return "0"; const abs = Math.abs(value); if (abs >= 1_000_000) return `${(value / 1_000_000).toFixed(2)}M`; if (abs >= 1_000) return `${(value / 1_000).toFixed(1)}k`; return String(Math.round(value)); } export function formatCost(value: number): string { if (!Number.isFinite(value)) return "$0.00"; if (value !== 0 && Math.abs(value) < 0.01) return `$${value.toFixed(4)}`; return `$${value.toFixed(2)}`; } /** * Duração de janela em unidade legível: minutes divisíveis por 60 → "5h"; * hours divisíveis por 24 → "2d"; senão mantém (ex.: "45min"). Sem duração * válida, cai no fallback `${duration} ${unit}`. */ export function formatWindowDuration(duration: number, unit: string): string { if (!Number.isFinite(duration) || duration <= 0) return `${duration} ${unit}`; if (unit === "minute") { return duration % 60 === 0 ? `${duration / 60}h` : `${duration}min`; } if (unit === "hour") { return duration % 24 === 0 ? `${duration / 24}d` : `${duration}h`; } if (unit === "day") return `${duration}d`; return `${duration} ${unit}`; } /** Moldura caramelo (#C68E3F) via ANSI truecolor; reset só do foreground. */ export const CARAMEL_RGB = "198;142;63"; export function caramel(text: string): string { if (!text) return ""; return `\x1b[38;2;${CARAMEL_RGB}m${text}\x1b[39m`; } /** Coerce seguro de campos numéricos que podem vir como string. */ export function toNumber(value: unknown): number { if (typeof value === "number" && Number.isFinite(value)) return value; if (typeof value === "string" && value.trim() !== "") { const n = Number(value); if (Number.isFinite(n)) return n; } return 0; }