diff --git a/apps/mobile/src/relative-date.ts b/apps/mobile/src/relative-date.ts new file mode 100644 index 000000000..642c6d958 --- /dev/null +++ b/apps/mobile/src/relative-date.ts @@ -0,0 +1,20 @@ +/** + * Relative time for activity timestamps. Values slightly in the future are + * clock skew and still read "Just now"; further future values fall back to a + * date instead of claiming they just happened. + */ +export function relativeDate(value: string, now = Date.now()): string { + const parsed = Date.parse(value); + if (!Number.isFinite(parsed)) return value; + const diff = now - parsed; + if (diff < -60_000) return dayLabel(parsed); + const elapsed = Math.max(0, diff); + if (elapsed < 60_000) return "Just now"; + if (elapsed < 3600_000) return `${Math.floor(elapsed / 60_000)}m ago`; + if (elapsed < 86400_000) return `${Math.floor(elapsed / 3600_000)}h ago`; + return dayLabel(parsed); +} + +function dayLabel(value: number): string { + return new Date(value).toLocaleDateString("en-US", { month: "short", day: "numeric" }); +} diff --git a/apps/mobile/src/ui.tsx b/apps/mobile/src/ui.tsx index ce36605cb..0346f6478 100644 --- a/apps/mobile/src/ui.tsx +++ b/apps/mobile/src/ui.tsx @@ -446,16 +446,7 @@ export function timeLabel(value: string, timeZone?: string) { ? value : date.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", timeZone }); } -export function relativeDate(value: string) { - const diff = Date.now() - new Date(value).getTime(); - return diff < 60_000 - ? "Just now" - : diff < 3600_000 - ? `${Math.floor(diff / 60_000)}m ago` - : diff < 86400_000 - ? `${Math.floor(diff / 3600_000)}h ago` - : dateLabel(value); -} +export { relativeDate } from "./relative-date"; export function resultSummary(value: string) { return /^Saved to (?:sample|local) sent mail(?: ยท .+)?$/.test(value) diff --git a/apps/mobile/test/relative-date.test.ts b/apps/mobile/test/relative-date.test.ts new file mode 100644 index 000000000..ab65f1210 --- /dev/null +++ b/apps/mobile/test/relative-date.test.ts @@ -0,0 +1,24 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { relativeDate } from "../src/relative-date.ts"; + +const now = Date.parse("2026-09-30T12:00:00Z"); + +test("recent past timestamps keep their buckets", () => { + assert.equal(relativeDate("2026-09-30T11:59:30Z", now), "Just now"); + assert.equal(relativeDate("2026-09-30T11:30:00Z", now), "30m ago"); + assert.equal(relativeDate("2026-09-30T09:00:00Z", now), "3h ago"); +}); + +test("future timestamps beyond clock skew show a date instead of just now", () => { + assert.equal(relativeDate("2026-09-30T12:00:10Z", now), "Just now"); + const expected = new Date(Date.parse("2026-10-05T12:00:00Z")).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + }); + assert.equal(relativeDate("2026-10-05T12:00:00Z", now), expected); +}); + +test("an unparseable timestamp is returned unchanged", () => { + assert.equal(relativeDate("not-a-date", now), "not-a-date"); +});