Skip to content

Commit faeb2bf

Browse files
committed
improvement(settings): show human-friendly timezone labels in picker
1 parent a38d548 commit faeb2bf

3 files changed

Lines changed: 137 additions & 24 deletions

File tree

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

Lines changed: 3 additions & 9 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, getTimezonesByPopularity } 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,14 +42,8 @@ import { clearUserData } from '@/stores'
4242

4343
const logger = createLogger('General')
4444

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

5448
/**
5549
* Extracts initials from a user's name.

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

Lines changed: 22 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,27 @@ describe('getSupportedTimezones', () => {
6869
})
6970
})
7071

72+
describe('getTimezoneOptions', () => {
73+
it('leads with curated, human-friendly labels', () => {
74+
const options = getTimezoneOptions()
75+
expect(options[0]).toEqual({ value: 'UTC', label: 'Coordinated Universal Time (UTC)' })
76+
expect(options.find((o) => o.value === 'America/Los_Angeles')?.label).toBe(
77+
'US Pacific Time - Los Angeles (PT)'
78+
)
79+
})
80+
81+
it('has no duplicate values even across legacy aliases', () => {
82+
const values = getTimezoneOptions().map((o) => o.value)
83+
expect(new Set(values).size).toBe(values.length)
84+
expect(values).not.toContain('Asia/Calcutta')
85+
})
86+
87+
it('labels the alphabetical tail with a GMT offset', () => {
88+
const abidjan = getTimezoneOptions().find((o) => o.value === 'Africa/Abidjan')
89+
expect(abidjan?.label).toMatch(/^Africa\/Abidjan \(GMT[+-]\d/)
90+
})
91+
})
92+
7193
describe('zonedClockDate', () => {
7294
const instant = new Date('2026-06-15T13:00:00.000Z')
7395

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

Lines changed: 112 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
/**
2-
* The most-used zones, ranked by popularity. Doubles as the popularity prefix
3-
* for {@link getTimezonesByPopularity} and as a curated fallback for runtimes
4-
* without `Intl.supportedValuesOf` (e.g. Safari < 15.4), so the timezone picker
5-
* is never an empty dead-end.
2+
* A curated fallback for runtimes without `Intl.supportedValuesOf` (e.g. Safari
3+
* < 15.4), so the timezone picker is never an empty dead-end.
64
*/
75
const COMMON_TIMEZONES = [
86
'UTC',
@@ -23,6 +21,73 @@ const COMMON_TIMEZONES = [
2321
'Australia/Sydney',
2422
]
2523

24+
/**
25+
* Curated, human-friendly timezone labels in popularity order. Each label reads
26+
* as "{Region} Time - {City} ({ABBR})" so the picker shows recognizable names
27+
* instead of raw IANA paths. Values stay canonical IANA ids (what we persist);
28+
* every zone the runtime knows but isn't curated still appears below these with
29+
* an auto-generated "{City} (GMT±X)" label, so coverage is never lost.
30+
*/
31+
const CURATED_TIMEZONES: ReadonlyArray<{ value: string; label: string }> = [
32+
{ value: 'UTC', label: 'Coordinated Universal Time (UTC)' },
33+
{ value: 'America/New_York', label: 'US Eastern Time - New York (ET)' },
34+
{ value: 'America/Chicago', label: 'US Central Time - Chicago (CT)' },
35+
{ value: 'America/Denver', label: 'US Mountain Time - Denver (MT)' },
36+
{ value: 'America/Phoenix', label: 'US Mountain Time - Phoenix (MST, no DST)' },
37+
{ value: 'America/Los_Angeles', label: 'US Pacific Time - Los Angeles (PT)' },
38+
{ value: 'America/Anchorage', label: 'US Alaska Time - Anchorage (AKT)' },
39+
{ value: 'Pacific/Honolulu', label: 'US Hawaii Time - Honolulu (HST)' },
40+
{ value: 'America/Toronto', label: 'Canada Eastern Time - Toronto (ET)' },
41+
{ value: 'America/Winnipeg', label: 'Canada Central Time - Winnipeg (CT)' },
42+
{ value: 'America/Edmonton', label: 'Canada Mountain Time - Edmonton (MT)' },
43+
{ value: 'America/Vancouver', label: 'Canada Pacific Time - Vancouver (PT)' },
44+
{ value: 'America/Halifax', label: 'Canada Atlantic Time - Halifax (AT)' },
45+
{ value: 'America/St_Johns', label: "Canada Newfoundland Time - St. John's (NT)" },
46+
{ value: 'America/Mexico_City', label: 'Mexico Central Time - Mexico City (CST)' },
47+
{ value: 'America/Bogota', label: 'Colombia Time - Bogotá (COT)' },
48+
{ value: 'America/Lima', label: 'Peru Time - Lima (PET)' },
49+
{ value: 'America/Sao_Paulo', label: 'Brazil Time - São Paulo (BRT)' },
50+
{ value: 'America/Argentina/Buenos_Aires', label: 'Argentina Time - Buenos Aires (ART)' },
51+
{ value: 'America/Santiago', label: 'Chile Time - Santiago (CLT)' },
52+
{ value: 'Europe/London', label: 'UK Time - London (GMT/BST)' },
53+
{ value: 'Europe/Dublin', label: 'Ireland Time - Dublin (GMT/IST)' },
54+
{ value: 'Europe/Lisbon', label: 'Portugal Time - Lisbon (WET)' },
55+
{ value: 'Europe/Paris', label: 'Central European Time - Paris (CET)' },
56+
{ value: 'Europe/Berlin', label: 'Central European Time - Berlin (CET)' },
57+
{ value: 'Europe/Madrid', label: 'Central European Time - Madrid (CET)' },
58+
{ value: 'Europe/Rome', label: 'Central European Time - Rome (CET)' },
59+
{ value: 'Europe/Amsterdam', label: 'Central European Time - Amsterdam (CET)' },
60+
{ value: 'Europe/Zurich', label: 'Central European Time - Zurich (CET)' },
61+
{ value: 'Europe/Stockholm', label: 'Central European Time - Stockholm (CET)' },
62+
{ value: 'Europe/Athens', label: 'Eastern European Time - Athens (EET)' },
63+
{ value: 'Europe/Helsinki', label: 'Eastern European Time - Helsinki (EET)' },
64+
{ value: 'Europe/Istanbul', label: 'Turkey Time - Istanbul (TRT)' },
65+
{ value: 'Europe/Moscow', label: 'Moscow Time - Moscow (MSK)' },
66+
{ value: 'Africa/Lagos', label: 'West Africa Time - Lagos (WAT)' },
67+
{ value: 'Africa/Cairo', label: 'Egypt Time - Cairo (EET)' },
68+
{ value: 'Africa/Nairobi', label: 'East Africa Time - Nairobi (EAT)' },
69+
{ value: 'Africa/Johannesburg', label: 'South Africa Time - Johannesburg (SAST)' },
70+
{ value: 'Asia/Jerusalem', label: 'Israel Time - Jerusalem (IST)' },
71+
{ value: 'Asia/Riyadh', label: 'Arabia Time - Riyadh (AST)' },
72+
{ value: 'Asia/Dubai', label: 'Gulf Time - Dubai (GST)' },
73+
{ value: 'Asia/Karachi', label: 'Pakistan Time - Karachi (PKT)' },
74+
{ value: 'Asia/Kolkata', label: 'India Time - Kolkata (IST)' },
75+
{ value: 'Asia/Dhaka', label: 'Bangladesh Time - Dhaka (BST)' },
76+
{ value: 'Asia/Bangkok', label: 'Indochina Time - Bangkok (ICT)' },
77+
{ value: 'Asia/Jakarta', label: 'Western Indonesia Time - Jakarta (WIB)' },
78+
{ value: 'Asia/Singapore', label: 'Singapore Time - Singapore (SGT)' },
79+
{ value: 'Asia/Hong_Kong', label: 'Hong Kong Time - Hong Kong (HKT)' },
80+
{ value: 'Asia/Shanghai', label: 'China Time - Shanghai (CST)' },
81+
{ value: 'Asia/Taipei', label: 'Taipei Time - Taipei (CST)' },
82+
{ value: 'Asia/Seoul', label: 'Korea Time - Seoul (KST)' },
83+
{ value: 'Asia/Tokyo', label: 'Japan Time - Tokyo (JST)' },
84+
{ value: 'Australia/Perth', label: 'Australia Western Time - Perth (AWST)' },
85+
{ value: 'Australia/Adelaide', label: 'Australia Central Time - Adelaide (ACT)' },
86+
{ value: 'Australia/Brisbane', label: 'Australia Eastern Time - Brisbane (AEST, no DST)' },
87+
{ value: 'Australia/Sydney', label: 'Australia Eastern Time - Sydney (AET)' },
88+
{ value: 'Pacific/Auckland', label: 'New Zealand Time - Auckland (NZT)' },
89+
]
90+
2691
/** The IANA timezone the current runtime resolves to (e.g. `America/New_York`). */
2792
export function getBrowserTimezone(): string {
2893
return Intl.DateTimeFormat().resolvedOptions().timeZone
@@ -41,18 +106,50 @@ export function getSupportedTimezones(): string[] {
41106
}
42107

43108
/**
44-
* Supported timezones ordered by popularity: the most-used zones
45-
* ({@link COMMON_TIMEZONES}) first, in ranked order, followed by every
46-
* remaining zone alphabetically. For the picker, where the zones people
47-
* actually pick should surface above the long alphabetical tail.
109+
* Legacy IANA aliases (from older ICU data) mapped to the canonical id used in
110+
* {@link CURATED_TIMEZONES}, so a runtime that reports the alias doesn't surface
111+
* it as a duplicate of an already-curated zone.
112+
*/
113+
const TIMEZONE_ALIASES: Record<string, string> = {
114+
'Asia/Calcutta': 'Asia/Kolkata',
115+
'America/Buenos_Aires': 'America/Argentina/Buenos_Aires',
116+
}
117+
118+
/** A timezone choice for a picker: the canonical IANA value plus a display label. */
119+
export interface TimezoneOption {
120+
value: string
121+
label: string
122+
}
123+
124+
/** `GMT±H` / `GMT±H:MM` for `timeZone` at the current instant (e.g. `GMT-7`). */
125+
function formatGmtOffset(timeZone: string): string {
126+
const offsetMinutes = Math.round(timezoneOffsetMs(new Date(), timeZone) / 60_000)
127+
const sign = offsetMinutes >= 0 ? '+' : '-'
128+
const absMinutes = Math.abs(offsetMinutes)
129+
const hours = Math.floor(absMinutes / 60)
130+
const minutes = absMinutes % 60
131+
return minutes === 0
132+
? `GMT${sign}${hours}`
133+
: `GMT${sign}${hours}:${String(minutes).padStart(2, '0')}`
134+
}
135+
136+
/**
137+
* Timezone options for the picker: the curated, human-friendly zones
138+
* ({@link CURATED_TIMEZONES}) first in popularity order, then every remaining
139+
* zone the runtime knows — alphabetically, with an auto-generated
140+
* "{City} (GMT±X)" label — so the common picks read naturally while full
141+
* coverage stays searchable.
48142
*/
49-
export function getTimezonesByPopularity(): string[] {
50-
const supported = getSupportedTimezones()
51-
const supportedSet = new Set(supported)
52-
const popular = COMMON_TIMEZONES.filter((tz) => supportedSet.has(tz))
53-
const popularSet = new Set(popular)
54-
const rest = supported.filter((tz) => !popularSet.has(tz)).sort((a, b) => a.localeCompare(b))
55-
return [...popular, ...rest]
143+
export function getTimezoneOptions(): TimezoneOption[] {
144+
const curatedValues = new Set(CURATED_TIMEZONES.map((option) => option.value))
145+
const rest = getSupportedTimezones()
146+
.filter((tz) => {
147+
const canonical = TIMEZONE_ALIASES[tz] ?? tz
148+
return !curatedValues.has(canonical)
149+
})
150+
.sort((a, b) => a.localeCompare(b))
151+
.map((tz) => ({ value: tz, label: `${tz.replace(/_/g, ' ')} (${formatGmtOffset(tz)})` }))
152+
return [...CURATED_TIMEZONES, ...rest]
56153
}
57154

58155
/**

0 commit comments

Comments
 (0)