Centralize date/time formatting utilities (#11831)

This commit is contained in:
max
2026-02-08 04:53:31 -08:00
committed by GitHub
parent 74fbbda283
commit a1123dd9be
77 changed files with 1508 additions and 1075 deletions

View File

@@ -0,0 +1,94 @@
/**
* Centralized date/time formatting utilities.
*
* All formatters are timezone-aware, using Intl.DateTimeFormat.
* Consolidates duplicated formatUtcTimestamp / formatZonedTimestamp / resolveExplicitTimezone
* that previously lived in envelope.ts and session-updates.ts.
*/
/**
* Validate an IANA timezone string. Returns the string if valid, undefined otherwise.
*/
export function resolveTimezone(value: string): string | undefined {
try {
new Intl.DateTimeFormat("en-US", { timeZone: value }).format(new Date());
return value;
} catch {
return undefined;
}
}
export type FormatTimestampOptions = {
/** Include seconds in the output. Default: false */
displaySeconds?: boolean;
};
export type FormatZonedTimestampOptions = FormatTimestampOptions & {
/** IANA timezone string (e.g., 'America/New_York'). Default: system timezone */
timeZone?: string;
};
/**
* Format a Date as a UTC timestamp string.
*
* Without seconds: `2024-01-15T14:30Z`
* With seconds: `2024-01-15T14:30:05Z`
*/
export function formatUtcTimestamp(date: Date, options?: FormatTimestampOptions): string {
const yyyy = String(date.getUTCFullYear()).padStart(4, "0");
const mm = String(date.getUTCMonth() + 1).padStart(2, "0");
const dd = String(date.getUTCDate()).padStart(2, "0");
const hh = String(date.getUTCHours()).padStart(2, "0");
const min = String(date.getUTCMinutes()).padStart(2, "0");
if (!options?.displaySeconds) {
return `${yyyy}-${mm}-${dd}T${hh}:${min}Z`;
}
const sec = String(date.getUTCSeconds()).padStart(2, "0");
return `${yyyy}-${mm}-${dd}T${hh}:${min}:${sec}Z`;
}
/**
* Format a Date with timezone display using Intl.DateTimeFormat.
*
* Without seconds: `2024-01-15 14:30 EST`
* With seconds: `2024-01-15 14:30:05 EST`
*
* Returns undefined if Intl formatting fails.
*/
export function formatZonedTimestamp(
date: Date,
options?: FormatZonedTimestampOptions,
): string | undefined {
const intlOptions: Intl.DateTimeFormatOptions = {
timeZone: options?.timeZone,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hourCycle: "h23",
timeZoneName: "short",
};
if (options?.displaySeconds) {
intlOptions.second = "2-digit";
}
const parts = new Intl.DateTimeFormat("en-US", intlOptions).formatToParts(date);
const pick = (type: string) => parts.find((part) => part.type === type)?.value;
const yyyy = pick("year");
const mm = pick("month");
const dd = pick("day");
const hh = pick("hour");
const min = pick("minute");
const sec = options?.displaySeconds ? pick("second") : undefined;
const tz = [...parts]
.toReversed()
.find((part) => part.type === "timeZoneName")
?.value?.trim();
if (!yyyy || !mm || !dd || !hh || !min) {
return undefined;
}
if (options?.displaySeconds && sec) {
return `${yyyy}-${mm}-${dd} ${hh}:${min}:${sec}${tz ? ` ${tz}` : ""}`;
}
return `${yyyy}-${mm}-${dd} ${hh}:${min}${tz ? ` ${tz}` : ""}`;
}

View File

@@ -0,0 +1,103 @@
export type FormatDurationSecondsOptions = {
decimals?: number;
unit?: "s" | "seconds";
};
export type FormatDurationCompactOptions = {
/** Add space between units: "2m 5s" instead of "2m5s". Default: false */
spaced?: boolean;
};
export function formatDurationSeconds(
ms: number,
options: FormatDurationSecondsOptions = {},
): string {
if (!Number.isFinite(ms)) {
return "unknown";
}
const decimals = options.decimals ?? 1;
const unit = options.unit ?? "s";
const seconds = Math.max(0, ms) / 1000;
const fixed = seconds.toFixed(Math.max(0, decimals));
const trimmed = fixed.replace(/\.0+$/, "").replace(/(\.\d*[1-9])0+$/, "$1");
return unit === "seconds" ? `${trimmed} seconds` : `${trimmed}s`;
}
/** Precise decimal-seconds output: "500ms" or "1.23s". Input is milliseconds. */
export function formatDurationPrecise(
ms: number,
options: FormatDurationSecondsOptions = {},
): string {
if (!Number.isFinite(ms)) {
return "unknown";
}
if (ms < 1000) {
return `${ms}ms`;
}
return formatDurationSeconds(ms, {
decimals: options.decimals ?? 2,
unit: options.unit ?? "s",
});
}
/**
* Compact compound duration: "500ms", "45s", "2m5s", "1h30m".
* With `spaced`: "45s", "2m 5s", "1h 30m".
* Omits trailing zero components: "1m" not "1m 0s", "2h" not "2h 0m".
* Returns undefined for null/undefined/non-finite/non-positive input.
*/
export function formatDurationCompact(
ms?: number | null,
options?: FormatDurationCompactOptions,
): string | undefined {
if (ms == null || !Number.isFinite(ms) || ms <= 0) {
return undefined;
}
if (ms < 1000) {
return `${Math.round(ms)}ms`;
}
const sep = options?.spaced ? " " : "";
const totalSeconds = Math.round(ms / 1000);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
if (hours >= 24) {
const days = Math.floor(hours / 24);
const remainingHours = hours % 24;
return remainingHours > 0 ? `${days}d${sep}${remainingHours}h` : `${days}d`;
}
if (hours > 0) {
return minutes > 0 ? `${hours}h${sep}${minutes}m` : `${hours}h`;
}
if (minutes > 0) {
return seconds > 0 ? `${minutes}m${sep}${seconds}s` : `${minutes}m`;
}
return `${seconds}s`;
}
/**
* Rounded single-unit duration for display: "500ms", "5s", "3m", "2h", "5d".
* Returns fallback string for null/undefined/non-finite input.
*/
export function formatDurationHuman(ms?: number | null, fallback = "n/a"): string {
if (ms == null || !Number.isFinite(ms) || ms < 0) {
return fallback;
}
if (ms < 1000) {
return `${Math.round(ms)}ms`;
}
const sec = Math.round(ms / 1000);
if (sec < 60) {
return `${sec}s`;
}
const min = Math.round(sec / 60);
if (min < 60) {
return `${min}m`;
}
const hr = Math.round(min / 60);
if (hr < 24) {
return `${hr}h`;
}
const day = Math.round(hr / 24);
return `${day}d`;
}

View File

@@ -0,0 +1,112 @@
/**
* Centralized relative-time formatting utilities.
*
* Consolidates 7+ scattered implementations (formatAge, formatAgeShort, formatAgo,
* formatRelativeTime, formatElapsedTime) into two functions:
*
* - `formatTimeAgo(durationMs)` — format a duration as "5m ago" / "5m" (for known elapsed time)
* - `formatRelativeTimestamp(epochMs)` — format an epoch timestamp relative to now (handles future)
*/
export type FormatTimeAgoOptions = {
/** Append "ago" suffix. Default: true. When false, returns bare unit: "5m", "2h" */
suffix?: boolean;
/** Return value for invalid/null/negative input. Default: "unknown" */
fallback?: string;
};
/**
* Format a duration (in ms) as a human-readable relative time.
*
* Input: how many milliseconds ago something happened.
*
* With suffix (default): "just now", "5m ago", "3h ago", "2d ago"
* Without suffix: "0s", "5m", "3h", "2d"
*/
export function formatTimeAgo(
durationMs: number | null | undefined,
options?: FormatTimeAgoOptions,
): string {
const suffix = options?.suffix !== false;
const fallback = options?.fallback ?? "unknown";
if (durationMs == null || !Number.isFinite(durationMs) || durationMs < 0) {
return fallback;
}
const totalSeconds = Math.round(durationMs / 1000);
const minutes = Math.round(totalSeconds / 60);
if (minutes < 1) {
return suffix ? "just now" : `${totalSeconds}s`;
}
if (minutes < 60) {
return suffix ? `${minutes}m ago` : `${minutes}m`;
}
const hours = Math.round(minutes / 60);
if (hours < 48) {
return suffix ? `${hours}h ago` : `${hours}h`;
}
const days = Math.round(hours / 24);
return suffix ? `${days}d ago` : `${days}d`;
}
export type FormatRelativeTimestampOptions = {
/** If true, fall back to short date (e.g. "Oct 5") for timestamps >7 days. Default: false */
dateFallback?: boolean;
/** IANA timezone for date fallback display */
timezone?: string;
/** Return value for invalid/null input. Default: "n/a" */
fallback?: string;
};
/**
* Format an epoch timestamp relative to now.
*
* Handles both past ("5m ago") and future ("in 5m") timestamps.
* Optionally falls back to a short date for timestamps older than 7 days.
*/
export function formatRelativeTimestamp(
timestampMs: number | null | undefined,
options?: FormatRelativeTimestampOptions,
): string {
const fallback = options?.fallback ?? "n/a";
if (timestampMs == null || !Number.isFinite(timestampMs)) {
return fallback;
}
const diff = Date.now() - timestampMs;
const absDiff = Math.abs(diff);
const isPast = diff >= 0;
const sec = Math.round(absDiff / 1000);
if (sec < 60) {
return isPast ? "just now" : "in <1m";
}
const min = Math.round(sec / 60);
if (min < 60) {
return isPast ? `${min}m ago` : `in ${min}m`;
}
const hr = Math.round(min / 60);
if (hr < 48) {
return isPast ? `${hr}h ago` : `in ${hr}h`;
}
const day = Math.round(hr / 24);
if (!options?.dateFallback || day <= 7) {
return isPast ? `${day}d ago` : `in ${day}d`;
}
// Fall back to short date display for old timestamps
try {
return new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
...(options.timezone ? { timeZone: options.timezone } : {}),
}).format(new Date(timestampMs));
} catch {
return `${day}d ago`;
}
}

