mirror of
https://github.com/supabase/supabase.git
synced 2026-05-08 15:57:47 +08:00
## Problem The dashboard renders all timestamps in the browser's local timezone. When debugging app issues, users often want to see logs and timestamps in a different timezone (e.g. their app's deployment region) without changing their OS clock. ## Fix - New Timezone submenu in the user-avatar dropdown, sitting next to the existing Theme picker. Search-as-you-type combobox over the full IANA catalog plus an Auto detect option. - Selection persists in localStorage (`supabase-ui-timezone`) and survives `clearLocalStorage()`. No backend schema change. - New `lib/datetime.tsx` exposes pure timezone-aware formatters (`formatDateTime`, `formatDate`, `formatTime`, `formatFromNow`, `toTimezone`) plus a `TimezoneProvider` and matching React hooks (`useTimezone`, `useFormatDateTime`, ...). The pure functions take `tz` explicitly so they're easy to unit test (17 vitest cases covering DST transitions, multi-tz formatting, unix-micro/Date inputs, invalid-tz fallback). - The selected timezone propagates to every existing `<TimestampInfo>` in Studio via a new `TimestampInfoProvider` context exported from `ui-patterns`. No per-callsite changes needed for those ~20+ surfaces. - The `UnifiedLogs` date column migrates off `date-fns` to the new `useFormatDateTime` hook (the rest of the date-fns callers stay as-is, since they're either internal range math or non-display). - `ALL_TIMEZONES` (~600 entries) moves out of `PITR.constants.ts` into a shared `lib/constants/timezones.ts`. PITR keeps a re-export shim so its callers don't move. New `TIMEZONES_BY_IANA` dedupes the catalog by primary IANA name (the original list contains both PDT and PST rows for `America/Los_Angeles`, etc.) and `findTimezoneByIana` provides reverse lookup. - Telemetry: `timezone_picker_clicked` PostHog event with `previousTimezone`, `nextTimezone`, `isAutoDetected` properties. Notes for reviewers: - Bare `dayjs(x).format(...)` calls (~157 files) intentionally still render in browser-local time. Surfaces opt in by switching to the new wrappers, so this PR is the abstraction plus logs adoption; broader migration is a follow-up. - Two `// prettier-ignore` lines (`apps/studio/pages/_app.tsx`, `apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.fields.tsx`) work around a pre-existing local-tooling issue where `prettier-plugin-sql-cst` strips angle-bracket type arguments under certain conditions. Project's pinned prettier (3.8.1) does not strip; the issue surfaces with a globally-installed prettier. Worth tracking separately. - Hydration: `guessLocalTimezone()` and `useLocalStorageQuery` are client-only. Studio is mostly CSR via the Pages Router, but any SSR'd `<TimestampInfo>` may briefly render in the server's tz before client hydration. Existing behavior already had this mismatch with `.local()`; this PR does not regress it. - Backend timestamps round-tripped through query params and mutations stay UTC. The picker is display-only. ## How to test - Run `pnpm dev:studio`, sign in. - Open the user avatar dropdown (top right). Hover Timezone. - Search for "tokyo", pick `(UTC+09:00) Osaka, Sapporo, Tokyo`. - Open any project, navigate to Logs (e.g. `Project > Logs > Edge Functions`). Hover a log row's timestamp; the popover should show UTC, the chosen tz (`Asia/Tokyo`), and the relative time. Visible cell text should be in JST. - Visit any page that uses `<TimestampInfo>` (Database > Backups, Project Pause state, Edge Function details). Same tooltip should reflect Asia/Tokyo. - Refresh the page; timezone is still Asia/Tokyo. - Reopen the picker, choose Auto detect; timestamps revert to browser local. - Run `pnpm --filter studio test lib/datetime.test.ts`. 17 tests should pass. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Timezone selector added to the user menu with auto-detect and manual override * App-wide timezone provider and hooks plus a shared timezone catalog for consistent timezone-aware display * Timestamp components accept an optional timezone prop and respect user preference (persisted) * **Bug Fixes / Improvements** * Logs and timestamp displays now use the new timezone formatting hooks * **Tests** * Added comprehensive datetime and timezone catalog tests * **Telemetry** * Telemetry event added for timezone picker interactions <!-- end of auto-generated comment: release notes by coderabbit.ai -->
186 lines
5.9 KiB
TypeScript
186 lines
5.9 KiB
TypeScript
import { LOCAL_STORAGE_KEYS } from 'common'
|
|
import dayjs, { type Dayjs } from 'dayjs'
|
|
import relativeTime from 'dayjs/plugin/relativeTime'
|
|
import timezone from 'dayjs/plugin/timezone'
|
|
import utc from 'dayjs/plugin/utc'
|
|
import { createContext, useCallback, useContext, useEffect, useMemo, type ReactNode } from 'react'
|
|
|
|
import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage'
|
|
import { guessLocalTimezone } from '@/lib/dayjs'
|
|
|
|
// dayjs.extend is idempotent. Extending here removes the implicit dependency
|
|
// on _app.tsx running first (e.g. Storybook, isolated scripts).
|
|
dayjs.extend(utc)
|
|
dayjs.extend(timezone)
|
|
dayjs.extend(relativeTime)
|
|
|
|
export type DateInput = string | number | Date | Dayjs
|
|
|
|
const isUnixMicro = (value: string | number): boolean => {
|
|
const digits = String(value).length
|
|
const isNum = !Number.isNaN(Number(value))
|
|
return isNum && digits === 16
|
|
}
|
|
|
|
const unixMicroToIso = (value: string | number): string =>
|
|
dayjs.unix(Number(value) / 1_000_000).toISOString()
|
|
|
|
const normalize = (input: DateInput): Dayjs => {
|
|
if (dayjs.isDayjs(input)) return input
|
|
if (input instanceof Date) return dayjs(input)
|
|
if ((typeof input === 'string' || typeof input === 'number') && isUnixMicro(input)) {
|
|
return dayjs.utc(unixMicroToIso(input))
|
|
}
|
|
return dayjs.utc(input)
|
|
}
|
|
|
|
const isValidTimezone = (tz: string): boolean => {
|
|
try {
|
|
Intl.DateTimeFormat(undefined, { timeZone: tz })
|
|
return true
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Resolve a user-supplied timezone to a valid IANA name. Falls back to the
|
|
* browser's guessed timezone, then UTC. Pass `undefined`/empty string to opt
|
|
* into the guessed default.
|
|
*/
|
|
export const resolveTimezone = (tz: string | undefined | null): string => {
|
|
if (tz && isValidTimezone(tz)) return tz
|
|
return guessLocalTimezone()
|
|
}
|
|
|
|
const DEFAULT_DATETIME_FORMAT = 'DD MMM YYYY HH:mm:ss'
|
|
const DEFAULT_DATE_FORMAT = 'DD MMM YYYY'
|
|
const DEFAULT_TIME_FORMAT = 'HH:mm:ss'
|
|
|
|
interface FormatOptions {
|
|
/** IANA timezone (e.g. 'Asia/Tokyo'). Falls back to guessed local. */
|
|
tz?: string
|
|
/** dayjs format string. */
|
|
format?: string
|
|
}
|
|
|
|
export const formatDateTime = (input: DateInput, opts: FormatOptions = {}): string =>
|
|
normalize(input)
|
|
.tz(resolveTimezone(opts.tz))
|
|
.format(opts.format ?? DEFAULT_DATETIME_FORMAT)
|
|
|
|
export const formatDate = (input: DateInput, opts: FormatOptions = {}): string =>
|
|
normalize(input)
|
|
.tz(resolveTimezone(opts.tz))
|
|
.format(opts.format ?? DEFAULT_DATE_FORMAT)
|
|
|
|
export const formatTime = (input: DateInput, opts: FormatOptions = {}): string =>
|
|
normalize(input)
|
|
.tz(resolveTimezone(opts.tz))
|
|
.format(opts.format ?? DEFAULT_TIME_FORMAT)
|
|
|
|
/** Returns a humanised relative time, e.g. "3 minutes ago". */
|
|
export const formatFromNow = (input: DateInput): string => normalize(input).fromNow()
|
|
|
|
/** Returns the input as a Dayjs instance pinned to the given timezone. */
|
|
export const toTimezone = (input: DateInput, tz?: string): Dayjs =>
|
|
normalize(input).tz(resolveTimezone(tz))
|
|
|
|
interface TimezoneContextValue {
|
|
/** The resolved IANA timezone currently in use. Always valid. */
|
|
timezone: string
|
|
/** The user's stored preference. Empty string means "use guessed local". */
|
|
storedTimezone: string
|
|
/** Update the stored preference. Pass an empty string to clear (use guessed). */
|
|
setTimezone: (tz: string) => void
|
|
/** Whether the current selection is the auto-detected default. */
|
|
isAutoDetected: boolean
|
|
}
|
|
|
|
const TimezoneContext = createContext<TimezoneContextValue | undefined>(undefined)
|
|
|
|
export const TimezoneProvider = ({ children }: { children: ReactNode }) => {
|
|
const [storedTimezone, setStoredTimezone] = useLocalStorageQuery<string>(
|
|
LOCAL_STORAGE_KEYS.UI_TIMEZONE,
|
|
''
|
|
)
|
|
|
|
const timezone = useMemo(() => resolveTimezone(storedTimezone), [storedTimezone])
|
|
|
|
// Apply the selected timezone as the dayjs default so anything calling
|
|
// `dayjs.tz()` or `.tz()` without an argument picks it up. Bare `dayjs()`
|
|
// calls are unaffected by design — those continue to render in the host
|
|
// browser's timezone until they're intentionally migrated to the wrappers
|
|
// below.
|
|
useEffect(() => {
|
|
dayjs.tz.setDefault(timezone)
|
|
}, [timezone])
|
|
|
|
const setTimezone = useCallback(
|
|
(tz: string) => {
|
|
setStoredTimezone(tz)
|
|
},
|
|
[setStoredTimezone]
|
|
)
|
|
|
|
const value = useMemo<TimezoneContextValue>(
|
|
() => ({
|
|
timezone,
|
|
storedTimezone,
|
|
setTimezone,
|
|
isAutoDetected: !storedTimezone,
|
|
}),
|
|
[timezone, storedTimezone, setTimezone]
|
|
)
|
|
|
|
return <TimezoneContext.Provider value={value}>{children}</TimezoneContext.Provider>
|
|
}
|
|
|
|
// Stable fallback so callers outside the provider (e.g. unit tests, isolated
|
|
// stories) don't get a fresh object identity every render.
|
|
const NO_OP_SET_TIMEZONE = () => {}
|
|
|
|
export const useTimezone = (): TimezoneContextValue => {
|
|
const ctx = useContext(TimezoneContext)
|
|
return useMemo<TimezoneContextValue>(
|
|
() =>
|
|
ctx ?? {
|
|
timezone: guessLocalTimezone(),
|
|
storedTimezone: '',
|
|
setTimezone: NO_OP_SET_TIMEZONE,
|
|
isAutoDetected: true,
|
|
},
|
|
[ctx]
|
|
)
|
|
}
|
|
|
|
/** Returns a memoised `(input, format?) => string` bound to the active timezone. */
|
|
export const useFormatDateTime = () => {
|
|
const { timezone } = useTimezone()
|
|
return useCallback(
|
|
(input: DateInput, format?: string) => formatDateTime(input, { tz: timezone, format }),
|
|
[timezone]
|
|
)
|
|
}
|
|
|
|
export const useFormatDate = () => {
|
|
const { timezone } = useTimezone()
|
|
return useCallback(
|
|
(input: DateInput, format?: string) => formatDate(input, { tz: timezone, format }),
|
|
[timezone]
|
|
)
|
|
}
|
|
|
|
export const useFormatTime = () => {
|
|
const { timezone } = useTimezone()
|
|
return useCallback(
|
|
(input: DateInput, format?: string) => formatTime(input, { tz: timezone, format }),
|
|
[timezone]
|
|
)
|
|
}
|
|
|
|
export const useToTimezone = () => {
|
|
const { timezone } = useTimezone()
|
|
return useCallback((input: DateInput) => toTimezone(input, timezone), [timezone])
|
|
}
|