Skip to content

Commit 1f76873

Browse files
committed
improvement(settings): use (GMT±HH:MM) City timezone labels, offset-sorted
1 parent faeb2bf commit 1f76873

2 files changed

Lines changed: 51 additions & 111 deletions

File tree

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

Lines changed: 25 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -70,23 +70,36 @@ describe('getSupportedTimezones', () => {
7070
})
7171

7272
describe('getTimezoneOptions', () => {
73-
it('leads with curated, human-friendly labels', () => {
73+
it('renders every zone as "(GMT±HH:MM) City"', () => {
7474
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-
)
75+
expect(options.length).toBeGreaterThan(0)
76+
for (const option of options) {
77+
expect(option.label).toMatch(/^\(GMT[+-]\d{2}:\d{2}\) .+/)
78+
}
7979
})
8080

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')
81+
it('orders zones west-to-east by UTC offset', () => {
82+
const offsets = getTimezoneOptions().map((option) => {
83+
const match = option.label.match(/^\(GMT([+-])(\d{2}):(\d{2})\)/)
84+
if (!match) throw new Error(`unexpected label: ${option.label}`)
85+
const sign = match[1] === '-' ? -1 : 1
86+
return sign * (Number(match[2]) * 60 + Number(match[3]))
87+
})
88+
expect(offsets).toEqual([...offsets].sort((a, b) => a - b))
89+
})
90+
91+
it('uses a live DST-aware offset and a friendly city', () => {
92+
const options = getTimezoneOptions()
93+
expect(options.find((o) => o.value === 'UTC')?.label).toBe('(GMT+00:00) UTC')
94+
// India has no DST, so this offset is stable regardless of when the test runs.
95+
expect(
96+
options.find((o) => o.value === 'Asia/Kolkata' || o.value === 'Asia/Calcutta')?.label
97+
).toMatch(/^\(GMT\+05:30\) (Kolkata|Calcutta)$/)
8598
})
8699

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/)
100+
it('has no duplicate values', () => {
101+
const values = getTimezoneOptions().map((o) => o.value)
102+
expect(new Set(values).size).toBe(values.length)
90103
})
91104
})
92105

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

Lines changed: 26 additions & 99 deletions
Original file line numberDiff line numberDiff line change
@@ -21,73 +21,6 @@ const COMMON_TIMEZONES = [
2121
'Australia/Sydney',
2222
]
2323

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-
9124
/** The IANA timezone the current runtime resolves to (e.g. `America/New_York`). */
9225
export function getBrowserTimezone(): string {
9326
return Intl.DateTimeFormat().resolvedOptions().timeZone
@@ -105,51 +38,45 @@ export function getSupportedTimezones(): string[] {
10538
return zones.includes('UTC') ? zones : ['UTC', ...zones]
10639
}
10740

108-
/**
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-
11841
/** A timezone choice for a picker: the canonical IANA value plus a display label. */
11942
export interface TimezoneOption {
12043
value: string
12144
label: string
12245
}
12346

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)
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 {
12754
const sign = offsetMinutes >= 0 ? '+' : '-'
12855
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')}`
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}`
13459
}
13560

13661
/**
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.
62+
* Timezone options for a picker, following the calendar-app convention: every
63+
* zone rendered as `(GMT±HH:MM) City`, ordered west-to-east by current UTC
64+
* offset (ties alphabetical by city). The offset is computed live, so it tracks
65+
* DST automatically. Values stay canonical IANA ids — what we persist.
14266
*/
14367
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]
68+
const now = new Date()
69+
return getSupportedTimezones()
70+
.map((value) => ({
71+
value,
72+
city: timezoneCity(value),
73+
offsetMinutes: Math.round(timezoneOffsetMs(now, value) / 60_000),
74+
}))
75+
.sort((a, b) => a.offsetMinutes - b.offsetMinutes || a.city.localeCompare(b.city))
76+
.map(({ value, city, offsetMinutes }) => ({
77+
value,
78+
label: `(${formatGmtOffset(offsetMinutes)}) ${city}`,
79+
}))
15380
}
15481

15582
/**

0 commit comments

Comments
 (0)