From babf668348de98add43c6ccf85eeff4055759abc Mon Sep 17 00:00:00 2001 From: wheval Date: Sat, 27 Jun 2026 16:59:58 +0100 Subject: [PATCH] feat(time-utils): add formatRelativeTimeLabel with injectable now param MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Returns human-readable relative labels with a 30-day cutoff: < 60s → "just now", < 60m → "N minutes ago", < 24h → "N hours ago", < 30d → "N days ago", ≥ 30d → formatted date ("12 Jan 2026"). Includes unit tests covering every boundary and the default-now path. Closes accesslayerorg/accesslayer-client#477 --- src/utils/__tests__/time.utils.test.ts | 63 ++++++++++++++++++++++++++ src/utils/time.utils.ts | 18 ++++++++ 2 files changed, 81 insertions(+) create mode 100644 src/utils/__tests__/time.utils.test.ts diff --git a/src/utils/__tests__/time.utils.test.ts b/src/utils/__tests__/time.utils.test.ts new file mode 100644 index 0000000..bc06c10 --- /dev/null +++ b/src/utils/__tests__/time.utils.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect } from 'vitest'; +import { formatRelativeTimeLabel } from '../time.utils'; + +function makeDate(secondsAgo: number, from: Date): Date { + return new Date(from.getTime() - secondsAgo * 1000); +} + +describe('formatRelativeTimeLabel', () => { + const now = new Date('2026-06-27T12:00:00.000Z'); + + it('returns "just now" for 0 seconds ago', () => { + expect(formatRelativeTimeLabel(now, now)).toBe('just now'); + }); + + it('returns "just now" for 59 seconds ago', () => { + expect(formatRelativeTimeLabel(makeDate(59, now), now)).toBe('just now'); + }); + + it('returns "1 minutes ago" for exactly 60 seconds ago', () => { + expect(formatRelativeTimeLabel(makeDate(60, now), now)).toBe('1 minutes ago'); + }); + + it('returns "45 minutes ago" for 45 minutes ago', () => { + expect(formatRelativeTimeLabel(makeDate(45 * 60, now), now)).toBe('45 minutes ago'); + }); + + it('returns "59 minutes ago" for 59 minutes 59 seconds ago', () => { + expect(formatRelativeTimeLabel(makeDate(59 * 60 + 59, now), now)).toBe('59 minutes ago'); + }); + + it('returns "1 hours ago" for exactly 1 hour ago', () => { + expect(formatRelativeTimeLabel(makeDate(3600, now), now)).toBe('1 hours ago'); + }); + + it('returns "23 hours ago" for 23 hours ago', () => { + expect(formatRelativeTimeLabel(makeDate(23 * 3600, now), now)).toBe('23 hours ago'); + }); + + it('returns "1 days ago" for exactly 24 hours ago', () => { + expect(formatRelativeTimeLabel(makeDate(24 * 3600, now), now)).toBe('1 days ago'); + }); + + it('returns "29 days ago" for 29 days ago', () => { + expect(formatRelativeTimeLabel(makeDate(29 * 24 * 3600, now), now)).toBe('29 days ago'); + }); + + it('returns a formatted date for exactly 30 days ago', () => { + const date = makeDate(30 * 24 * 3600, now); + const result = formatRelativeTimeLabel(date, now); + expect(result).toMatch(/\d{1,2} \w+ \d{4}/); + }); + + it('returns a formatted date for dates older than 30 days', () => { + const date = new Date('2026-01-12T00:00:00.000Z'); + const result = formatRelativeTimeLabel(date, now); + expect(result).toMatch(/12 Jan 2026/); + }); + + it('defaults now to the current time when omitted', () => { + const veryRecentDate = new Date(Date.now() - 5000); + expect(formatRelativeTimeLabel(veryRecentDate)).toBe('just now'); + }); +}); diff --git a/src/utils/time.utils.ts b/src/utils/time.utils.ts index 6fce6c4..18eae15 100644 --- a/src/utils/time.utils.ts +++ b/src/utils/time.utils.ts @@ -81,6 +81,24 @@ export function formatAbsoluteDateTime( }).format(date); } +export function formatRelativeTimeLabel(date: Date, now: Date = new Date()): string { + const diffMs = now.getTime() - date.getTime(); + const diffSec = Math.floor(diffMs / 1000); + + if (diffSec < 60) return 'just now'; + + const diffMin = Math.floor(diffSec / 60); + if (diffMin < 60) return `${diffMin} minutes ago`; + + const diffHr = Math.floor(diffMin / 60); + if (diffHr < 24) return `${diffHr} hours ago`; + + const diffDay = Math.floor(diffHr / 24); + if (diffDay < 30) return `${diffDay} days ago`; + + return date.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' }); +} + export function formatRelativeTime( input: string | number | Date | null | undefined, options: RelativeTimeOptions = {}