feat(studio): add timezone picker to user dropdown (#45517)

## 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 -->
This commit is contained in:
Jordi Enric
2026-05-06 14:52:36 +02:00
committed by GitHub
parent d859176eac
commit d8bb0ade65
16 changed files with 2051 additions and 1265 deletions
@@ -0,0 +1,166 @@
import { useFlag } from 'common'
import { CheckIcon, ChevronsUpDown, Globe } from 'lucide-react'
import { useId, useMemo, useState } from 'react'
import {
Button,
Card,
CardContent,
cn,
Command_Shadcn_,
CommandEmpty_Shadcn_,
CommandGroup_Shadcn_,
CommandInput_Shadcn_,
CommandItem_Shadcn_,
CommandList_Shadcn_,
Popover_Shadcn_,
PopoverContent_Shadcn_,
PopoverTrigger_Shadcn_,
ScrollArea,
} from 'ui'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import {
PageSection,
PageSectionContent,
PageSectionDescription,
PageSectionMeta,
PageSectionSummary,
PageSectionTitle,
} from 'ui-patterns/PageSection'
import { findTimezoneByIana, TIMEZONES_BY_IANA } from '@/lib/constants/timezones'
import { useTimezone } from '@/lib/datetime'
import { guessLocalTimezone } from '@/lib/dayjs'
import { useTrack } from '@/lib/telemetry/track'
const AUTO_OPTION_VALUE = '__auto__'
export const TimezoneSettings = () => {
const timezonePickerEnabled = useFlag('timezonePicker')
const { timezone, storedTimezone, setTimezone, isAutoDetected } = useTimezone()
const track = useTrack()
const [open, setOpen] = useState(false)
const listboxId = useId()
// Browser timezone is captured once and stays stable even when the user has
// overridden the dashboard timezone — that's the value the "Auto detect"
// option will revert to.
const browserTimezone = useMemo(() => guessLocalTimezone(), [])
const triggerLabel = useMemo(() => findTimezoneByIana(timezone)?.text ?? timezone, [timezone])
if (!timezonePickerEnabled) return null
const handleSelect = (nextStored: string) => {
setTimezone(nextStored)
const resolvedNext = nextStored || guessLocalTimezone()
track('timezone_picker_clicked', {
previousTimezone: timezone,
nextTimezone: resolvedNext,
isAutoDetected: nextStored === '',
source: 'account_preferences',
})
setOpen(false)
}
return (
<PageSection>
<PageSectionMeta>
<PageSectionSummary>
<PageSectionTitle>Timezone</PageSectionTitle>
<PageSectionDescription>
Choose how dates and times in logs and other dashboard surfaces are displayed.
</PageSectionDescription>
</PageSectionSummary>
</PageSectionMeta>
<PageSectionContent>
<Card>
<CardContent>
<FormItemLayout
isReactForm={false}
label="Display timezone"
layout="flex-row-reverse"
description={
isAutoDetected
? `Auto detected from your browser (${browserTimezone}).`
: 'Pick "Auto detect" to follow your browser timezone again.'
}
>
<Popover_Shadcn_ open={open} onOpenChange={setOpen}>
<PopoverTrigger_Shadcn_ asChild>
<Button
role="combobox"
aria-expanded={open}
aria-controls={listboxId}
className="w-full justify-between"
type="default"
size="small"
icon={<Globe />}
iconRight={<ChevronsUpDown size={14} strokeWidth={1.5} />}
>
<span className="truncate text-left">
{isAutoDetected ? `Auto detect (${timezone})` : triggerLabel}
</span>
</Button>
</PopoverTrigger_Shadcn_>
<PopoverContent_Shadcn_
id={listboxId}
className="w-[--radix-popover-trigger-width] p-0"
>
<Command_Shadcn_>
<CommandInput_Shadcn_ placeholder="Search timezone..." className="h-9" />
<CommandList_Shadcn_>
<CommandEmpty_Shadcn_>No timezones found</CommandEmpty_Shadcn_>
<CommandGroup_Shadcn_>
<ScrollArea className="h-72">
<CommandItem_Shadcn_
key={AUTO_OPTION_VALUE}
value={`Auto detect ${browserTimezone}`}
onSelect={() => handleSelect('')}
>
<div className="flex flex-col">
<span>Auto detect</span>
<span className="text-xs text-foreground-lighter">
{browserTimezone}
</span>
</div>
<CheckIcon
className={cn(
'ml-auto h-4 w-4',
isAutoDetected ? 'opacity-100' : 'opacity-0'
)}
/>
</CommandItem_Shadcn_>
{TIMEZONES_BY_IANA.map((entry) => {
const ianaName = entry.utc[0]
const isSelected = !isAutoDetected && storedTimezone === ianaName
return (
<CommandItem_Shadcn_
key={ianaName}
// CommandItem matches against the `value` prop for the input filter — include
// both the human label and the IANA name so search works for either.
value={`${entry.text} ${ianaName}`}
onSelect={() => handleSelect(ianaName)}
>
{entry.text}
<CheckIcon
className={cn(
'ml-auto h-4 w-4',
isSelected ? 'opacity-100' : 'opacity-0'
)}
/>
</CommandItem_Shadcn_>
)
})}
</ScrollArea>
</CommandGroup_Shadcn_>
</CommandList_Shadcn_>
</Command_Shadcn_>
</PopoverContent_Shadcn_>
</Popover_Shadcn_>
</FormItemLayout>
</CardContent>
</Card>
</PageSectionContent>
</PageSection>
)
}
File diff suppressed because it is too large Load Diff
@@ -1,4 +1,3 @@
import { format } from 'date-fns'
import { User } from 'lucide-react'
import { cn } from 'ui'
@@ -9,6 +8,27 @@ import { getLevelLabel } from './UnifiedLogs.utils'
import { LEVELS } from '@/components/ui/DataTable/DataTable.constants'
import { DataTableFilterField, Option } from '@/components/ui/DataTable/DataTable.types'
import { getLevelColor } from '@/components/ui/DataTable/DataTable.utils'
import { useFormatDateTime } from '@/lib/datetime'
const DateCell = (props: { date: ColumnSchema['date'] }) => {
const formatDateTime = useFormatDateTime()
const month = formatDateTime(props.date, 'MMM')
const day = formatDateTime(props.date, 'DD')
const year = formatDateTime(props.date, 'YYYY')
const time = formatDateTime(props.date, 'HH:mm:ss')
return (
<div className="font-mono whitespace-nowrap flex items-center gap-1 justify-end">
<span>{month}</span>
<span className="text-foreground/50">·</span>
<span>{day}</span>
<span className="text-foreground/50">·</span>
<span>{year}</span>
<span className="text-foreground/50">·</span>
<span>{time}</span>
</div>
)
}
// instead of filterFields, maybe just 'fields' with a filterDisabled prop?
// that way, we could have 'message' or 'headers' field with label and value as well as type!
@@ -127,25 +147,7 @@ export const sheetFields = [
id: 'date',
label: 'Date',
type: 'timerange',
component: (props) => {
const date = new Date(props.date)
const month = format(date, 'LLL')
const day = format(date, 'dd')
const year = format(date, 'y')
const time = format(date, 'HH:mm:ss')
return (
<div className="font-mono whitespace-nowrap flex items-center gap-1 justify-end">
<span>{month}</span>
<span className="text-foreground/50">·</span>
<span>{day}</span>
<span className="text-foreground/50">·</span>
<span>{year}</span>
<span className="text-foreground/50">·</span>
<span>{time}</span>
</div>
)
},
component: DateCell,
skeletonClassName: 'w-36',
},
{
@@ -1,3 +1,4 @@
import { useFlag } from 'common'
import { FlaskConical, Loader2, ScrollText, Settings } from 'lucide-react'
import { useTheme } from 'next-themes'
import Link from 'next/link'
@@ -19,6 +20,7 @@ import {
import { ButtonTooltip } from '../ui/ButtonTooltip'
import { useFeaturePreviewModal } from './App/FeaturePreview/FeaturePreviewContext'
import { TimezoneDropdown } from './UserDropdown/TimezoneDropdown'
import { ProfileImage } from '@/components/ui/ProfileImage'
import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
import { IS_PLATFORM } from '@/lib/constants'
@@ -37,6 +39,7 @@ export function UserDropdown({
const { theme, setTheme } = useTheme()
const appStateSnapshot = useAppStateSnapshot()
const profileShowEmailEnabled = useIsFeatureEnabled('profile:show_email')
const timezonePickerEnabled = useFlag('timezonePicker')
const { username, avatarUrl, primaryEmail, isLoading } = useProfileNameAndPicture()
const { toggleFeaturePreviewModal } = useFeaturePreviewModal()
@@ -144,6 +147,14 @@ export function UserDropdown({
))}
</DropdownMenuRadioGroup>
</DropdownMenuGroup>
{timezonePickerEnabled && (
<>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<TimezoneDropdown />
</DropdownMenuGroup>
</>
)}
{IS_PLATFORM && (
<>
<DropdownMenuSeparator />
@@ -0,0 +1,113 @@
import { CheckIcon } from 'lucide-react'
import { useMemo, useState } from 'react'
import {
cn,
Command_Shadcn_,
CommandEmpty_Shadcn_,
CommandGroup_Shadcn_,
CommandInput_Shadcn_,
CommandItem_Shadcn_,
CommandList_Shadcn_,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
ScrollArea,
} from 'ui'
import { findTimezoneByIana, TIMEZONES_BY_IANA } from '@/lib/constants/timezones'
import { useTimezone } from '@/lib/datetime'
import { guessLocalTimezone } from '@/lib/dayjs'
import { useTrack } from '@/lib/telemetry/track'
const AUTO_OPTION_VALUE = '__auto__'
export const TimezoneDropdown = () => {
const { timezone, storedTimezone, setTimezone, isAutoDetected } = useTimezone()
const track = useTrack()
const [open, setOpen] = useState(false)
// The "Auto detect" row always advertises the browser's own timezone, even
// when the user is currently overriding it with a manual pick.
const browserTimezone = useMemo(() => guessLocalTimezone(), [])
const triggerLabel = useMemo(() => {
return findTimezoneByIana(timezone)?.text ?? timezone
}, [timezone])
const handleSelect = (nextStored: string) => {
setTimezone(nextStored)
const resolvedNext = nextStored || guessLocalTimezone()
track('timezone_picker_clicked', {
previousTimezone: timezone,
nextTimezone: resolvedNext,
isAutoDetected: nextStored === '',
source: 'user_dropdown',
})
setOpen(false)
}
return (
<DropdownMenuSub open={open} onOpenChange={setOpen}>
<DropdownMenuSubTrigger className="flex gap-2 cursor-pointer">
<div className="flex flex-col min-w-0">
<span>Timezone</span>
<span className="text-xs text-foreground-lighter truncate" title={triggerLabel}>
{isAutoDetected ? `Auto (${timezone})` : triggerLabel}
</span>
</div>
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent className="p-0 w-[320px]" sideOffset={4}>
<Command_Shadcn_>
<CommandInput_Shadcn_ placeholder="Search timezone..." className="h-9" />
<CommandList_Shadcn_>
<CommandEmpty_Shadcn_>No timezones found</CommandEmpty_Shadcn_>
<CommandGroup_Shadcn_>
<ScrollArea className="h-72">
<CommandItem_Shadcn_
key={AUTO_OPTION_VALUE}
value={`Auto detect ${browserTimezone}`}
onSelect={() => handleSelect('')}
>
<div className="flex flex-col">
<span>Auto detect</span>
<span className="text-xs text-foreground-lighter">{browserTimezone}</span>
</div>
<CheckIcon
className={cn(
'ml-auto h-4 w-4',
isAutoDetected ? 'opacity-100' : 'opacity-0'
)}
/>
</CommandItem_Shadcn_>
{TIMEZONES_BY_IANA.map((entry) => {
const ianaName = entry.utc[0]
const isSelected = !isAutoDetected && storedTimezone === ianaName
return (
<CommandItem_Shadcn_
key={ianaName}
// CommandItem matches against the `value` prop for the input filter — include
// both the human label and the IANA name so search works for either.
value={`${entry.text} ${ianaName}`}
onSelect={() => handleSelect(ianaName)}
>
{entry.text}
<CheckIcon
className={cn(
'ml-auto h-4 w-4',
isSelected ? 'opacity-100' : 'opacity-0'
)}
/>
</CommandItem_Shadcn_>
)
})}
</ScrollArea>
</CommandGroup_Shadcn_>
</CommandList_Shadcn_>
</Command_Shadcn_>
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
)
}
@@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest'
import { ALL_TIMEZONES, findTimezoneByIana, TIMEZONES_BY_IANA } from '@/lib/constants/timezones'
describe('TIMEZONES_BY_IANA', () => {
it('produces one row per primary IANA name', () => {
const ianas = TIMEZONES_BY_IANA.map((entry) => entry.utc[0])
expect(new Set(ianas).size).toBe(ianas.length)
})
it('prefers the standard-time row when multiple catalog rows share a primary IANA', () => {
// ALL_TIMEZONES has both PDT (isdst: true) and PST (isdst: false) pointing
// at America/Los_Angeles. The deduped view should pick the standard one
// so the picker label doesn't flip on DST changes.
const collisions = ALL_TIMEZONES.filter((entry) => entry.utc[0] === 'America/Los_Angeles')
expect(collisions.length).toBeGreaterThan(1)
const winner = TIMEZONES_BY_IANA.find((entry) => entry.utc[0] === 'America/Los_Angeles')
expect(winner).toBeDefined()
expect(winner!.isdst).toBe(false)
})
it('preserves entries that have no collision', () => {
// Most rows have a unique primary IANA. The UTC catalog row's primary is
// 'America/Danmarkshavn' and survives the dedupe pass unchanged.
const utcRow = TIMEZONES_BY_IANA.find((entry) => entry.value === 'UTC')
expect(utcRow?.text).toContain('Coordinated Universal Time')
expect(utcRow?.utc[0]).toBe('America/Danmarkshavn')
})
})
describe('findTimezoneByIana', () => {
it('matches the entry by its primary IANA name', () => {
const entry = findTimezoneByIana('America/Danmarkshavn')
expect(entry?.text).toContain('Coordinated Universal Time')
})
it('matches the entry by any of its secondary IANA names', () => {
// 'Asia/Tokyo' is one of the IANA aliases on the JST row whose primary
// IANA is 'Asia/Dili'. Lookup must walk the full alias list.
const entry = findTimezoneByIana('Asia/Tokyo')
expect(entry?.utc).toContain('Asia/Tokyo')
})
it('returns undefined for an unknown IANA name', () => {
expect(findTimezoneByIana('Not/A/Real_Zone')).toBeUndefined()
})
})
File diff suppressed because it is too large Load Diff
+122
View File
@@ -0,0 +1,122 @@
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')
})
})
+185
View File
@@ -0,0 +1,185 @@
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])
}
+63 -52
View File
@@ -39,6 +39,7 @@ import { NuqsAdapter } from 'nuqs/adapters/next/pages'
import { ErrorInfo, useCallback, type ComponentProps } from 'react'
import { ErrorBoundary } from 'react-error-boundary'
import { TooltipProvider } from 'ui'
import { TimestampInfoProvider } from 'ui-patterns'
import { StudioCommandMenu } from '@/components/interfaces/App/CommandMenu'
import { StudioCommandProvider as CommandProvider } from '@/components/interfaces/App/CommandMenu/StudioCommandProvider'
@@ -57,6 +58,7 @@ import { useCustomContent } from '@/hooks/custom-content/useCustomContent'
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
import { AuthProvider } from '@/lib/auth'
import { API_URL, BASE_PATH, IS_PLATFORM, useDefaultProvider } from '@/lib/constants'
import { TimezoneProvider, useTimezone } from '@/lib/datetime'
import { ProfileProvider } from '@/lib/profile'
import { Telemetry } from '@/lib/telemetry'
import { Toaster } from '@/lib/toaster'
@@ -96,6 +98,11 @@ const FeatureFlagProviderWithOrgContext = ({
)
}
const TimestampInfoTimezoneBridge = ({ children }: { children: React.ReactNode }) => {
const { timezone } = useTimezone()
return <TimestampInfoProvider timezone={timezone}>{children}</TimestampInfoProvider>
}
loader.config({
// [Joshen] Attempt for offline support/bypass ISP issues is to store the assets required for monaco
// locally. We're however, only storing the assets which we need (based on what the network tab loads
@@ -159,58 +166,62 @@ function CustomApp({ Component, pageProps }: AppPropsWithLayout) {
getConfigCatFlags={getConfigCatFlags}
>
<ProfileProvider>
<Head>
<title>{appTitle ?? 'Supabase'}</title>
<meta name="viewport" content="initial-scale=1.0, width=device-width" />
<meta property="og:image" content={`${BASE_PATH}/img/supabase-logo.png`} />
<meta name="googlebot" content="notranslate" />
{/* [Alaister]: This has to be an inline style tag here and not a separate component due to next/font */}
<style
dangerouslySetInnerHTML={{
__html: `:root{--font-custom:${customFont.style.fontFamily};--font-source-code-pro:${sourceCodePro.style.fontFamily};}`,
}}
/>
{/* Speed up initial API loading times by pre-connecting to the API domain */}
{IS_PLATFORM && (
<link
rel="preconnect"
href={new URL(API_URL).origin}
crossOrigin="use-credentials"
/>
)}
</Head>
<MetaFaviconsPagesRouter applicationName="Supabase Studio" includeManifest />
<TooltipProvider delayDuration={0}>
<RouteValidationWrapper>
<ThemeProvider>
<DevToolbarProvider apiUrl={API_URL}>
<AiAssistantStateContextProvider>
<CommandProvider>
<BannerStackProvider>
<FeaturePreviewContextProvider>
<MainScrollContainerProvider>
{getLayout(<Component {...pageProps} />)}
</MainScrollContainerProvider>
<GlobalShortcuts />
<StudioCommandMenu />
<FeaturePreviewModal />
<UpdateBillingAddressModal />
</FeaturePreviewContextProvider>
</BannerStackProvider>
<Toaster />
<MonacoThemeProvider />
</CommandProvider>
</AiAssistantStateContextProvider>
<DevToolbar extraTabs={devToolbarExtraTabs} />
<DevToolbarTrigger />
</DevToolbarProvider>
</ThemeProvider>
</RouteValidationWrapper>
</TooltipProvider>
<Telemetry />
{!isTestEnv && (
<ReactQueryDevtools initialIsOpen={false} buttonPosition="bottom-left" />
)}
<TimezoneProvider>
<TimestampInfoTimezoneBridge>
<Head>
<title>{appTitle ?? 'Supabase'}</title>
<meta name="viewport" content="initial-scale=1.0, width=device-width" />
<meta property="og:image" content={`${BASE_PATH}/img/supabase-logo.png`} />
<meta name="googlebot" content="notranslate" />
{/* [Alaister]: This has to be an inline style tag here and not a separate component due to next/font */}
<style
dangerouslySetInnerHTML={{
__html: `:root{--font-custom:${customFont.style.fontFamily};--font-source-code-pro:${sourceCodePro.style.fontFamily};}`,
}}
/>
{/* Speed up initial API loading times by pre-connecting to the API domain */}
{IS_PLATFORM && (
<link
rel="preconnect"
href={new URL(API_URL).origin}
crossOrigin="use-credentials"
/>
)}
</Head>
<MetaFaviconsPagesRouter applicationName="Supabase Studio" includeManifest />
<TooltipProvider delayDuration={0}>
<RouteValidationWrapper>
<ThemeProvider>
<DevToolbarProvider apiUrl={API_URL}>
<AiAssistantStateContextProvider>
<CommandProvider>
<BannerStackProvider>
<FeaturePreviewContextProvider>
<MainScrollContainerProvider>
{getLayout(<Component {...pageProps} />)}
</MainScrollContainerProvider>
<GlobalShortcuts />
<StudioCommandMenu />
<FeaturePreviewModal />
<UpdateBillingAddressModal />
</FeaturePreviewContextProvider>
</BannerStackProvider>
<Toaster />
<MonacoThemeProvider />
</CommandProvider>
</AiAssistantStateContextProvider>
<DevToolbar extraTabs={devToolbarExtraTabs} />
<DevToolbarTrigger />
</DevToolbarProvider>
</ThemeProvider>
</RouteValidationWrapper>
</TooltipProvider>
<Telemetry />
{!isTestEnv && (
<ReactQueryDevtools initialIsOpen={false} buttonPosition="bottom-left" />
)}
</TimestampInfoTimezoneBridge>
</TimezoneProvider>
</ProfileProvider>
</FeatureFlagProviderWithOrgContext>
</AuthProvider>
+5
View File
@@ -25,6 +25,7 @@ import { DashboardSettings } from '@/components/interfaces/Account/Preferences/D
import { HotkeySettings } from '@/components/interfaces/Account/Preferences/HotkeySettings'
import { ProfileInformation } from '@/components/interfaces/Account/Preferences/ProfileInformation'
import { ThemeSettings } from '@/components/interfaces/Account/Preferences/ThemeSettings'
import { TimezoneSettings } from '@/components/interfaces/Account/Preferences/TimezoneSettings'
import AccountLayout from '@/components/layouts/AccountLayout/AccountLayout'
import { AppLayout } from '@/components/layouts/AppLayout/AppLayout'
import { DefaultLayout } from '@/components/layouts/DefaultLayout'
@@ -95,6 +96,8 @@ const PlatformPreferences = () => {
<ThemeSettings />
<TimezoneSettings />
<HotkeySettings />
<DashboardSettings />
@@ -191,6 +194,8 @@ const SelfHostedPreferences = () => {
<PageContainer size="small">
<ThemeSettings />
<TimezoneSettings />
<HotkeySettings />
<DashboardSettings />
@@ -57,6 +57,10 @@ vi.mock('@/components/interfaces/Account/Preferences/ThemeSettings', () => ({
ThemeSettings: () => <div>ThemeSettings</div>,
}))
vi.mock('@/components/interfaces/Account/Preferences/TimezoneSettings', () => ({
TimezoneSettings: () => <div>TimezoneSettings</div>,
}))
vi.mock('@/components/interfaces/Account/Preferences/HotkeySettings', () => ({
HotkeySettings: () => <div>HotkeySettings</div>,
}))
@@ -103,6 +107,7 @@ describe('/account/me', () => {
expect(screen.getByText('AccountIdentities')).toBeInTheDocument()
expect(screen.getByText('AccountConnections')).toBeInTheDocument()
expect(screen.getByText('ThemeSettings')).toBeInTheDocument()
expect(screen.getByText('TimezoneSettings')).toBeInTheDocument()
expect(screen.getByText('HotkeySettings')).toBeInTheDocument()
expect(screen.getByText('DashboardSettings')).toBeInTheDocument()
expect(screen.getByText('AnalyticsSettings')).toBeInTheDocument()
@@ -115,6 +120,7 @@ describe('/account/me', () => {
render(<PreferencesPage dehydratedState={{}} />)
expect(screen.getByText('ThemeSettings')).toBeInTheDocument()
expect(screen.getByText('TimezoneSettings')).toBeInTheDocument()
expect(screen.getByText('HotkeySettings')).toBeInTheDocument()
expect(screen.getByText('DashboardSettings')).toBeInTheDocument()
expect(screen.queryByText('ProfileInformation')).not.toBeInTheDocument()