View File

@@ -0,0 +1,221 @@
import { describe, expect, it } from "vitest";
import { formatUtcTimestamp, formatZonedTimestamp, resolveTimezone } from "./format-datetime.js";
import {
formatDurationCompact,
formatDurationHuman,
formatDurationPrecise,
formatDurationSeconds,
} from "./format-duration.js";
import { formatTimeAgo, formatRelativeTimestamp } from "./format-relative.js";
describe("format-duration", () => {
describe("formatDurationCompact", () => {
it("returns undefined for null/undefined/non-positive", () => {
expect(formatDurationCompact(null)).toBeUndefined();
expect(formatDurationCompact(undefined)).toBeUndefined();
expect(formatDurationCompact(0)).toBeUndefined();
expect(formatDurationCompact(-100)).toBeUndefined();
});
it("formats milliseconds for sub-second durations", () => {
expect(formatDurationCompact(500)).toBe("500ms");
expect(formatDurationCompact(999)).toBe("999ms");
});
it("formats seconds", () => {
expect(formatDurationCompact(1000)).toBe("1s");
expect(formatDurationCompact(45000)).toBe("45s");
expect(formatDurationCompact(59000)).toBe("59s");
});
it("formats minutes and seconds", () => {
expect(formatDurationCompact(60000)).toBe("1m");
expect(formatDurationCompact(65000)).toBe("1m5s");
expect(formatDurationCompact(90000)).toBe("1m30s");
});
it("omits trailing zero components", () => {
expect(formatDurationCompact(60000)).toBe("1m"); // not "1m0s"
expect(formatDurationCompact(3600000)).toBe("1h"); // not "1h0m"
expect(formatDurationCompact(86400000)).toBe("1d"); // not "1d0h"
});
it("formats hours and minutes", () => {
expect(formatDurationCompact(3660000)).toBe("1h1m");
expect(formatDurationCompact(5400000)).toBe("1h30m");
});
it("formats days and hours", () => {
expect(formatDurationCompact(90000000)).toBe("1d1h");
expect(formatDurationCompact(172800000)).toBe("2d");
});
it("supports spaced option", () => {
expect(formatDurationCompact(65000, { spaced: true })).toBe("1m 5s");
expect(formatDurationCompact(3660000, { spaced: true })).toBe("1h 1m");
expect(formatDurationCompact(90000000, { spaced: true })).toBe("1d 1h");
});
it("rounds at boundaries", () => {
// 59.5 seconds rounds to 60s = 1m
expect(formatDurationCompact(59500)).toBe("1m");
// 59.4 seconds rounds to 59s
expect(formatDurationCompact(59400)).toBe("59s");
});
});
describe("formatDurationHuman", () => {
it("returns fallback for invalid input", () => {
expect(formatDurationHuman(null)).toBe("n/a");
expect(formatDurationHuman(undefined)).toBe("n/a");
expect(formatDurationHuman(-100)).toBe("n/a");
expect(formatDurationHuman(null, "unknown")).toBe("unknown");
});
it("formats single unit", () => {
expect(formatDurationHuman(500)).toBe("500ms");
expect(formatDurationHuman(5000)).toBe("5s");
expect(formatDurationHuman(180000)).toBe("3m");
expect(formatDurationHuman(7200000)).toBe("2h");
expect(formatDurationHuman(172800000)).toBe("2d");
});
it("uses 24h threshold for days", () => {
expect(formatDurationHuman(23 * 3600000)).toBe("23h");
expect(formatDurationHuman(24 * 3600000)).toBe("1d");
expect(formatDurationHuman(25 * 3600000)).toBe("1d"); // rounds
});
});
describe("formatDurationPrecise", () => {
it("shows milliseconds for sub-second", () => {
expect(formatDurationPrecise(500)).toBe("500ms");
expect(formatDurationPrecise(999)).toBe("999ms");
});
it("shows decimal seconds for >=1s", () => {
expect(formatDurationPrecise(1000)).toBe("1s");
expect(formatDurationPrecise(1500)).toBe("1.5s");
expect(formatDurationPrecise(1234)).toBe("1.23s");
});
it("returns unknown for non-finite", () => {
expect(formatDurationPrecise(NaN)).toBe("unknown");
expect(formatDurationPrecise(Infinity)).toBe("unknown");
});
});
describe("formatDurationSeconds", () => {
it("formats with configurable decimals", () => {
expect(formatDurationSeconds(1500, { decimals: 1 })).toBe("1.5s");
expect(formatDurationSeconds(1234, { decimals: 2 })).toBe("1.23s");
expect(formatDurationSeconds(1000, { decimals: 0 })).toBe("1s");
});
it("supports seconds unit", () => {
expect(formatDurationSeconds(2000, { unit: "seconds" })).toBe("2 seconds");
});
});
});
describe("format-datetime", () => {
describe("resolveTimezone", () => {
it("returns valid IANA timezone strings", () => {
expect(resolveTimezone("America/New_York")).toBe("America/New_York");
expect(resolveTimezone("Europe/London")).toBe("Europe/London");
expect(resolveTimezone("UTC")).toBe("UTC");
});
it("returns undefined for invalid timezones", () => {
expect(resolveTimezone("Invalid/Timezone")).toBeUndefined();
expect(resolveTimezone("garbage")).toBeUndefined();
expect(resolveTimezone("")).toBeUndefined();
});
});
describe("formatUtcTimestamp", () => {
it("formats without seconds by default", () => {
const date = new Date("2024-01-15T14:30:45.000Z");
expect(formatUtcTimestamp(date)).toBe("2024-01-15T14:30Z");
});
it("includes seconds when requested", () => {
const date = new Date("2024-01-15T14:30:45.000Z");
expect(formatUtcTimestamp(date, { displaySeconds: true })).toBe("2024-01-15T14:30:45Z");
});
});
describe("formatZonedTimestamp", () => {
it("formats with timezone abbreviation", () => {
const date = new Date("2024-01-15T14:30:00.000Z");
const result = formatZonedTimestamp(date, { timeZone: "UTC" });
expect(result).toMatch(/2024-01-15 14:30/);
});
it("includes seconds when requested", () => {
const date = new Date("2024-01-15T14:30:45.000Z");
const result = formatZonedTimestamp(date, { timeZone: "UTC", displaySeconds: true });
expect(result).toMatch(/2024-01-15 14:30:45/);
});
});
});
describe("format-relative", () => {
describe("formatTimeAgo", () => {
it("returns fallback for invalid input", () => {
expect(formatTimeAgo(null)).toBe("unknown");
expect(formatTimeAgo(undefined)).toBe("unknown");
expect(formatTimeAgo(-100)).toBe("unknown");
expect(formatTimeAgo(null, { fallback: "n/a" })).toBe("n/a");
});
it("formats with 'ago' suffix by default", () => {
expect(formatTimeAgo(0)).toBe("just now");
expect(formatTimeAgo(29000)).toBe("just now"); // rounds to <1m
expect(formatTimeAgo(30000)).toBe("1m ago"); // 30s rounds to 1m
expect(formatTimeAgo(300000)).toBe("5m ago");
expect(formatTimeAgo(7200000)).toBe("2h ago");
expect(formatTimeAgo(172800000)).toBe("2d ago");
});
it("omits suffix when suffix: false", () => {
expect(formatTimeAgo(0, { suffix: false })).toBe("0s");
expect(formatTimeAgo(300000, { suffix: false })).toBe("5m");
expect(formatTimeAgo(7200000, { suffix: false })).toBe("2h");
});
it("uses 48h threshold before switching to days", () => {
expect(formatTimeAgo(47 * 3600000)).toBe("47h ago");
expect(formatTimeAgo(48 * 3600000)).toBe("2d ago");
});
});
describe("formatRelativeTimestamp", () => {
it("returns fallback for invalid input", () => {
expect(formatRelativeTimestamp(null)).toBe("n/a");
expect(formatRelativeTimestamp(undefined)).toBe("n/a");
expect(formatRelativeTimestamp(null, { fallback: "unknown" })).toBe("unknown");
});
it("formats past timestamps", () => {
const now = Date.now();
expect(formatRelativeTimestamp(now - 10000)).toBe("just now");
expect(formatRelativeTimestamp(now - 300000)).toBe("5m ago");
expect(formatRelativeTimestamp(now - 7200000)).toBe("2h ago");
});
it("formats future timestamps", () => {
const now = Date.now();
expect(formatRelativeTimestamp(now + 30000)).toBe("in <1m");
expect(formatRelativeTimestamp(now + 300000)).toBe("in 5m");
expect(formatRelativeTimestamp(now + 7200000)).toBe("in 2h");
});
it("falls back to date for old timestamps when enabled", () => {
const oldDate = Date.now() - 30 * 24 * 3600000; // 30 days ago
const result = formatRelativeTimestamp(oldDate, { dateFallback: true });
// Should be a short date like "Jan 9" not "30d ago"
expect(result).toMatch(/[A-Z][a-z]{2} \d{1,2}/);
});
});
});