Files
supabase/apps/studio/components/interfaces/UserDropdown/TimezoneDropdown.tsx
Jordi Enric d8bb0ade65 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 -->
2026-05-06 14:52:36 +02:00

114 lines
4.2 KiB
TypeScript

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>
)
}