datetime.test.ts 3.9 KB

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