mirror of
https://github.com/supabase/supabase.git
synced 2026-05-22 17:00:43 +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 -->
123 lines
3.9 KiB
TypeScript
123 lines
3.9 KiB
TypeScript
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
|
|
|
|
import {
|
|
formatDate,
|
|
formatDateTime,
|
|
formatFromNow,
|
|
formatTime,
|
|
resolveTimezone,
|
|
toTimezone,
|
|
} from '@/lib/datetime'
|
|
|
|
// Fixed reference points so the assertions don't depend on the host machine's
|
|
// timezone or current wall-clock time.
|
|
const SUMMER_UTC = '2025-06-15T12:34:56Z'
|
|
const WINTER_UTC = '2025-01-15T12:34:56Z'
|
|
// 16-digit unix microseconds equivalent of SUMMER_UTC.
|
|
const SUMMER_UNIX_MICRO = '1749990896000000'
|
|
|
|
describe('formatDateTime', () => {
|
|
it('renders a UTC instant in Asia/Tokyo (UTC+9, no DST)', () => {
|
|
expect(formatDateTime(SUMMER_UTC, { tz: 'Asia/Tokyo' })).toBe('15 Jun 2025 21:34:56')
|
|
})
|
|
|
|
it('renders a UTC instant in America/Los_Angeles during DST (UTC-7)', () => {
|
|
expect(formatDateTime(SUMMER_UTC, { tz: 'America/Los_Angeles' })).toBe('15 Jun 2025 05:34:56')
|
|
})
|
|
|
|
it('renders a UTC instant in America/Los_Angeles outside DST (UTC-8)', () => {
|
|
expect(formatDateTime(WINTER_UTC, { tz: 'America/Los_Angeles' })).toBe('15 Jan 2025 04:34:56')
|
|
})
|
|
|
|
it('flips the wall-clock day when crossing date boundaries', () => {
|
|
const lateUtc = '2025-06-15T22:00:00Z'
|
|
expect(formatDateTime(lateUtc, { tz: 'Asia/Tokyo', format: 'YYYY-MM-DD' })).toBe('2025-06-16')
|
|
expect(formatDateTime(lateUtc, { tz: 'Pacific/Honolulu', format: 'YYYY-MM-DD' })).toBe(
|
|
'2025-06-15'
|
|
)
|
|
})
|
|
|
|
it('respects an explicit format string', () => {
|
|
expect(formatDateTime(SUMMER_UTC, { tz: 'UTC', format: 'YYYY-MM-DDTHH:mm:ssZ' })).toBe(
|
|
'2025-06-15T12:34:56+00:00'
|
|
)
|
|
})
|
|
|
|
it('accepts unix microsecond timestamps', () => {
|
|
expect(formatDateTime(SUMMER_UNIX_MICRO, { tz: 'UTC' })).toBe('15 Jun 2025 12:34:56')
|
|
})
|
|
|
|
it('accepts Date instances', () => {
|
|
expect(formatDateTime(new Date(SUMMER_UTC), { tz: 'UTC' })).toBe('15 Jun 2025 12:34:56')
|
|
})
|
|
})
|
|
|
|
describe('DST transitions in Europe/Berlin', () => {
|
|
it('shows +01:00 before the spring transition', () => {
|
|
expect(formatDateTime('2025-03-30T00:00:00Z', { tz: 'Europe/Berlin', format: 'HH:mm Z' })).toBe(
|
|
'01:00 +01:00'
|
|
)
|
|
})
|
|
|
|
it('shows +02:00 after the spring transition', () => {
|
|
expect(formatDateTime('2025-03-30T02:00:00Z', { tz: 'Europe/Berlin', format: 'HH:mm Z' })).toBe(
|
|
'04:00 +02:00'
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('formatDate / formatTime', () => {
|
|
it('formatDate uses the date-only default', () => {
|
|
expect(formatDate(SUMMER_UTC, { tz: 'UTC' })).toBe('15 Jun 2025')
|
|
})
|
|
|
|
it('formatTime uses the time-only default', () => {
|
|
expect(formatTime(SUMMER_UTC, { tz: 'UTC' })).toBe('12:34:56')
|
|
})
|
|
})
|
|
|
|
describe('resolveTimezone', () => {
|
|
it('returns the input when it is a valid IANA name', () => {
|
|
expect(resolveTimezone('Asia/Tokyo')).toBe('Asia/Tokyo')
|
|
})
|
|
|
|
it('falls back to the guessed local timezone when input is empty', () => {
|
|
expect(resolveTimezone('')).toBeTruthy()
|
|
expect(resolveTimezone(null)).toBeTruthy()
|
|
expect(resolveTimezone(undefined)).toBeTruthy()
|
|
})
|
|
|
|
it('falls back when the input is not a real IANA zone', () => {
|
|
// Should not throw, should resolve to something we can format with.
|
|
const tz = resolveTimezone('Not/A/Real_Zone')
|
|
expect(() => formatDateTime(SUMMER_UTC, { tz })).not.toThrow()
|
|
})
|
|
})
|
|
|
|
describe('toTimezone', () => {
|
|
it('returns a Dayjs pinned to the given timezone for chained calls', () => {
|
|
const d = toTimezone(SUMMER_UTC, 'Asia/Tokyo')
|
|
expect(d.format('HH:mm')).toBe('21:34')
|
|
expect(d.hour()).toBe(21)
|
|
})
|
|
})
|
|
|
|
describe('formatFromNow', () => {
|
|
beforeAll(() => {
|
|
vi.useFakeTimers()
|
|
vi.setSystemTime(new Date('2025-06-15T13:34:56Z'))
|
|
})
|
|
|
|
afterAll(() => {
|
|
vi.useRealTimers()
|
|
})
|
|
|
|
it('renders ISO inputs as a relative duration', () => {
|
|
expect(formatFromNow(SUMMER_UTC)).toBe('an hour ago')
|
|
})
|
|
|
|
it('accepts unix microsecond inputs', () => {
|
|
expect(formatFromNow(SUMMER_UNIX_MICRO)).toBe('an hour ago')
|
|
})
|
|
})
|