From 6f2d563b3da17b17556bbeb43a967abc3e725568 Mon Sep 17 00:00:00 2001 From: Dave Date: Wed, 16 Sep 2026 10:57:55 +0200 Subject: [PATCH] feat(time): relative timestamp formatting --- src/lib/time/relative.test.ts | 47 ++++++++++++++++++++++++++++++++ src/lib/time/relative.ts | 50 +++++++++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+) create mode 100644 src/lib/time/relative.test.ts create mode 100644 src/lib/time/relative.ts diff --git a/src/lib/time/relative.test.ts b/src/lib/time/relative.test.ts new file mode 100644 index 0000000..c952773 --- /dev/null +++ b/src/lib/time/relative.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect } from "vitest"; +import { relativeTime } from "@/lib/time/relative"; + +const now = new Date("2026-09-16T12:00:00Z"); + +describe("relativeTime", () => { + it("returns an empty string for a missing value", () => { + expect(relativeTime("", now)).toBe(""); + }); + + it("says just now under a minute", () => { + expect(relativeTime("2026-09-16 11:59:30.000Z", now)).toBe("just now"); + }); + + it("counts minutes", () => { + expect(relativeTime("2026-09-16 11:59:00.000Z", now)).toBe("1 minute ago"); + expect(relativeTime("2026-09-16 11:45:00.000Z", now)).toBe("15 minutes ago"); + }); + + it("counts hours", () => { + expect(relativeTime("2026-09-16 11:00:00.000Z", now)).toBe("1 hour ago"); + expect(relativeTime("2026-09-16 07:00:00.000Z", now)).toBe("5 hours ago"); + }); + + it("says yesterday", () => { + expect(relativeTime("2026-09-15 08:00:00.000Z", now)).toBe("yesterday"); + }); + + it("counts days up to a week", () => { + expect(relativeTime("2026-09-12 12:00:00.000Z", now)).toBe("4 days ago"); + }); + + it("falls back to a date beyond a week", () => { + expect(relativeTime("2026-09-01 12:00:00.000Z", now)).toBe("1 Sep"); + }); + + it("includes the year for another year", () => { + expect(relativeTime("2025-12-20 12:00:00.000Z", now)).toBe("20 Dec 2025"); + }); + + it("does not produce negative times for a near-future timestamp", () => { + // Clock skew between the PocketBase host and the browser can put a + // just-written record a few seconds in the future. "in -3 seconds" is + // worse than a small lie. + expect(relativeTime("2026-09-16 12:00:05.000Z", now)).toBe("just now"); + }); +}); \ No newline at end of file diff --git a/src/lib/time/relative.ts b/src/lib/time/relative.ts new file mode 100644 index 0000000..41be5de --- /dev/null +++ b/src/lib/time/relative.ts @@ -0,0 +1,50 @@ +/** + * Human-readable elapsed time. + * + * PocketBase returns timestamps as "2026-09-16 12:00:00.000Z" — a space, not + * the "T" that Date.parse wants everywhere. Normalise before parsing or this + * silently returns NaN on some engines. + */ + +const MONTHS = [ + "Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", +]; + +function parse(value: string): Date { + return new Date(value.replace(" ", "T")); +} + +function plural(count: number, unit: string): string { + return `${count} ${unit}${count === 1 ? "" : "s"} ago`; +} + +export function relativeTime(value: string, now: Date = new Date()): string { + if (!value) return ""; + + const then = parse(value); + if (Number.isNaN(then.getTime())) return ""; + + const seconds = Math.round((now.getTime() - then.getTime()) / 1000); + + // Clamp small negatives: clock skew between the database host and here can + // put a record a few seconds in the future. + if (seconds < 60) return "just now"; + + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return plural(minutes, "minute"); + + const hours = Math.floor(minutes / 60); + if (hours < 24) return plural(hours, "hour"); + + const days = Math.floor(hours / 24); + if (days === 1) return "yesterday"; + if (days < 7) return plural(days, "day"); + + const day = then.getUTCDate(); + const month = MONTHS[then.getUTCMonth()]; + const year = then.getUTCFullYear(); + + if (year !== now.getUTCFullYear()) return `${day} ${month} ${year}`; + return `${day} ${month}`; +} \ No newline at end of file