datetime.tsx185 lines · main
| 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 | |
| 8 | import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage' |
| 9 | import { 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). |
| 13 | dayjs.extend(utc) |
| 14 | dayjs.extend(timezone) |
| 15 | dayjs.extend(relativeTime) |
| 16 | |
| 17 | export type DateInput = string | number | Date | Dayjs |
| 18 | |
| 19 | const 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 | |
| 25 | const unixMicroToIso = (value: string | number): string => |
| 26 | dayjs.unix(Number(value) / 1_000_000).toISOString() |
| 27 | |
| 28 | const 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 | |
| 37 | const 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 | */ |
| 51 | export const resolveTimezone = (tz: string | undefined | null): string => { |
| 52 | if (tz && isValidTimezone(tz)) return tz |
| 53 | return guessLocalTimezone() |
| 54 | } |
| 55 | |
| 56 | const DEFAULT_DATETIME_FORMAT = 'DD MMM YYYY HH:mm:ss' |
| 57 | const DEFAULT_DATE_FORMAT = 'DD MMM YYYY' |
| 58 | const DEFAULT_TIME_FORMAT = 'HH:mm:ss' |
| 59 | |
| 60 | interface 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 | |
| 67 | export const formatDateTime = (input: DateInput, opts: FormatOptions = {}): string => |
| 68 | normalize(input) |
| 69 | .tz(resolveTimezone(opts.tz)) |
| 70 | .format(opts.format ?? DEFAULT_DATETIME_FORMAT) |
| 71 | |
| 72 | export const formatDate = (input: DateInput, opts: FormatOptions = {}): string => |
| 73 | normalize(input) |
| 74 | .tz(resolveTimezone(opts.tz)) |
| 75 | .format(opts.format ?? DEFAULT_DATE_FORMAT) |
| 76 | |
| 77 | export 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". */ |
| 83 | export const formatFromNow = (input: DateInput): string => normalize(input).fromNow() |
| 84 | |
| 85 | /** Returns the input as a Dayjs instance pinned to the given timezone. */ |
| 86 | export const toTimezone = (input: DateInput, tz?: string): Dayjs => |
| 87 | normalize(input).tz(resolveTimezone(tz)) |
| 88 | |
| 89 | interface 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 | |
| 100 | const TimezoneContext = createContext<TimezoneContextValue | undefined>(undefined) |
| 101 | |
| 102 | export 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. |
| 141 | const NO_OP_SET_TIMEZONE = () => {} |
| 142 | |
| 143 | export 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. */ |
| 158 | export const useFormatDateTime = () => { |
| 159 | const { timezone } = useTimezone() |
| 160 | return useCallback( |
| 161 | (input: DateInput, format?: string) => formatDateTime(input, { tz: timezone, format }), |
| 162 | [timezone] |
| 163 | ) |
| 164 | } |
| 165 | |
| 166 | export const useFormatDate = () => { |
| 167 | const { timezone } = useTimezone() |
| 168 | return useCallback( |
| 169 | (input: DateInput, format?: string) => formatDate(input, { tz: timezone, format }), |
| 170 | [timezone] |
| 171 | ) |
| 172 | } |
| 173 | |
| 174 | export const useFormatTime = () => { |
| 175 | const { timezone } = useTimezone() |
| 176 | return useCallback( |
| 177 | (input: DateInput, format?: string) => formatTime(input, { tz: timezone, format }), |
| 178 | [timezone] |
| 179 | ) |
| 180 | } |
| 181 | |
| 182 | export const useToTimezone = () => { |
| 183 | const { timezone } = useTimezone() |
| 184 | return useCallback((input: DateInput) => toTimezone(input, timezone), [timezone]) |
| 185 | } |