From 42a6f7e7ac8554e7eb7ae5b2c9745799ebae8717 Mon Sep 17 00:00:00 2001 From: wheval Date: Mon, 29 Jun 2026 20:37:57 +0100 Subject: [PATCH] feat(utils): add formatRelativeTime helper for activity feed timestamps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - formatRelativeTime(date, now?): 'just now' | 'N minutes ago' | 'N hours ago' | 'N days ago' | formatted date - Boundaries: <60s → just now, <60m → minutes, <24h → hours, <30d → days, ≥30d → 'DD Mon YYYY' - Injectable now param for deterministic unit tests - Unit tests covering all time range boundaries and singular/plural forms Closes accesslayerorg/accesslayer-client#477 --- .../__tests__/relativeTime.utils.test.ts | 123 ++++++++++++++++++ src/utils/relativeTime.utils.ts | 37 ++++++ 2 files changed, 160 insertions(+) create mode 100644 src/utils/__tests__/relativeTime.utils.test.ts create mode 100644 src/utils/relativeTime.utils.ts diff --git a/src/utils/__tests__/relativeTime.utils.test.ts b/src/utils/__tests__/relativeTime.utils.test.ts new file mode 100644 index 0000000..326a24b --- /dev/null +++ b/src/utils/__tests__/relativeTime.utils.test.ts @@ -0,0 +1,123 @@ +import { describe, it, expect } from 'vitest'; +import { formatRelativeTime } from '../relativeTime.utils'; + +const NOW = new Date('2026-01-12T12:00:00Z'); + +function ago(ms: number): Date { + return new Date(NOW.getTime() - ms); +} + +const SEC = 1_000; +const MIN = 60 * SEC; +const HR = 60 * MIN; +const DAY = 24 * HR; + +describe('formatRelativeTime', () => { + describe('under 60 seconds', () => { + it('returns "just now" for 0 seconds ago', () => { + expect(formatRelativeTime(ago(0), NOW)).toBe('just now'); + }); + + it('returns "just now" for 30 seconds ago', () => { + expect(formatRelativeTime(ago(30 * SEC), NOW)).toBe('just now'); + }); + + it('returns "just now" at exactly 59 seconds ago', () => { + expect(formatRelativeTime(ago(59 * SEC), NOW)).toBe('just now'); + }); + }); + + describe('boundary at 60 seconds', () => { + it('returns "1 minute ago" at exactly 60 seconds', () => { + expect(formatRelativeTime(ago(60 * SEC), NOW)).toBe('1 minute ago'); + }); + }); + + describe('under 60 minutes', () => { + it('returns singular "1 minute ago" at 1 minute', () => { + expect(formatRelativeTime(ago(1 * MIN), NOW)).toBe('1 minute ago'); + }); + + it('returns "5 minutes ago" at 5 minutes', () => { + expect(formatRelativeTime(ago(5 * MIN), NOW)).toBe('5 minutes ago'); + }); + + it('returns "59 minutes ago" at 59 minutes', () => { + expect(formatRelativeTime(ago(59 * MIN), NOW)).toBe('59 minutes ago'); + }); + }); + + describe('boundary at 60 minutes', () => { + it('returns "1 hour ago" at exactly 60 minutes', () => { + expect(formatRelativeTime(ago(60 * MIN), NOW)).toBe('1 hour ago'); + }); + }); + + describe('under 24 hours', () => { + it('returns singular "1 hour ago" at 1 hour', () => { + expect(formatRelativeTime(ago(1 * HR), NOW)).toBe('1 hour ago'); + }); + + it('returns "3 hours ago" at 3 hours', () => { + expect(formatRelativeTime(ago(3 * HR), NOW)).toBe('3 hours ago'); + }); + + it('returns "23 hours ago" at 23 hours', () => { + expect(formatRelativeTime(ago(23 * HR), NOW)).toBe('23 hours ago'); + }); + }); + + describe('boundary at 24 hours', () => { + it('returns "1 day ago" at exactly 24 hours', () => { + expect(formatRelativeTime(ago(24 * HR), NOW)).toBe('1 day ago'); + }); + }); + + describe('under 30 days', () => { + it('returns singular "1 day ago" at 1 day', () => { + expect(formatRelativeTime(ago(1 * DAY), NOW)).toBe('1 day ago'); + }); + + it('returns "7 days ago" at 7 days', () => { + expect(formatRelativeTime(ago(7 * DAY), NOW)).toBe('7 days ago'); + }); + + it('returns "29 days ago" at 29 days', () => { + expect(formatRelativeTime(ago(29 * DAY), NOW)).toBe('29 days ago'); + }); + }); + + describe('boundary at 30 days', () => { + it('returns a formatted date string at exactly 30 days', () => { + const result = formatRelativeTime(ago(30 * DAY), NOW); + expect(result).not.toContain('ago'); + expect(result).toMatch(/\d{1,2} \w+ \d{4}/); + }); + }); + + describe('beyond 30 days', () => { + it('returns formatted date string "13 Dec 2025" for 30 days ago from Jan 12 2026', () => { + expect(formatRelativeTime(ago(30 * DAY), NOW)).toBe('13 Dec 2025'); + }); + + it('returns formatted date string for a date 1 year ago', () => { + const result = formatRelativeTime(ago(365 * DAY), NOW); + expect(result).toMatch(/\d{1,2} \w+ \d{4}/); + expect(result).not.toContain('ago'); + }); + }); + + describe('injectable now param', () => { + it('uses the provided now date for deterministic results', () => { + const fixedNow = new Date('2026-06-01T00:00:00Z'); + const date = new Date('2026-05-31T23:59:00Z'); + expect(formatRelativeTime(date, fixedNow)).toBe('1 minute ago'); + }); + + it('defaults to real Date when now is omitted (smoke test)', () => { + const recent = new Date(Date.now() - 5 * MIN); + const result = formatRelativeTime(recent); + expect(result).toBe('5 minutes ago'); + }); + }); +}); diff --git a/src/utils/relativeTime.utils.ts b/src/utils/relativeTime.utils.ts new file mode 100644 index 0000000..d2cfb42 --- /dev/null +++ b/src/utils/relativeTime.utils.ts @@ -0,0 +1,37 @@ +/** + * Formats a date as a human-readable relative time label for activity feeds. + * + * @param date - The date to format + * @param now - Reference point for "now" (defaults to `new Date()`; injectable for deterministic tests) + * @returns A relative label such as "just now", "5 minutes ago", "2 hours ago", + * "3 days ago", or a formatted date string like "12 Jan 2026". + */ +export function formatRelativeTime(date: Date, now: Date = new Date()): string { + const diffMs = now.getTime() - date.getTime(); + const diffSec = Math.floor(diffMs / 1_000); + const diffMin = Math.floor(diffMs / 60_000); + const diffHrs = Math.floor(diffMs / 3_600_000); + const diffDays = Math.floor(diffMs / 86_400_000); + + if (diffSec < 60) { + return 'just now'; + } + + if (diffMin < 60) { + return `${diffMin} ${diffMin === 1 ? 'minute' : 'minutes'} ago`; + } + + if (diffHrs < 24) { + return `${diffHrs} ${diffHrs === 1 ? 'hour' : 'hours'} ago`; + } + + if (diffDays < 30) { + return `${diffDays} ${diffDays === 1 ? 'day' : 'days'} ago`; + } + + return date.toLocaleDateString('en-GB', { + day: 'numeric', + month: 'short', + year: 'numeric', + }); +}