/** * Elapsed-duration formatting. * * Ported from openlastcode `src/time/` (which itself mirrors * opencode-quota-finer's countdown formatting, inverted for elapsed time). * Unit selection and decimal logic are kept identical so output feels * consistent across the three plugins. * * Pure: given (elapsedMs, style, decimals) the same string always comes out. */ /** How an elapsed duration is rendered as text. */ export type FormatStyle = "compact" | "verbose" | "precise" | "clock"; export const FORMAT_STYLES: readonly FormatStyle[] = ["compact", "verbose", "precise", "clock"]; export const DEFAULT_FORMAT_STYLE: FormatStyle = "precise"; /** Upper bound for the `decimals` setting (precise style). */ export const MAX_DECIMALS = 4; export const DEFAULT_DECIMALS = 1; /** Clamp a decimals value into the accepted [0, MAX_DECIMALS] range. */ export function clampDecimals(n: number): number { if (!Number.isFinite(n)) return DEFAULT_DECIMALS; return Math.max(0, Math.min(MAX_DECIMALS, Math.trunc(n))); } const MS_PER_SECOND = 1_000; const MS_PER_MINUTE = 60 * MS_PER_SECOND; const MS_PER_HOUR = 60 * MS_PER_MINUTE; const MS_PER_DAY = 24 * MS_PER_HOUR; function pad2(n: number): string { return String(Math.trunc(n)).padStart(2, "0"); } /** * Compact style: a single largest unit, rounded down. * * - 0ms -> "0s" * - 45_000 -> "45s" * - 5m 30s -> "5m" * - 2h 14m -> "2h" * - 1d 3h -> "1d" */ export function formatCompact(elapsedMs: number): string { const ms = Math.max(0, Math.trunc(elapsedMs)); if (ms < MS_PER_MINUTE) return `${Math.floor(ms / MS_PER_SECOND)}s`; if (ms < MS_PER_HOUR) return `${Math.floor(ms / MS_PER_MINUTE)}m`; if (ms < MS_PER_DAY) return `${Math.floor(ms / MS_PER_HOUR)}h`; return `${Math.floor(ms / MS_PER_DAY)}d`; } /** * Verbose style: the two largest non-zero units. * * - 5m 30s -> "5m 30s" * - 2h 14m -> "2h 14m" * - 45s -> "45s" */ export function formatVerbose(elapsedMs: number): string { const ms = Math.max(0, Math.trunc(elapsedMs)); const days = Math.floor(ms / MS_PER_DAY); const hours = Math.floor((ms % MS_PER_DAY) / MS_PER_HOUR); const minutes = Math.floor((ms % MS_PER_HOUR) / MS_PER_MINUTE); const seconds = Math.floor((ms % MS_PER_MINUTE) / MS_PER_SECOND); const parts: string[] = []; if (days > 0) parts.push(`${days}d`); if (hours > 0) parts.push(`${hours}h`); if (minutes > 0) parts.push(`${minutes}m`); if (seconds > 0 || parts.length === 0) parts.push(`${seconds}s`); return parts.slice(0, 2).join(" "); } /** * Precise style: the single largest unit as a fraction with `decimals` * decimal places. * * - (5m 24s, 1) -> "5.4m" * - (1h 18m, 2) -> "1.30h" * - (12_300, 1) -> "12.3s" */ export function formatPrecise(elapsedMs: number, decimals = DEFAULT_DECIMALS): string { const ms = Math.max(0, Math.trunc(elapsedMs)); const d = clampDecimals(decimals); let unit: string; let divisor: number; if (ms < MS_PER_MINUTE) { unit = "s"; divisor = MS_PER_SECOND; } else if (ms < MS_PER_HOUR) { unit = "m"; divisor = MS_PER_MINUTE; } else if (ms < MS_PER_DAY) { unit = "h"; divisor = MS_PER_HOUR; } else { unit = "d"; divisor = MS_PER_DAY; } return `${(ms / divisor).toFixed(d)}${unit}`; } /** * Clock style: wall-clock time of the activity timestamp. * * `HH:MM` when the activity happened today (local), otherwise `HH:MM DD/MM` * so a stale timestamp stays unambiguous. */ export function formatClock(atMs: number, nowMs: number = Date.now()): string { const at = new Date(atMs); const now = new Date(nowMs); const sameDay = at.getFullYear() === now.getFullYear() && at.getMonth() === now.getMonth() && at.getDate() === now.getDate(); const time = `${pad2(at.getHours())}:${pad2(at.getMinutes())}`; if (sameDay) return time; return `${time} ${pad2(at.getDate())}/${pad2(at.getMonth() + 1)}`; } /** * Render an elapsed duration according to a {@link FormatStyle}. * * For `clock`, pass the activity timestamp (`activityAtMs`) and a `nowMs`; * `elapsedMs` is ignored for that style. */ export function formatElapsed( elapsedMs: number, style: FormatStyle, options: { decimals?: number; activityAtMs?: number; nowMs?: number } = {}, ): string { switch (style) { case "compact": return formatCompact(elapsedMs); case "verbose": return formatVerbose(elapsedMs); case "precise": return formatPrecise(elapsedMs, options.decimals); case "clock": return formatClock(options.activityAtMs ?? Date.now() - elapsedMs, options.nowMs); } }