lib/duration.ts
1.7 KB · sha256:f38434136436fed1ca634b19f0f19ab3eb799d5e808de29a4a65e4a421928afb
const UNIT_MS: Record<string, number> = {
y: 365 * 24 * 60 * 60 * 1000,
w: 7 * 24 * 60 * 60 * 1000,
d: 24 * 60 * 60 * 1000,
h: 60 * 60 * 1000,
m: 60 * 1000,
s: 1000,
};
export function parseDuration(input: string | undefined): number | null {
if (input == null) return null;
const s = String(input).trim().toLowerCase();
if (s === "" || s === "perm" || s === "permanent" || s === "never") {
return null;
}
const matches = s.matchAll(/(\d+)\s*([ywdhms])/g);
let total = 0;
let any = false;
let consumed = 0;
for (const m of matches) {
any = true;
total += Number(m[1]) * UNIT_MS[m[2]];
consumed += m[0].length;
}
const stripped = s.replace(/\s+/g, "");
if (!any || consumed !== stripped.length) return Number.NaN;
return total;
}
export function formatDuration(ms: number): string {
if (!Number.isFinite(ms) || ms <= 0) return "0s";
const units: [string, number][] = [
["y", UNIT_MS.y],
["w", UNIT_MS.w],
["d", UNIT_MS.d],
["h", UNIT_MS.h],
["m", UNIT_MS.m],
["s", UNIT_MS.s],
];
let remaining = ms;
const parts: string[] = [];
for (const [tag, unit] of units) {
const n = Math.floor(remaining / unit);
if (n > 0) {
parts.push(`${n}${tag}`);
remaining -= n * unit;
}
if (parts.length >= 2) break; // 2 components is plenty for display
}
return parts.length > 0 ? parts.join("") : "<1s";
}
/** Render an absolute unix-ms expiry as "in 7d" or "expired 3h ago". */
export function describeExpiry(expiryMs: number, now = Date.now()): string {
const delta = expiryMs - now;
if (delta <= 0) return `expired ${formatDuration(-delta)} ago`;
return `in ${formatDuration(delta)}`;
}