feat(time): relative timestamp formatting
This commit is contained in:
parent
0c6557b89d
commit
6f2d563b3d
2 changed files with 97 additions and 0 deletions
47
src/lib/time/relative.test.ts
Normal file
47
src/lib/time/relative.test.ts
Normal 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
50
src/lib/time/relative.ts
Normal 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}`;
|
||||
}
|
||||
Loading…
Reference in a new issue