Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 131 additions & 2 deletions desktop/src/renderer/src/lib/utils/time.test.ts
Original file line number Diff line number Diff line change
@@ -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(() => {
Expand Down Expand Up @@ -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")
Expand Down
12 changes: 5 additions & 7 deletions desktop/src/renderer/src/lib/utils/time.ts
Original file line number Diff line number Diff line change
@@ -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`
Expand Down
Loading