mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 04:51:25 +00:00
Centralize date/time formatting utilities (#11831)
This commit is contained in:
94
src/infra/format-time/format-datetime.ts
Normal file
94
src/infra/format-time/format-datetime.ts
Normal 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}` : ""}`;
|
||||
}
|
||||
103
src/infra/format-time/format-duration.ts
Normal file
103
src/infra/format-time/format-duration.ts
Normal 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`;
|
||||
}
|
||||
112
src/infra/format-time/format-relative.ts
Normal file
112
src/infra/format-time/format-relative.ts
Normal 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`;
|
||||
}
|
||||
}
|
||||
221
src/infra/format-time/format-time.test.ts
Normal file
221
src/infra/format-time/format-time.test.ts
Normal 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}/);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user