datetime.tsx 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185
  1. import { LOCAL_STORAGE_KEYS } from 'common'
  2. import dayjs, { type Dayjs } from 'dayjs'
  3. import relativeTime from 'dayjs/plugin/relativeTime'
  4. import timezone from 'dayjs/plugin/timezone'
  5. import utc from 'dayjs/plugin/utc'
  6. import { createContext, useCallback, useContext, useEffect, useMemo, type ReactNode } from 'react'
  7. import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage'
  8. import { guessLocalTimezone } from '@/lib/dayjs'
  9. // dayjs.extend is idempotent. Extending here removes the implicit dependency
  10. // on _app.tsx running first (e.g. Storybook, isolated scripts).
  11. dayjs.extend(utc)
  12. dayjs.extend(timezone)
  13. dayjs.extend(relativeTime)
  14. export type DateInput = string | number | Date | Dayjs
  15. const isUnixMicro = (value: string | number): boolean => {
  16. const digits = String(value).length
  17. const isNum = !Number.isNaN(Number(value))
  18. return isNum && digits === 16
  19. }
  20. const unixMicroToIso = (value: string | number): string =>
  21. dayjs.unix(Number(value) / 1_000_000).toISOString()
  22. const normalize = (input: DateInput): Dayjs => {
  23. if (dayjs.isDayjs(input)) return input
  24. if (input instanceof Date) return dayjs(input)
  25. if ((typeof input === 'string' || typeof input === 'number') && isUnixMicro(input)) {
  26. return dayjs.utc(unixMicroToIso(input))
  27. }
  28. return dayjs.utc(input)
  29. }
  30. const isValidTimezone = (tz: string): boolean => {
  31. try {
  32. Intl.DateTimeFormat(undefined, { timeZone: tz })
  33. return true
  34. } catch {
  35. return false
  36. }
  37. }
  38. /**
  39. * Resolve a user-supplied timezone to a valid IANA name. Falls back to the
  40. * browser's guessed timezone, then UTC. Pass `undefined`/empty string to opt
  41. * into the guessed default.
  42. */
  43. export const resolveTimezone = (tz: string | undefined | null): string => {
  44. if (tz && isValidTimezone(tz)) return tz
  45. return guessLocalTimezone()
  46. }
  47. const DEFAULT_DATETIME_FORMAT = 'DD MMM YYYY HH:mm:ss'
  48. const DEFAULT_DATE_FORMAT = 'DD MMM YYYY'
  49. const DEFAULT_TIME_FORMAT = 'HH:mm:ss'
  50. interface FormatOptions {
  51. /** IANA timezone (e.g. 'Asia/Tokyo'). Falls back to guessed local. */
  52. tz?: string
  53. /** dayjs format string. */
  54. format?: string
  55. }
  56. export const formatDateTime = (input: DateInput, opts: FormatOptions = {}): string =>
  57. normalize(input)
  58. .tz(resolveTimezone(opts.tz))
  59. .format(opts.format ?? DEFAULT_DATETIME_FORMAT)
  60. export const formatDate = (input: DateInput, opts: FormatOptions = {}): string =>
  61. normalize(input)
  62. .tz(resolveTimezone(opts.tz))
  63. .format(opts.format ?? DEFAULT_DATE_FORMAT)
  64. export const formatTime = (input: DateInput, opts: FormatOptions = {}): string =>
  65. normalize(input)
  66. .tz(resolveTimezone(opts.tz))
  67. .format(opts.format ?? DEFAULT_TIME_FORMAT)
  68. /** Returns a humanised relative time, e.g. "3 minutes ago". */
  69. export const formatFromNow = (input: DateInput): string => normalize(input).fromNow()
  70. /** Returns the input as a Dayjs instance pinned to the given timezone. */
  71. export const toTimezone = (input: DateInput, tz?: string): Dayjs =>
  72. normalize(input).tz(resolveTimezone(tz))
  73. interface TimezoneContextValue {
  74. /** The resolved IANA timezone currently in use. Always valid. */
  75. timezone: string
  76. /** The user's stored preference. Empty string means "use guessed local". */
  77. storedTimezone: string
  78. /** Update the stored preference. Pass an empty string to clear (use guessed). */
  79. setTimezone: (tz: string) => void
  80. /** Whether the current selection is the auto-detected default. */
  81. isAutoDetected: boolean
  82. }
  83. const TimezoneContext = createContext<TimezoneContextValue | undefined>(undefined)
  84. export const TimezoneProvider = ({ children }: { children: ReactNode }) => {
  85. const [storedTimezone, setStoredTimezone] = useLocalStorageQuery<string>(
  86. LOCAL_STORAGE_KEYS.UI_TIMEZONE,
  87. ''
  88. )
  89. const timezone = useMemo(() => resolveTimezone(storedTimezone), [storedTimezone])
  90. // Apply the selected timezone as the dayjs default so anything calling
  91. // `dayjs.tz()` or `.tz()` without an argument picks it up. Bare `dayjs()`
  92. // calls are unaffected by design — those continue to render in the host
  93. // browser's timezone until they're intentionally migrated to the wrappers
  94. // below.
  95. useEffect(() => {
  96. dayjs.tz.setDefault(timezone)
  97. }, [timezone])
  98. const setTimezone = useCallback(
  99. (tz: string) => {
  100. setStoredTimezone(tz)
  101. },
  102. [setStoredTimezone]
  103. )
  104. const value = useMemo<TimezoneContextValue>(
  105. () => ({
  106. timezone,
  107. storedTimezone,
  108. setTimezone,
  109. isAutoDetected: !storedTimezone,
  110. }),
  111. [timezone, storedTimezone, setTimezone]
  112. )
  113. return <TimezoneContext.Provider value={value}>{children}</TimezoneContext.Provider>
  114. }
  115. // Stable fallback so callers outside the provider (e.g. unit tests, isolated
  116. // stories) don't get a fresh object identity every render.
  117. const NO_OP_SET_TIMEZONE = () => {}
  118. export const useTimezone = (): TimezoneContextValue => {
  119. const ctx = useContext(TimezoneContext)
  120. return useMemo<TimezoneContextValue>(
  121. () =>
  122. ctx ?? {
  123. timezone: guessLocalTimezone(),
  124. storedTimezone: '',
  125. setTimezone: NO_OP_SET_TIMEZONE,
  126. isAutoDetected: true,
  127. },
  128. [ctx]
  129. )
  130. }
  131. /** Returns a memoised `(input, format?) => string` bound to the active timezone. */
  132. export const useFormatDateTime = () => {
  133. const { timezone } = useTimezone()
  134. return useCallback(
  135. (input: DateInput, format?: string) => formatDateTime(input, { tz: timezone, format }),
  136. [timezone]
  137. )
  138. }
  139. export const useFormatDate = () => {
  140. const { timezone } = useTimezone()
  141. return useCallback(
  142. (input: DateInput, format?: string) => formatDate(input, { tz: timezone, format }),
  143. [timezone]
  144. )
  145. }
  146. export const useFormatTime = () => {
  147. const { timezone } = useTimezone()
  148. return useCallback(
  149. (input: DateInput, format?: string) => formatTime(input, { tz: timezone, format }),
  150. [timezone]
  151. )
  152. }
  153. export const useToTimezone = () => {
  154. const { timezone } = useTimezone()
  155. return useCallback((input: DateInput) => toTimezone(input, timezone), [timezone])
  156. }