TimezoneSettings.tsx163 lines · main
1import { useFlag } from 'common'
2import { CheckIcon, ChevronsUpDown, Globe } from 'lucide-react'
3import { useId, useMemo, useState } from 'react'
4import {
5 Button,
6 Card,
7 CardContent,
8 cn,
9 Command,
10 CommandEmpty,
11 CommandGroup,
12 CommandInput,
13 CommandItem,
14 CommandList,
15 Popover,
16 PopoverContent,
17 PopoverTrigger,
18 ScrollArea,
19} from 'ui'
20import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
21import {
22 PageSection,
23 PageSectionContent,
24 PageSectionDescription,
25 PageSectionMeta,
26 PageSectionSummary,
27 PageSectionTitle,
28} from 'ui-patterns/PageSection'
29
30import { findTimezoneByIana, TIMEZONES_BY_IANA } from '@/lib/constants/timezones'
31import { useTimezone } from '@/lib/datetime'
32import { guessLocalTimezone } from '@/lib/dayjs'
33import { useTrack } from '@/lib/telemetry/track'
34
35const AUTO_OPTION_VALUE = '__auto__'
36
37export const TimezoneSettings = () => {
38 const timezonePickerEnabled = useFlag('timezonePicker')
39 const { timezone, storedTimezone, setTimezone, isAutoDetected } = useTimezone()
40 const track = useTrack()
41 const [open, setOpen] = useState(false)
42 const listboxId = useId()
43
44 // Browser timezone is captured once and stays stable even when the user has
45 // overridden the dashboard timezone — that's the value the "Auto detect"
46 // option will revert to.
47 const browserTimezone = useMemo(() => guessLocalTimezone(), [])
48
49 const triggerLabel = useMemo(() => findTimezoneByIana(timezone)?.text ?? timezone, [timezone])
50
51 if (!timezonePickerEnabled) return null
52
53 const handleSelect = (nextStored: string) => {
54 setTimezone(nextStored)
55 const resolvedNext = nextStored || guessLocalTimezone()
56 track('timezone_picker_clicked', {
57 previousTimezone: timezone,
58 nextTimezone: resolvedNext,
59 isAutoDetected: nextStored === '',
60 source: 'account_preferences',
61 })
62 setOpen(false)
63 }
64
65 return (
66 <PageSection>
67 <PageSectionMeta>
68 <PageSectionSummary>
69 <PageSectionTitle>Timezone</PageSectionTitle>
70 <PageSectionDescription>
71 Choose how dates and times in logs and other dashboard surfaces are displayed.
72 </PageSectionDescription>
73 </PageSectionSummary>
74 </PageSectionMeta>
75 <PageSectionContent>
76 <Card>
77 <CardContent>
78 <FormItemLayout
79 isReactForm={false}
80 label="Display timezone"
81 layout="flex-row-reverse"
82 description={
83 isAutoDetected
84 ? `Auto detected from your browser (${browserTimezone}).`
85 : 'Pick "Auto detect" to follow your browser timezone again.'
86 }
87 >
88 <Popover open={open} onOpenChange={setOpen}>
89 <PopoverTrigger asChild>
90 <Button
91 role="combobox"
92 aria-expanded={open}
93 aria-controls={listboxId}
94 className="w-full justify-between"
95 type="default"
96 size="small"
97 icon={<Globe />}
98 iconRight={<ChevronsUpDown size={14} strokeWidth={1.5} />}
99 >
100 <span className="truncate text-left">
101 {isAutoDetected ? `Auto detect (${timezone})` : triggerLabel}
102 </span>
103 </Button>
104 </PopoverTrigger>
105 <PopoverContent id={listboxId} className="w-[--radix-popover-trigger-width] p-0">
106 <Command>
107 <CommandInput placeholder="Search timezone..." className="h-9" />
108 <CommandList>
109 <CommandEmpty>No timezones found</CommandEmpty>
110 <CommandGroup>
111 <ScrollArea className="h-72">
112 <CommandItem
113 key={AUTO_OPTION_VALUE}
114 value={`Auto detect ${browserTimezone}`}
115 onSelect={() => handleSelect('')}
116 >
117 <div className="flex flex-col">
118 <span>Auto detect</span>
119 <span className="text-xs text-foreground-lighter">
120 {browserTimezone}
121 </span>
122 </div>
123 <CheckIcon
124 className={cn(
125 'ml-auto h-4 w-4',
126 isAutoDetected ? 'opacity-100' : 'opacity-0'
127 )}
128 />
129 </CommandItem>
130 {TIMEZONES_BY_IANA.map((entry) => {
131 const ianaName = entry.utc[0]
132 const isSelected = !isAutoDetected && storedTimezone === ianaName
133 return (
134 <CommandItem
135 key={ianaName}
136 // CommandItem matches against the `value` prop for the input filter — include
137 // both the human label and the IANA name so search works for either.
138 value={`${entry.text} ${ianaName}`}
139 onSelect={() => handleSelect(ianaName)}
140 >
141 {entry.text}
142 <CheckIcon
143 className={cn(
144 'ml-auto h-4 w-4',
145 isSelected ? 'opacity-100' : 'opacity-0'
146 )}
147 />
148 </CommandItem>
149 )
150 })}
151 </ScrollArea>
152 </CommandGroup>
153 </CommandList>
154 </Command>
155 </PopoverContent>
156 </Popover>
157 </FormItemLayout>
158 </CardContent>
159 </Card>
160 </PageSectionContent>
161 </PageSection>
162 )
163}