Skip to content

Commit 6c56a21

Browse files
authored
improvement(settings): right-align timezone picker, order by popularity, drop tooltip (#5043)
* improvement(settings): right-align timezone picker, order by popularity, drop tooltip * improvement(settings): show human-friendly timezone labels in picker * improvement(settings): use (GMT±HH:MM) City timezone labels, offset-sorted * improvement(settings): sort timezones alphabetically by city per UX research
1 parent 4ec26a0 commit 6c56a21

3 files changed

Lines changed: 91 additions & 27 deletions

File tree

apps/sim/app/workspace/[workspaceId]/settings/components/general/general.tsx

Lines changed: 15 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ import { ANONYMOUS_USER_ID } from '@/lib/auth/constants'
2727
import { getEnv, isTruthy } from '@/lib/core/config/env'
2828
import { isHosted } from '@/lib/core/config/feature-flags'
2929
import { handleKeyboardActivation } from '@/lib/core/utils/keyboard'
30-
import { getBrowserTimezone, getSupportedTimezones } from '@/lib/core/utils/timezone'
30+
import { getBrowserTimezone, getTimezoneOptions } from '@/lib/core/utils/timezone'
3131
import { getBaseUrl } from '@/lib/core/utils/urls'
3232
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
3333
import { useProfilePictureUpload } from '@/app/workspace/[workspaceId]/settings/hooks/use-profile-picture-upload'
@@ -42,11 +42,8 @@ import { clearUserData } from '@/stores'
4242

4343
const logger = createLogger('General')
4444

45-
/** IANA zones for the timezone picker; labels drop underscores so search reads naturally. */
46-
const TIMEZONE_OPTIONS = getSupportedTimezones().map((tz) => ({
47-
label: tz.replace(/_/g, ' '),
48-
value: tz,
49-
}))
45+
/** Human-friendly timezone options for the picker, common zones first. */
46+
const TIMEZONE_OPTIONS = getTimezoneOptions()
5047

5148
/**
5249
* Extracts initials from a user's name.
@@ -419,28 +416,19 @@ export function General() {
419416
</div>
420417

421418
<div className='flex items-center justify-between gap-4'>
422-
<div className='flex items-center gap-1.5'>
423-
<Label>Timezone</Label>
424-
<Tooltip.Root>
425-
<Tooltip.Trigger asChild>
426-
<Info className='size-[14px] cursor-default text-[var(--text-muted)]' />
427-
</Tooltip.Trigger>
428-
<Tooltip.Content side='bottom' align='start'>
429-
<p>The timezone scheduled tasks run in. Defaults to this device's zone.</p>
430-
</Tooltip.Content>
431-
</Tooltip.Root>
419+
<Label>Timezone</Label>
420+
<div className='w-[260px] flex-shrink-0'>
421+
<ChipCombobox
422+
align='start'
423+
dropdownWidth={260}
424+
searchable
425+
searchPlaceholder='Search timezones'
426+
value={settings?.timezone ?? getBrowserTimezone()}
427+
onChange={handleTimezoneChange}
428+
placeholder='Select timezone'
429+
options={TIMEZONE_OPTIONS}
430+
/>
432431
</div>
433-
<ChipCombobox
434-
className='min-w-0 max-w-[260px]'
435-
align='start'
436-
dropdownWidth={260}
437-
searchable
438-
searchPlaceholder='Search timezones'
439-
value={settings?.timezone ?? getBrowserTimezone()}
440-
onChange={handleTimezoneChange}
441-
placeholder='Select timezone'
442-
options={TIMEZONE_OPTIONS}
443-
/>
444432
</div>
445433

446434
<div className='flex items-center justify-between'>

apps/sim/lib/core/utils/timezone.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { describe, expect, it } from 'vitest'
22
import {
33
getSupportedTimezones,
4+
getTimezoneOptions,
45
wallClockNow,
56
zonedClockDate,
67
zonedWallClockToUtc,
@@ -68,6 +69,37 @@ describe('getSupportedTimezones', () => {
6869
})
6970
})
7071

72+
describe('getTimezoneOptions', () => {
73+
it('renders every zone as "City (GMT±HH:MM)"', () => {
74+
const options = getTimezoneOptions()
75+
expect(options.length).toBeGreaterThan(0)
76+
for (const option of options) {
77+
expect(option.label).toMatch(/^.+ \(GMT[+-]\d{2}:\d{2}\)$/)
78+
}
79+
})
80+
81+
it('orders zones alphabetically by city', () => {
82+
const cities = getTimezoneOptions().map((option) =>
83+
option.label.replace(/ \(GMT[+-]\d{2}:\d{2}\)$/, '')
84+
)
85+
expect(cities).toEqual([...cities].sort((a, b) => a.localeCompare(b)))
86+
})
87+
88+
it('uses a live DST-aware offset and a friendly city', () => {
89+
const options = getTimezoneOptions()
90+
expect(options.find((o) => o.value === 'UTC')?.label).toBe('UTC (GMT+00:00)')
91+
// India has no DST, so this offset is stable regardless of when the test runs.
92+
expect(
93+
options.find((o) => o.value === 'Asia/Kolkata' || o.value === 'Asia/Calcutta')?.label
94+
).toMatch(/^(Kolkata|Calcutta) \(GMT\+05:30\)$/)
95+
})
96+
97+
it('has no duplicate values', () => {
98+
const values = getTimezoneOptions().map((o) => o.value)
99+
expect(new Set(values).size).toBe(values.length)
100+
})
101+
})
102+
71103
describe('zonedClockDate', () => {
72104
const instant = new Date('2026-06-15T13:00:00.000Z')
73105

apps/sim/lib/core/utils/timezone.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,50 @@ export function getSupportedTimezones(): string[] {
3838
return zones.includes('UTC') ? zones : ['UTC', ...zones]
3939
}
4040

41+
/** A timezone choice for a picker: the canonical IANA value plus a display label. */
42+
export interface TimezoneOption {
43+
value: string
44+
label: string
45+
}
46+
47+
/** The city/locale portion of an IANA id, formatted for display (e.g. `Los Angeles`). */
48+
function timezoneCity(timeZone: string): string {
49+
return (timeZone.split('/').pop() ?? timeZone).replace(/_/g, ' ')
50+
}
51+
52+
/** `GMT±HH:MM` for an offset expressed in minutes east of UTC (e.g. `GMT-08:00`). */
53+
function formatGmtOffset(offsetMinutes: number): string {
54+
const sign = offsetMinutes >= 0 ? '+' : '-'
55+
const absMinutes = Math.abs(offsetMinutes)
56+
const hours = String(Math.floor(absMinutes / 60)).padStart(2, '0')
57+
const minutes = String(absMinutes % 60).padStart(2, '0')
58+
return `GMT${sign}${hours}:${minutes}`
59+
}
60+
61+
/**
62+
* Timezone options for a picker. Each zone reads as `City (GMT±HH:MM)` — city
63+
* first, offset for reference — and the list is sorted alphabetically by city,
64+
* the order usability research (NN/g, Smart Interface Design Patterns) found
65+
* users expect; offset-sorting confuses people who don't know their offset. The
66+
* offset is computed live, so it tracks DST automatically. Pair this with the
67+
* picker's search and a browser-detected default. Values stay canonical IANA
68+
* ids — what we persist.
69+
*/
70+
export function getTimezoneOptions(): TimezoneOption[] {
71+
const now = new Date()
72+
return getSupportedTimezones()
73+
.map((value) => ({
74+
value,
75+
city: timezoneCity(value),
76+
offsetMinutes: Math.round(timezoneOffsetMs(now, value) / 60_000),
77+
}))
78+
.sort((a, b) => a.city.localeCompare(b.city))
79+
.map(({ value, city, offsetMinutes }) => ({
80+
value,
81+
label: `${city} (${formatGmtOffset(offsetMinutes)})`,
82+
}))
83+
}
84+
4185
/**
4286
* An instant's wall-clock time in `timeZone` as a naive `yyyy-MM-ddTHH:mm`
4387
* string. Lets callers reason about a user's local date/time without UTC — e.g.

0 commit comments

Comments
 (0)