diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/general/general.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/general/general.tsx index 843778837e..647b021a43 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/general/general.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/general/general.tsx @@ -27,7 +27,7 @@ import { ANONYMOUS_USER_ID } from '@/lib/auth/constants' import { getEnv, isTruthy } from '@/lib/core/config/env' import { isHosted } from '@/lib/core/config/feature-flags' import { handleKeyboardActivation } from '@/lib/core/utils/keyboard' -import { getBrowserTimezone, getSupportedTimezones } from '@/lib/core/utils/timezone' +import { getBrowserTimezone, getTimezoneOptions } from '@/lib/core/utils/timezone' import { getBaseUrl } from '@/lib/core/utils/urls' import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' import { useProfilePictureUpload } from '@/app/workspace/[workspaceId]/settings/hooks/use-profile-picture-upload' @@ -42,11 +42,8 @@ import { clearUserData } from '@/stores' const logger = createLogger('General') -/** IANA zones for the timezone picker; labels drop underscores so search reads naturally. */ -const TIMEZONE_OPTIONS = getSupportedTimezones().map((tz) => ({ - label: tz.replace(/_/g, ' '), - value: tz, -})) +/** Human-friendly timezone options for the picker, common zones first. */ +const TIMEZONE_OPTIONS = getTimezoneOptions() /** * Extracts initials from a user's name. @@ -419,28 +416,19 @@ export function General() {
-
- - - - - - -

The timezone scheduled tasks run in. Defaults to this device's zone.

-
-
+ +
+
-
diff --git a/apps/sim/lib/core/utils/timezone.test.ts b/apps/sim/lib/core/utils/timezone.test.ts index f0966144f3..935a9061ce 100644 --- a/apps/sim/lib/core/utils/timezone.test.ts +++ b/apps/sim/lib/core/utils/timezone.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { getSupportedTimezones, + getTimezoneOptions, wallClockNow, zonedClockDate, zonedWallClockToUtc, @@ -68,6 +69,37 @@ describe('getSupportedTimezones', () => { }) }) +describe('getTimezoneOptions', () => { + it('renders every zone as "City (GMT±HH:MM)"', () => { + const options = getTimezoneOptions() + expect(options.length).toBeGreaterThan(0) + for (const option of options) { + expect(option.label).toMatch(/^.+ \(GMT[+-]\d{2}:\d{2}\)$/) + } + }) + + it('orders zones alphabetically by city', () => { + const cities = getTimezoneOptions().map((option) => + option.label.replace(/ \(GMT[+-]\d{2}:\d{2}\)$/, '') + ) + expect(cities).toEqual([...cities].sort((a, b) => a.localeCompare(b))) + }) + + it('uses a live DST-aware offset and a friendly city', () => { + const options = getTimezoneOptions() + expect(options.find((o) => o.value === 'UTC')?.label).toBe('UTC (GMT+00:00)') + // India has no DST, so this offset is stable regardless of when the test runs. + expect( + options.find((o) => o.value === 'Asia/Kolkata' || o.value === 'Asia/Calcutta')?.label + ).toMatch(/^(Kolkata|Calcutta) \(GMT\+05:30\)$/) + }) + + it('has no duplicate values', () => { + const values = getTimezoneOptions().map((o) => o.value) + expect(new Set(values).size).toBe(values.length) + }) +}) + describe('zonedClockDate', () => { const instant = new Date('2026-06-15T13:00:00.000Z') diff --git a/apps/sim/lib/core/utils/timezone.ts b/apps/sim/lib/core/utils/timezone.ts index a0869af08f..5118061bb4 100644 --- a/apps/sim/lib/core/utils/timezone.ts +++ b/apps/sim/lib/core/utils/timezone.ts @@ -38,6 +38,50 @@ export function getSupportedTimezones(): string[] { return zones.includes('UTC') ? zones : ['UTC', ...zones] } +/** A timezone choice for a picker: the canonical IANA value plus a display label. */ +export interface TimezoneOption { + value: string + label: string +} + +/** The city/locale portion of an IANA id, formatted for display (e.g. `Los Angeles`). */ +function timezoneCity(timeZone: string): string { + return (timeZone.split('/').pop() ?? timeZone).replace(/_/g, ' ') +} + +/** `GMT±HH:MM` for an offset expressed in minutes east of UTC (e.g. `GMT-08:00`). */ +function formatGmtOffset(offsetMinutes: number): string { + const sign = offsetMinutes >= 0 ? '+' : '-' + const absMinutes = Math.abs(offsetMinutes) + const hours = String(Math.floor(absMinutes / 60)).padStart(2, '0') + const minutes = String(absMinutes % 60).padStart(2, '0') + return `GMT${sign}${hours}:${minutes}` +} + +/** + * Timezone options for a picker. Each zone reads as `City (GMT±HH:MM)` — city + * first, offset for reference — and the list is sorted alphabetically by city, + * the order usability research (NN/g, Smart Interface Design Patterns) found + * users expect; offset-sorting confuses people who don't know their offset. The + * offset is computed live, so it tracks DST automatically. Pair this with the + * picker's search and a browser-detected default. Values stay canonical IANA + * ids — what we persist. + */ +export function getTimezoneOptions(): TimezoneOption[] { + const now = new Date() + return getSupportedTimezones() + .map((value) => ({ + value, + city: timezoneCity(value), + offsetMinutes: Math.round(timezoneOffsetMs(now, value) / 60_000), + })) + .sort((a, b) => a.city.localeCompare(b.city)) + .map(({ value, city, offsetMinutes }) => ({ + value, + label: `${city} (${formatGmtOffset(offsetMinutes)})`, + })) +} + /** * An instant's wall-clock time in `timeZone` as a naive `yyyy-MM-ddTHH:mm` * string. Lets callers reason about a user's local date/time without UTC — e.g.