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