feat(time): relative timestamp formatting

This commit is contained in:
Dave 2026-09-16 10:57:55 +02:00
parent 0c6557b89d
commit 6f2d563b3d
2 changed files with 97 additions and 0 deletions

View file

@ -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");
});
});

50
src/lib/time/relative.ts Normal file
View file

@ -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}`;
}