diff --git a/desktop/src/renderer/src/lib/utils/time.test.ts b/desktop/src/renderer/src/lib/utils/time.test.ts index 71ddfaaca4..54502fd2aa 100644 --- a/desktop/src/renderer/src/lib/utils/time.test.ts +++ b/desktop/src/renderer/src/lib/utils/time.test.ts @@ -1,5 +1,5 @@ -import { afterEach, describe, expect, it, vi } from "vitest" -import { formatTimestamp, timeAgo } from "./time.js" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { formatTimestamp, timeAgo, timeAgoMs } from "./time.js" describe("timeAgo", () => { afterEach(() => { @@ -62,6 +62,135 @@ describe("timeAgo", () => { }) }) +describe("relative timestamps with a frozen clock", () => { + const now = 864000000 + + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(now) + }) + + afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() + vi.useRealTimers() + }) + + it.each([ + [0, "Just now"], + [59999, "Just now"], + [60000, "1m ago"], + [3599999, "59m ago"], + [3600000, "1h ago"], + [86399999, "23h ago"], + [86400000, "1d ago"], + [5 * 86400000 + 86399999, "5d ago"], + [-1, "Just now"], + [-86400000, "Just now"], + ])( + "formats %d milliseconds ago through both adapters", + (elapsed, expected) => { + const timestamp = now - elapsed + const clock = vi.spyOn(Date, "now") + + expect(timeAgo(new Date(timestamp).toISOString())).toBe(expected) + expect(clock).toHaveBeenCalledTimes(1) + clock.mockClear() + expect(timeAgoMs(timestamp)).toBe(expected) + expect(clock).toHaveBeenCalledTimes(1) + }, + ) + + it.each([ + [0, "10d ago"], + [-86400000, "11d ago"], + ])( + "formats epoch timestamp %d through both adapters", + (timestamp, expected) => { + expect(timeAgo(new Date(timestamp).toISOString())).toBe(expected) + expect(timeAgoMs(timestamp)).toBe(expected) + }, + ) + + it.each([undefined, ""])( + "avoids the clock for missing input %s", + (timestamp) => { + const clock = vi.spyOn(Date, "now").mockImplementation(() => { + throw new Error("Clock must not be read") + }) + + expect(timeAgo(timestamp)).toBe("Unknown") + expect(clock).not.toHaveBeenCalled() + }, + ) + + it.each(["not-a-date", "NaN", "Infinity", "-Infinity"])( + "preserves invalid nonempty string %s", + (timestamp) => { + const clock = vi.spyOn(Date, "now") + + expect(timeAgo(timestamp)).toBe("NaNd ago") + expect(clock).toHaveBeenCalledTimes(1) + }, + ) + + it.each([ + [NaN, "NaNd ago"], + [Infinity, "Just now"], + [-Infinity, "Infinityd ago"], + [now - 60000 + 0.5, "Just now"], + [now - 60000 - 0.5, "1m ago"], + [-0.5, "10d ago"], + ])("preserves numeric timestamp %s", (timestamp, expected) => { + const clock = vi.spyOn(Date, "now") + + expect(timeAgoMs(timestamp)).toBe(expected) + expect(clock).toHaveBeenCalledTimes(1) + }) + + it("reads the clock before constructing and reading the parsed date", () => { + const events: string[] = [] + const OriginalDate = Date + class ObservedDate extends OriginalDate { + constructor(timestamp: string) { + events.push("parse") + super(timestamp) + } + + getTime(): number { + events.push("getTime") + return super.getTime() + } + + static now(): number { + events.push("now") + return now + } + } + vi.stubGlobal("Date", ObservedDate) + + expect(timeAgo("1970-01-10T23:59:00Z")).toBe("1m ago") + expect(events).toEqual(["now", "parse", "getTime"]) + }) + + it("reads the clock before coercing the numeric timestamp", () => { + const events: string[] = [] + vi.spyOn(Date, "now").mockImplementation(() => { + events.push("now") + return now + }) + const timestamp = { + valueOf() { + events.push("timestamp") + return now - 60000 + }, + } + + expect(timeAgoMs(timestamp as unknown as number)).toBe("1m ago") + expect(events).toEqual(["now", "timestamp"]) + }) +}) + describe("formatTimestamp", () => { it("formats a valid ISO timestamp", () => { const result = formatTimestamp("2026-01-15T12:00:00Z") diff --git a/desktop/src/renderer/src/lib/utils/time.ts b/desktop/src/renderer/src/lib/utils/time.ts index 094f4c68e9..493bdc1615 100644 --- a/desktop/src/renderer/src/lib/utils/time.ts +++ b/desktop/src/renderer/src/lib/utils/time.ts @@ -1,17 +1,15 @@ export function timeAgo(timestamp?: string): string { if (!timestamp) return "Unknown" const diff = Date.now() - new Date(timestamp).getTime() - const minutes = Math.floor(diff / 60000) - if (minutes < 1) return "Just now" - if (minutes < 60) return `${minutes}m ago` - const hours = Math.floor(minutes / 60) - if (hours < 24) return `${hours}h ago` - const days = Math.floor(hours / 24) - return `${days}d ago` + return formatElapsedTime(diff) } export function timeAgoMs(ts: number): string { const diff = Date.now() - ts + return formatElapsedTime(diff) +} + +function formatElapsedTime(diff: number): string { const minutes = Math.floor(diff / 60000) if (minutes < 1) return "Just now" if (minutes < 60) return `${minutes}m ago`