datetime.test.ts122 lines · main
1import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
2
3import {
4 formatDate,
5 formatDateTime,
6 formatFromNow,
7 formatTime,
8 resolveTimezone,
9 toTimezone,
10} from '@/lib/datetime'
11
12// Fixed reference points so the assertions don't depend on the host machine's
13// timezone or current wall-clock time.
14const SUMMER_UTC = '2025-06-15T12:34:56Z'
15const WINTER_UTC = '2025-01-15T12:34:56Z'
16// 16-digit unix microseconds equivalent of SUMMER_UTC.
17const SUMMER_UNIX_MICRO = '1749990896000000'
18
19describe('formatDateTime', () => {
20 it('renders a UTC instant in Asia/Tokyo (UTC+9, no DST)', () => {
21 expect(formatDateTime(SUMMER_UTC, { tz: 'Asia/Tokyo' })).toBe('15 Jun 2025 21:34:56')
22 })
23
24 it('renders a UTC instant in America/Los_Angeles during DST (UTC-7)', () => {
25 expect(formatDateTime(SUMMER_UTC, { tz: 'America/Los_Angeles' })).toBe('15 Jun 2025 05:34:56')
26 })
27
28 it('renders a UTC instant in America/Los_Angeles outside DST (UTC-8)', () => {
29 expect(formatDateTime(WINTER_UTC, { tz: 'America/Los_Angeles' })).toBe('15 Jan 2025 04:34:56')
30 })
31
32 it('flips the wall-clock day when crossing date boundaries', () => {
33 const lateUtc = '2025-06-15T22:00:00Z'
34 expect(formatDateTime(lateUtc, { tz: 'Asia/Tokyo', format: 'YYYY-MM-DD' })).toBe('2025-06-16')
35 expect(formatDateTime(lateUtc, { tz: 'Pacific/Honolulu', format: 'YYYY-MM-DD' })).toBe(
36 '2025-06-15'
37 )
38 })
39
40 it('respects an explicit format string', () => {
41 expect(formatDateTime(SUMMER_UTC, { tz: 'UTC', format: 'YYYY-MM-DDTHH:mm:ssZ' })).toBe(
42 '2025-06-15T12:34:56+00:00'
43 )
44 })
45
46 it('accepts unix microsecond timestamps', () => {
47 expect(formatDateTime(SUMMER_UNIX_MICRO, { tz: 'UTC' })).toBe('15 Jun 2025 12:34:56')
48 })
49
50 it('accepts Date instances', () => {
51 expect(formatDateTime(new Date(SUMMER_UTC), { tz: 'UTC' })).toBe('15 Jun 2025 12:34:56')
52 })
53})
54
55describe('DST transitions in Europe/Berlin', () => {
56 it('shows +01:00 before the spring transition', () => {
57 expect(formatDateTime('2025-03-30T00:00:00Z', { tz: 'Europe/Berlin', format: 'HH:mm Z' })).toBe(
58 '01:00 +01:00'
59 )
60 })
61
62 it('shows +02:00 after the spring transition', () => {
63 expect(formatDateTime('2025-03-30T02:00:00Z', { tz: 'Europe/Berlin', format: 'HH:mm Z' })).toBe(
64 '04:00 +02:00'
65 )
66 })
67})
68
69describe('formatDate / formatTime', () => {
70 it('formatDate uses the date-only default', () => {
71 expect(formatDate(SUMMER_UTC, { tz: 'UTC' })).toBe('15 Jun 2025')
72 })
73
74 it('formatTime uses the time-only default', () => {
75 expect(formatTime(SUMMER_UTC, { tz: 'UTC' })).toBe('12:34:56')
76 })
77})
78
79describe('resolveTimezone', () => {
80 it('returns the input when it is a valid IANA name', () => {
81 expect(resolveTimezone('Asia/Tokyo')).toBe('Asia/Tokyo')
82 })
83
84 it('falls back to the guessed local timezone when input is empty', () => {
85 expect(resolveTimezone('')).toBeTruthy()
86 expect(resolveTimezone(null)).toBeTruthy()
87 expect(resolveTimezone(undefined)).toBeTruthy()
88 })
89
90 it('falls back when the input is not a real IANA zone', () => {
91 // Should not throw, should resolve to something we can format with.
92 const tz = resolveTimezone('Not/A/Real_Zone')
93 expect(() => formatDateTime(SUMMER_UTC, { tz })).not.toThrow()
94 })
95})
96
97describe('toTimezone', () => {
98 it('returns a Dayjs pinned to the given timezone for chained calls', () => {
99 const d = toTimezone(SUMMER_UTC, 'Asia/Tokyo')
100 expect(d.format('HH:mm')).toBe('21:34')
101 expect(d.hour()).toBe(21)
102 })
103})
104
105describe('formatFromNow', () => {
106 beforeAll(() => {
107 vi.useFakeTimers()
108 vi.setSystemTime(new Date('2025-06-15T13:34:56Z'))
109 })
110
111 afterAll(() => {
112 vi.useRealTimers()
113 })
114
115 it('renders ISO inputs as a relative duration', () => {
116 expect(formatFromNow(SUMMER_UTC)).toBe('an hour ago')
117 })
118
119 it('accepts unix microsecond inputs', () => {
120 expect(formatFromNow(SUMMER_UNIX_MICRO)).toBe('an hour ago')
121 })
122})