From a38d5482fcc409cefa80cc76111cdb7d1e3b4b61 Mon Sep 17 00:00:00 2001 From: waleed Date: Sat, 13 Jun 2026 22:36:10 -0700 Subject: [PATCH 1/4] improvement(settings): right-align timezone picker, order by popularity, drop tooltip --- .../settings/components/general/general.tsx | 42 ++++++++----------- apps/sim/lib/core/utils/timezone.ts | 21 +++++++++- 2 files changed, 37 insertions(+), 26 deletions(-) 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..16b94c572c 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, getTimezonesByPopularity } 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,8 +42,11 @@ 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) => ({ +/** + * IANA zones for the timezone picker, ordered most-popular first; labels drop + * underscores so search reads naturally. + */ +const TIMEZONE_OPTIONS = getTimezonesByPopularity().map((tz) => ({ label: tz.replace(/_/g, ' '), value: tz, })) @@ -419,28 +422,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.ts b/apps/sim/lib/core/utils/timezone.ts index a0869af08f..d52e949417 100644 --- a/apps/sim/lib/core/utils/timezone.ts +++ b/apps/sim/lib/core/utils/timezone.ts @@ -1,6 +1,8 @@ /** - * A curated fallback for runtimes without `Intl.supportedValuesOf` (e.g. Safari - * < 15.4), so the timezone picker is never an empty dead-end. + * The most-used zones, ranked by popularity. Doubles as the popularity prefix + * for {@link getTimezonesByPopularity} and as a curated fallback for runtimes + * without `Intl.supportedValuesOf` (e.g. Safari < 15.4), so the timezone picker + * is never an empty dead-end. */ const COMMON_TIMEZONES = [ 'UTC', @@ -38,6 +40,21 @@ export function getSupportedTimezones(): string[] { return zones.includes('UTC') ? zones : ['UTC', ...zones] } +/** + * Supported timezones ordered by popularity: the most-used zones + * ({@link COMMON_TIMEZONES}) first, in ranked order, followed by every + * remaining zone alphabetically. For the picker, where the zones people + * actually pick should surface above the long alphabetical tail. + */ +export function getTimezonesByPopularity(): string[] { + const supported = getSupportedTimezones() + const supportedSet = new Set(supported) + const popular = COMMON_TIMEZONES.filter((tz) => supportedSet.has(tz)) + const popularSet = new Set(popular) + const rest = supported.filter((tz) => !popularSet.has(tz)).sort((a, b) => a.localeCompare(b)) + return [...popular, ...rest] +} + /** * 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. From faeb2bf262ece5190b01ddc40d39f176e3b8103d Mon Sep 17 00:00:00 2001 From: waleed Date: Sat, 13 Jun 2026 22:40:43 -0700 Subject: [PATCH 2/4] improvement(settings): show human-friendly timezone labels in picker --- .../settings/components/general/general.tsx | 12 +- apps/sim/lib/core/utils/timezone.test.ts | 22 +++ apps/sim/lib/core/utils/timezone.ts | 127 +++++++++++++++--- 3 files changed, 137 insertions(+), 24 deletions(-) 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 16b94c572c..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, getTimezonesByPopularity } 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,14 +42,8 @@ import { clearUserData } from '@/stores' const logger = createLogger('General') -/** - * IANA zones for the timezone picker, ordered most-popular first; labels drop - * underscores so search reads naturally. - */ -const TIMEZONE_OPTIONS = getTimezonesByPopularity().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. diff --git a/apps/sim/lib/core/utils/timezone.test.ts b/apps/sim/lib/core/utils/timezone.test.ts index f0966144f3..5548d8381a 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,27 @@ describe('getSupportedTimezones', () => { }) }) +describe('getTimezoneOptions', () => { + it('leads with curated, human-friendly labels', () => { + const options = getTimezoneOptions() + expect(options[0]).toEqual({ value: 'UTC', label: 'Coordinated Universal Time (UTC)' }) + expect(options.find((o) => o.value === 'America/Los_Angeles')?.label).toBe( + 'US Pacific Time - Los Angeles (PT)' + ) + }) + + it('has no duplicate values even across legacy aliases', () => { + const values = getTimezoneOptions().map((o) => o.value) + expect(new Set(values).size).toBe(values.length) + expect(values).not.toContain('Asia/Calcutta') + }) + + it('labels the alphabetical tail with a GMT offset', () => { + const abidjan = getTimezoneOptions().find((o) => o.value === 'Africa/Abidjan') + expect(abidjan?.label).toMatch(/^Africa\/Abidjan \(GMT[+-]\d/) + }) +}) + 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 d52e949417..1dfe96698b 100644 --- a/apps/sim/lib/core/utils/timezone.ts +++ b/apps/sim/lib/core/utils/timezone.ts @@ -1,8 +1,6 @@ /** - * The most-used zones, ranked by popularity. Doubles as the popularity prefix - * for {@link getTimezonesByPopularity} and as a curated fallback for runtimes - * without `Intl.supportedValuesOf` (e.g. Safari < 15.4), so the timezone picker - * is never an empty dead-end. + * A curated fallback for runtimes without `Intl.supportedValuesOf` (e.g. Safari + * < 15.4), so the timezone picker is never an empty dead-end. */ const COMMON_TIMEZONES = [ 'UTC', @@ -23,6 +21,73 @@ const COMMON_TIMEZONES = [ 'Australia/Sydney', ] +/** + * Curated, human-friendly timezone labels in popularity order. Each label reads + * as "{Region} Time - {City} ({ABBR})" so the picker shows recognizable names + * instead of raw IANA paths. Values stay canonical IANA ids (what we persist); + * every zone the runtime knows but isn't curated still appears below these with + * an auto-generated "{City} (GMT±X)" label, so coverage is never lost. + */ +const CURATED_TIMEZONES: ReadonlyArray<{ value: string; label: string }> = [ + { value: 'UTC', label: 'Coordinated Universal Time (UTC)' }, + { value: 'America/New_York', label: 'US Eastern Time - New York (ET)' }, + { value: 'America/Chicago', label: 'US Central Time - Chicago (CT)' }, + { value: 'America/Denver', label: 'US Mountain Time - Denver (MT)' }, + { value: 'America/Phoenix', label: 'US Mountain Time - Phoenix (MST, no DST)' }, + { value: 'America/Los_Angeles', label: 'US Pacific Time - Los Angeles (PT)' }, + { value: 'America/Anchorage', label: 'US Alaska Time - Anchorage (AKT)' }, + { value: 'Pacific/Honolulu', label: 'US Hawaii Time - Honolulu (HST)' }, + { value: 'America/Toronto', label: 'Canada Eastern Time - Toronto (ET)' }, + { value: 'America/Winnipeg', label: 'Canada Central Time - Winnipeg (CT)' }, + { value: 'America/Edmonton', label: 'Canada Mountain Time - Edmonton (MT)' }, + { value: 'America/Vancouver', label: 'Canada Pacific Time - Vancouver (PT)' }, + { value: 'America/Halifax', label: 'Canada Atlantic Time - Halifax (AT)' }, + { value: 'America/St_Johns', label: "Canada Newfoundland Time - St. John's (NT)" }, + { value: 'America/Mexico_City', label: 'Mexico Central Time - Mexico City (CST)' }, + { value: 'America/Bogota', label: 'Colombia Time - Bogotá (COT)' }, + { value: 'America/Lima', label: 'Peru Time - Lima (PET)' }, + { value: 'America/Sao_Paulo', label: 'Brazil Time - São Paulo (BRT)' }, + { value: 'America/Argentina/Buenos_Aires', label: 'Argentina Time - Buenos Aires (ART)' }, + { value: 'America/Santiago', label: 'Chile Time - Santiago (CLT)' }, + { value: 'Europe/London', label: 'UK Time - London (GMT/BST)' }, + { value: 'Europe/Dublin', label: 'Ireland Time - Dublin (GMT/IST)' }, + { value: 'Europe/Lisbon', label: 'Portugal Time - Lisbon (WET)' }, + { value: 'Europe/Paris', label: 'Central European Time - Paris (CET)' }, + { value: 'Europe/Berlin', label: 'Central European Time - Berlin (CET)' }, + { value: 'Europe/Madrid', label: 'Central European Time - Madrid (CET)' }, + { value: 'Europe/Rome', label: 'Central European Time - Rome (CET)' }, + { value: 'Europe/Amsterdam', label: 'Central European Time - Amsterdam (CET)' }, + { value: 'Europe/Zurich', label: 'Central European Time - Zurich (CET)' }, + { value: 'Europe/Stockholm', label: 'Central European Time - Stockholm (CET)' }, + { value: 'Europe/Athens', label: 'Eastern European Time - Athens (EET)' }, + { value: 'Europe/Helsinki', label: 'Eastern European Time - Helsinki (EET)' }, + { value: 'Europe/Istanbul', label: 'Turkey Time - Istanbul (TRT)' }, + { value: 'Europe/Moscow', label: 'Moscow Time - Moscow (MSK)' }, + { value: 'Africa/Lagos', label: 'West Africa Time - Lagos (WAT)' }, + { value: 'Africa/Cairo', label: 'Egypt Time - Cairo (EET)' }, + { value: 'Africa/Nairobi', label: 'East Africa Time - Nairobi (EAT)' }, + { value: 'Africa/Johannesburg', label: 'South Africa Time - Johannesburg (SAST)' }, + { value: 'Asia/Jerusalem', label: 'Israel Time - Jerusalem (IST)' }, + { value: 'Asia/Riyadh', label: 'Arabia Time - Riyadh (AST)' }, + { value: 'Asia/Dubai', label: 'Gulf Time - Dubai (GST)' }, + { value: 'Asia/Karachi', label: 'Pakistan Time - Karachi (PKT)' }, + { value: 'Asia/Kolkata', label: 'India Time - Kolkata (IST)' }, + { value: 'Asia/Dhaka', label: 'Bangladesh Time - Dhaka (BST)' }, + { value: 'Asia/Bangkok', label: 'Indochina Time - Bangkok (ICT)' }, + { value: 'Asia/Jakarta', label: 'Western Indonesia Time - Jakarta (WIB)' }, + { value: 'Asia/Singapore', label: 'Singapore Time - Singapore (SGT)' }, + { value: 'Asia/Hong_Kong', label: 'Hong Kong Time - Hong Kong (HKT)' }, + { value: 'Asia/Shanghai', label: 'China Time - Shanghai (CST)' }, + { value: 'Asia/Taipei', label: 'Taipei Time - Taipei (CST)' }, + { value: 'Asia/Seoul', label: 'Korea Time - Seoul (KST)' }, + { value: 'Asia/Tokyo', label: 'Japan Time - Tokyo (JST)' }, + { value: 'Australia/Perth', label: 'Australia Western Time - Perth (AWST)' }, + { value: 'Australia/Adelaide', label: 'Australia Central Time - Adelaide (ACT)' }, + { value: 'Australia/Brisbane', label: 'Australia Eastern Time - Brisbane (AEST, no DST)' }, + { value: 'Australia/Sydney', label: 'Australia Eastern Time - Sydney (AET)' }, + { value: 'Pacific/Auckland', label: 'New Zealand Time - Auckland (NZT)' }, +] + /** The IANA timezone the current runtime resolves to (e.g. `America/New_York`). */ export function getBrowserTimezone(): string { return Intl.DateTimeFormat().resolvedOptions().timeZone @@ -41,18 +106,50 @@ export function getSupportedTimezones(): string[] { } /** - * Supported timezones ordered by popularity: the most-used zones - * ({@link COMMON_TIMEZONES}) first, in ranked order, followed by every - * remaining zone alphabetically. For the picker, where the zones people - * actually pick should surface above the long alphabetical tail. + * Legacy IANA aliases (from older ICU data) mapped to the canonical id used in + * {@link CURATED_TIMEZONES}, so a runtime that reports the alias doesn't surface + * it as a duplicate of an already-curated zone. + */ +const TIMEZONE_ALIASES: Record = { + 'Asia/Calcutta': 'Asia/Kolkata', + 'America/Buenos_Aires': 'America/Argentina/Buenos_Aires', +} + +/** A timezone choice for a picker: the canonical IANA value plus a display label. */ +export interface TimezoneOption { + value: string + label: string +} + +/** `GMT±H` / `GMT±H:MM` for `timeZone` at the current instant (e.g. `GMT-7`). */ +function formatGmtOffset(timeZone: string): string { + const offsetMinutes = Math.round(timezoneOffsetMs(new Date(), timeZone) / 60_000) + const sign = offsetMinutes >= 0 ? '+' : '-' + const absMinutes = Math.abs(offsetMinutes) + const hours = Math.floor(absMinutes / 60) + const minutes = absMinutes % 60 + return minutes === 0 + ? `GMT${sign}${hours}` + : `GMT${sign}${hours}:${String(minutes).padStart(2, '0')}` +} + +/** + * Timezone options for the picker: the curated, human-friendly zones + * ({@link CURATED_TIMEZONES}) first in popularity order, then every remaining + * zone the runtime knows — alphabetically, with an auto-generated + * "{City} (GMT±X)" label — so the common picks read naturally while full + * coverage stays searchable. */ -export function getTimezonesByPopularity(): string[] { - const supported = getSupportedTimezones() - const supportedSet = new Set(supported) - const popular = COMMON_TIMEZONES.filter((tz) => supportedSet.has(tz)) - const popularSet = new Set(popular) - const rest = supported.filter((tz) => !popularSet.has(tz)).sort((a, b) => a.localeCompare(b)) - return [...popular, ...rest] +export function getTimezoneOptions(): TimezoneOption[] { + const curatedValues = new Set(CURATED_TIMEZONES.map((option) => option.value)) + const rest = getSupportedTimezones() + .filter((tz) => { + const canonical = TIMEZONE_ALIASES[tz] ?? tz + return !curatedValues.has(canonical) + }) + .sort((a, b) => a.localeCompare(b)) + .map((tz) => ({ value: tz, label: `${tz.replace(/_/g, ' ')} (${formatGmtOffset(tz)})` })) + return [...CURATED_TIMEZONES, ...rest] } /** From 1f76873a0c678bd37829400df4890e38166b39bb Mon Sep 17 00:00:00 2001 From: waleed Date: Sun, 14 Jun 2026 04:46:33 -0700 Subject: [PATCH 3/4] =?UTF-8?q?improvement(settings):=20use=20(GMT=C2=B1HH?= =?UTF-8?q?:MM)=20City=20timezone=20labels,=20offset-sorted?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/sim/lib/core/utils/timezone.test.ts | 37 ++++--- apps/sim/lib/core/utils/timezone.ts | 125 +++++------------------ 2 files changed, 51 insertions(+), 111 deletions(-) diff --git a/apps/sim/lib/core/utils/timezone.test.ts b/apps/sim/lib/core/utils/timezone.test.ts index 5548d8381a..c5ee2f3b38 100644 --- a/apps/sim/lib/core/utils/timezone.test.ts +++ b/apps/sim/lib/core/utils/timezone.test.ts @@ -70,23 +70,36 @@ describe('getSupportedTimezones', () => { }) describe('getTimezoneOptions', () => { - it('leads with curated, human-friendly labels', () => { + it('renders every zone as "(GMT±HH:MM) City"', () => { const options = getTimezoneOptions() - expect(options[0]).toEqual({ value: 'UTC', label: 'Coordinated Universal Time (UTC)' }) - expect(options.find((o) => o.value === 'America/Los_Angeles')?.label).toBe( - 'US Pacific Time - Los Angeles (PT)' - ) + expect(options.length).toBeGreaterThan(0) + for (const option of options) { + expect(option.label).toMatch(/^\(GMT[+-]\d{2}:\d{2}\) .+/) + } }) - it('has no duplicate values even across legacy aliases', () => { - const values = getTimezoneOptions().map((o) => o.value) - expect(new Set(values).size).toBe(values.length) - expect(values).not.toContain('Asia/Calcutta') + it('orders zones west-to-east by UTC offset', () => { + const offsets = getTimezoneOptions().map((option) => { + const match = option.label.match(/^\(GMT([+-])(\d{2}):(\d{2})\)/) + if (!match) throw new Error(`unexpected label: ${option.label}`) + const sign = match[1] === '-' ? -1 : 1 + return sign * (Number(match[2]) * 60 + Number(match[3])) + }) + expect(offsets).toEqual([...offsets].sort((a, b) => a - b)) + }) + + it('uses a live DST-aware offset and a friendly city', () => { + const options = getTimezoneOptions() + expect(options.find((o) => o.value === 'UTC')?.label).toBe('(GMT+00:00) UTC') + // 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(/^\(GMT\+05:30\) (Kolkata|Calcutta)$/) }) - it('labels the alphabetical tail with a GMT offset', () => { - const abidjan = getTimezoneOptions().find((o) => o.value === 'Africa/Abidjan') - expect(abidjan?.label).toMatch(/^Africa\/Abidjan \(GMT[+-]\d/) + it('has no duplicate values', () => { + const values = getTimezoneOptions().map((o) => o.value) + expect(new Set(values).size).toBe(values.length) }) }) diff --git a/apps/sim/lib/core/utils/timezone.ts b/apps/sim/lib/core/utils/timezone.ts index 1dfe96698b..1663d94c47 100644 --- a/apps/sim/lib/core/utils/timezone.ts +++ b/apps/sim/lib/core/utils/timezone.ts @@ -21,73 +21,6 @@ const COMMON_TIMEZONES = [ 'Australia/Sydney', ] -/** - * Curated, human-friendly timezone labels in popularity order. Each label reads - * as "{Region} Time - {City} ({ABBR})" so the picker shows recognizable names - * instead of raw IANA paths. Values stay canonical IANA ids (what we persist); - * every zone the runtime knows but isn't curated still appears below these with - * an auto-generated "{City} (GMT±X)" label, so coverage is never lost. - */ -const CURATED_TIMEZONES: ReadonlyArray<{ value: string; label: string }> = [ - { value: 'UTC', label: 'Coordinated Universal Time (UTC)' }, - { value: 'America/New_York', label: 'US Eastern Time - New York (ET)' }, - { value: 'America/Chicago', label: 'US Central Time - Chicago (CT)' }, - { value: 'America/Denver', label: 'US Mountain Time - Denver (MT)' }, - { value: 'America/Phoenix', label: 'US Mountain Time - Phoenix (MST, no DST)' }, - { value: 'America/Los_Angeles', label: 'US Pacific Time - Los Angeles (PT)' }, - { value: 'America/Anchorage', label: 'US Alaska Time - Anchorage (AKT)' }, - { value: 'Pacific/Honolulu', label: 'US Hawaii Time - Honolulu (HST)' }, - { value: 'America/Toronto', label: 'Canada Eastern Time - Toronto (ET)' }, - { value: 'America/Winnipeg', label: 'Canada Central Time - Winnipeg (CT)' }, - { value: 'America/Edmonton', label: 'Canada Mountain Time - Edmonton (MT)' }, - { value: 'America/Vancouver', label: 'Canada Pacific Time - Vancouver (PT)' }, - { value: 'America/Halifax', label: 'Canada Atlantic Time - Halifax (AT)' }, - { value: 'America/St_Johns', label: "Canada Newfoundland Time - St. John's (NT)" }, - { value: 'America/Mexico_City', label: 'Mexico Central Time - Mexico City (CST)' }, - { value: 'America/Bogota', label: 'Colombia Time - Bogotá (COT)' }, - { value: 'America/Lima', label: 'Peru Time - Lima (PET)' }, - { value: 'America/Sao_Paulo', label: 'Brazil Time - São Paulo (BRT)' }, - { value: 'America/Argentina/Buenos_Aires', label: 'Argentina Time - Buenos Aires (ART)' }, - { value: 'America/Santiago', label: 'Chile Time - Santiago (CLT)' }, - { value: 'Europe/London', label: 'UK Time - London (GMT/BST)' }, - { value: 'Europe/Dublin', label: 'Ireland Time - Dublin (GMT/IST)' }, - { value: 'Europe/Lisbon', label: 'Portugal Time - Lisbon (WET)' }, - { value: 'Europe/Paris', label: 'Central European Time - Paris (CET)' }, - { value: 'Europe/Berlin', label: 'Central European Time - Berlin (CET)' }, - { value: 'Europe/Madrid', label: 'Central European Time - Madrid (CET)' }, - { value: 'Europe/Rome', label: 'Central European Time - Rome (CET)' }, - { value: 'Europe/Amsterdam', label: 'Central European Time - Amsterdam (CET)' }, - { value: 'Europe/Zurich', label: 'Central European Time - Zurich (CET)' }, - { value: 'Europe/Stockholm', label: 'Central European Time - Stockholm (CET)' }, - { value: 'Europe/Athens', label: 'Eastern European Time - Athens (EET)' }, - { value: 'Europe/Helsinki', label: 'Eastern European Time - Helsinki (EET)' }, - { value: 'Europe/Istanbul', label: 'Turkey Time - Istanbul (TRT)' }, - { value: 'Europe/Moscow', label: 'Moscow Time - Moscow (MSK)' }, - { value: 'Africa/Lagos', label: 'West Africa Time - Lagos (WAT)' }, - { value: 'Africa/Cairo', label: 'Egypt Time - Cairo (EET)' }, - { value: 'Africa/Nairobi', label: 'East Africa Time - Nairobi (EAT)' }, - { value: 'Africa/Johannesburg', label: 'South Africa Time - Johannesburg (SAST)' }, - { value: 'Asia/Jerusalem', label: 'Israel Time - Jerusalem (IST)' }, - { value: 'Asia/Riyadh', label: 'Arabia Time - Riyadh (AST)' }, - { value: 'Asia/Dubai', label: 'Gulf Time - Dubai (GST)' }, - { value: 'Asia/Karachi', label: 'Pakistan Time - Karachi (PKT)' }, - { value: 'Asia/Kolkata', label: 'India Time - Kolkata (IST)' }, - { value: 'Asia/Dhaka', label: 'Bangladesh Time - Dhaka (BST)' }, - { value: 'Asia/Bangkok', label: 'Indochina Time - Bangkok (ICT)' }, - { value: 'Asia/Jakarta', label: 'Western Indonesia Time - Jakarta (WIB)' }, - { value: 'Asia/Singapore', label: 'Singapore Time - Singapore (SGT)' }, - { value: 'Asia/Hong_Kong', label: 'Hong Kong Time - Hong Kong (HKT)' }, - { value: 'Asia/Shanghai', label: 'China Time - Shanghai (CST)' }, - { value: 'Asia/Taipei', label: 'Taipei Time - Taipei (CST)' }, - { value: 'Asia/Seoul', label: 'Korea Time - Seoul (KST)' }, - { value: 'Asia/Tokyo', label: 'Japan Time - Tokyo (JST)' }, - { value: 'Australia/Perth', label: 'Australia Western Time - Perth (AWST)' }, - { value: 'Australia/Adelaide', label: 'Australia Central Time - Adelaide (ACT)' }, - { value: 'Australia/Brisbane', label: 'Australia Eastern Time - Brisbane (AEST, no DST)' }, - { value: 'Australia/Sydney', label: 'Australia Eastern Time - Sydney (AET)' }, - { value: 'Pacific/Auckland', label: 'New Zealand Time - Auckland (NZT)' }, -] - /** The IANA timezone the current runtime resolves to (e.g. `America/New_York`). */ export function getBrowserTimezone(): string { return Intl.DateTimeFormat().resolvedOptions().timeZone @@ -105,51 +38,45 @@ export function getSupportedTimezones(): string[] { return zones.includes('UTC') ? zones : ['UTC', ...zones] } -/** - * Legacy IANA aliases (from older ICU data) mapped to the canonical id used in - * {@link CURATED_TIMEZONES}, so a runtime that reports the alias doesn't surface - * it as a duplicate of an already-curated zone. - */ -const TIMEZONE_ALIASES: Record = { - 'Asia/Calcutta': 'Asia/Kolkata', - 'America/Buenos_Aires': 'America/Argentina/Buenos_Aires', -} - /** A timezone choice for a picker: the canonical IANA value plus a display label. */ export interface TimezoneOption { value: string label: string } -/** `GMT±H` / `GMT±H:MM` for `timeZone` at the current instant (e.g. `GMT-7`). */ -function formatGmtOffset(timeZone: string): string { - const offsetMinutes = Math.round(timezoneOffsetMs(new Date(), timeZone) / 60_000) +/** 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 = Math.floor(absMinutes / 60) - const minutes = absMinutes % 60 - return minutes === 0 - ? `GMT${sign}${hours}` - : `GMT${sign}${hours}:${String(minutes).padStart(2, '0')}` + 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 the picker: the curated, human-friendly zones - * ({@link CURATED_TIMEZONES}) first in popularity order, then every remaining - * zone the runtime knows — alphabetically, with an auto-generated - * "{City} (GMT±X)" label — so the common picks read naturally while full - * coverage stays searchable. + * Timezone options for a picker, following the calendar-app convention: every + * zone rendered as `(GMT±HH:MM) City`, ordered west-to-east by current UTC + * offset (ties alphabetical by city). The offset is computed live, so it tracks + * DST automatically. Values stay canonical IANA ids — what we persist. */ export function getTimezoneOptions(): TimezoneOption[] { - const curatedValues = new Set(CURATED_TIMEZONES.map((option) => option.value)) - const rest = getSupportedTimezones() - .filter((tz) => { - const canonical = TIMEZONE_ALIASES[tz] ?? tz - return !curatedValues.has(canonical) - }) - .sort((a, b) => a.localeCompare(b)) - .map((tz) => ({ value: tz, label: `${tz.replace(/_/g, ' ')} (${formatGmtOffset(tz)})` })) - return [...CURATED_TIMEZONES, ...rest] + const now = new Date() + return getSupportedTimezones() + .map((value) => ({ + value, + city: timezoneCity(value), + offsetMinutes: Math.round(timezoneOffsetMs(now, value) / 60_000), + })) + .sort((a, b) => a.offsetMinutes - b.offsetMinutes || a.city.localeCompare(b.city)) + .map(({ value, city, offsetMinutes }) => ({ + value, + label: `(${formatGmtOffset(offsetMinutes)}) ${city}`, + })) } /** From 50519857d6aa4fb3d601edcd8b3a842f10a3662b Mon Sep 17 00:00:00 2001 From: waleed Date: Sun, 14 Jun 2026 04:52:48 -0700 Subject: [PATCH 4/4] improvement(settings): sort timezones alphabetically by city per UX research --- apps/sim/lib/core/utils/timezone.test.ts | 21 +++++++++------------ apps/sim/lib/core/utils/timezone.ts | 15 +++++++++------ 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/apps/sim/lib/core/utils/timezone.test.ts b/apps/sim/lib/core/utils/timezone.test.ts index c5ee2f3b38..935a9061ce 100644 --- a/apps/sim/lib/core/utils/timezone.test.ts +++ b/apps/sim/lib/core/utils/timezone.test.ts @@ -70,31 +70,28 @@ describe('getSupportedTimezones', () => { }) describe('getTimezoneOptions', () => { - it('renders every zone as "(GMT±HH:MM) City"', () => { + 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}\) .+/) + expect(option.label).toMatch(/^.+ \(GMT[+-]\d{2}:\d{2}\)$/) } }) - it('orders zones west-to-east by UTC offset', () => { - const offsets = getTimezoneOptions().map((option) => { - const match = option.label.match(/^\(GMT([+-])(\d{2}):(\d{2})\)/) - if (!match) throw new Error(`unexpected label: ${option.label}`) - const sign = match[1] === '-' ? -1 : 1 - return sign * (Number(match[2]) * 60 + Number(match[3])) - }) - expect(offsets).toEqual([...offsets].sort((a, b) => a - b)) + 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('(GMT+00:00) UTC') + 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(/^\(GMT\+05:30\) (Kolkata|Calcutta)$/) + ).toMatch(/^(Kolkata|Calcutta) \(GMT\+05:30\)$/) }) it('has no duplicate values', () => { diff --git a/apps/sim/lib/core/utils/timezone.ts b/apps/sim/lib/core/utils/timezone.ts index 1663d94c47..5118061bb4 100644 --- a/apps/sim/lib/core/utils/timezone.ts +++ b/apps/sim/lib/core/utils/timezone.ts @@ -59,10 +59,13 @@ function formatGmtOffset(offsetMinutes: number): string { } /** - * Timezone options for a picker, following the calendar-app convention: every - * zone rendered as `(GMT±HH:MM) City`, ordered west-to-east by current UTC - * offset (ties alphabetical by city). The offset is computed live, so it tracks - * DST automatically. Values stay canonical IANA ids — what we persist. + * 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() @@ -72,10 +75,10 @@ export function getTimezoneOptions(): TimezoneOption[] { city: timezoneCity(value), offsetMinutes: Math.round(timezoneOffsetMs(now, value) / 60_000), })) - .sort((a, b) => a.offsetMinutes - b.offsetMinutes || a.city.localeCompare(b.city)) + .sort((a, b) => a.city.localeCompare(b.city)) .map(({ value, city, offsetMinutes }) => ({ value, - label: `(${formatGmtOffset(offsetMinutes)}) ${city}`, + label: `${city} (${formatGmtOffset(offsetMinutes)})`, })) }