@forcecalendar/core 2.1.0 → 2.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -11,439 +11,459 @@ import { TimezoneDatabase } from './TimezoneDatabase.js';
11
11
  let sharedInstance = null;
12
12
 
13
13
  export class TimezoneManager {
14
- /**
15
- * Get the shared singleton instance of TimezoneManager
16
- * This should be used instead of creating new instances to avoid memory bloat
17
- * @returns {TimezoneManager} The shared instance
18
- */
19
- static getInstance() {
20
- if (!sharedInstance) {
21
- sharedInstance = new TimezoneManager();
22
- }
23
- return sharedInstance;
24
- }
25
-
26
- /**
27
- * Reset the singleton instance (useful for testing)
28
- * @private
29
- */
30
- static _resetInstance() {
31
- if (sharedInstance) {
32
- sharedInstance.clearCache();
33
- }
34
- sharedInstance = null;
14
+ /**
15
+ * Get the shared singleton instance of TimezoneManager
16
+ * This should be used instead of creating new instances to avoid memory bloat
17
+ * @returns {TimezoneManager} The shared instance
18
+ */
19
+ static getInstance() {
20
+ if (!sharedInstance) {
21
+ sharedInstance = new TimezoneManager();
35
22
  }
36
-
37
- constructor() {
38
- // Initialize comprehensive timezone database
39
- this.database = new TimezoneDatabase();
40
-
41
- // Cache timezone offsets for performance
42
- this.offsetCache = new Map();
43
- this.dstCache = new Map();
44
-
45
- // Cache size management
46
- this.maxCacheSize = 1000;
47
- this.cacheHits = 0;
48
- this.cacheMisses = 0;
23
+ return sharedInstance;
24
+ }
25
+
26
+ /**
27
+ * Reset the singleton instance (useful for testing)
28
+ * @private
29
+ */
30
+ static _resetInstance() {
31
+ if (sharedInstance) {
32
+ sharedInstance.clearCache();
49
33
  }
50
-
51
- /**
52
- * Convert date from one timezone to another
53
- * @param {Date} date - Date to convert
54
- * @param {string} fromTimezone - Source timezone (IANA identifier)
55
- * @param {string} toTimezone - Target timezone (IANA identifier)
56
- * @returns {Date} Converted date
57
- */
58
- convertTimezone(date, fromTimezone, toTimezone) {
59
- if (!date) return null;
60
- if (fromTimezone === toTimezone) return new Date(date);
61
-
62
- // Get offset difference
63
- const fromOffset = this.getTimezoneOffset(date, fromTimezone);
64
- const toOffset = this.getTimezoneOffset(date, toTimezone);
65
- const offsetDiff = (toOffset - fromOffset) * 60 * 1000; // Convert to milliseconds
66
-
67
- return new Date(date.getTime() + offsetDiff);
34
+ sharedInstance = null;
35
+ }
36
+
37
+ constructor() {
38
+ // Initialize comprehensive timezone database
39
+ this.database = new TimezoneDatabase();
40
+
41
+ // Cache timezone offsets for performance
42
+ this.offsetCache = new Map();
43
+ this.dstCache = new Map();
44
+
45
+ // Cache size management
46
+ this.maxCacheSize = 1000;
47
+ this.cacheHits = 0;
48
+ this.cacheMisses = 0;
49
+ }
50
+
51
+ /**
52
+ * Convert date from one timezone to another
53
+ * @param {Date} date - Date to convert
54
+ * @param {string} fromTimezone - Source timezone (IANA identifier)
55
+ * @param {string} toTimezone - Target timezone (IANA identifier)
56
+ * @returns {Date} Converted date
57
+ */
58
+ convertTimezone(date, fromTimezone, toTimezone) {
59
+ if (!date) return null;
60
+ if (fromTimezone === toTimezone) return new Date(date);
61
+
62
+ // Get offset difference
63
+ const fromOffset = this.getTimezoneOffset(date, fromTimezone);
64
+ const toOffset = this.getTimezoneOffset(date, toTimezone);
65
+ const offsetDiff = (toOffset - fromOffset) * 60 * 1000; // Convert to milliseconds
66
+
67
+ return new Date(date.getTime() + offsetDiff);
68
+ }
69
+
70
+ /**
71
+ * Convert date to UTC
72
+ * @param {Date} date - Date in local timezone
73
+ * @param {string} timezone - Source timezone
74
+ * @returns {Date} Date in UTC
75
+ */
76
+ toUTC(date, timezone) {
77
+ if (!date) return null;
78
+ if (timezone === 'UTC') return new Date(date);
79
+
80
+ // offset is positive for timezones behind UTC (e.g., NYC = +300)
81
+ // To convert local to UTC, we ADD the offset
82
+ const offset = this.getTimezoneOffset(date, timezone);
83
+ return new Date(date.getTime() + offset * 60 * 1000);
84
+ }
85
+
86
+ /**
87
+ * Convert UTC date to timezone
88
+ * @param {Date} utcDate - Date in UTC
89
+ * @param {string} timezone - Target timezone
90
+ * @returns {Date} Date in specified timezone
91
+ */
92
+ fromUTC(utcDate, timezone) {
93
+ if (!utcDate) return null;
94
+ if (timezone === 'UTC') return new Date(utcDate);
95
+
96
+ // offset is positive for timezones behind UTC (e.g., NYC = +300)
97
+ // To convert UTC to local, we SUBTRACT the offset
98
+ const offset = this.getTimezoneOffset(utcDate, timezone);
99
+ return new Date(utcDate.getTime() - offset * 60 * 1000);
100
+ }
101
+
102
+ /**
103
+ * Get timezone offset in minutes
104
+ * @param {Date} date - Date to check (for DST calculation)
105
+ * @param {string} timezone - Timezone identifier
106
+ * @returns {number} Offset in minutes from UTC
107
+ */
108
+ getTimezoneOffset(date, timezone) {
109
+ // Resolve any aliases
110
+ timezone = this.database.resolveAlias(timezone);
111
+
112
+ // Check cache first
113
+ const cacheKey = `${timezone}_${date.getFullYear()}_${date.getMonth()}_${date.getDate()}`;
114
+ if (this.offsetCache.has(cacheKey)) {
115
+ this.cacheHits++;
116
+ this._manageCacheSize();
117
+ return this.offsetCache.get(cacheKey);
68
118
  }
69
119
 
70
- /**
71
- * Convert date to UTC
72
- * @param {Date} date - Date in local timezone
73
- * @param {string} timezone - Source timezone
74
- * @returns {Date} Date in UTC
75
- */
76
- toUTC(date, timezone) {
77
- if (!date) return null;
78
- if (timezone === 'UTC') return new Date(date);
79
-
80
- // offset is positive for timezones behind UTC (e.g., NYC = +300)
81
- // To convert local to UTC, we ADD the offset
82
- const offset = this.getTimezoneOffset(date, timezone);
83
- return new Date(date.getTime() + (offset * 60 * 1000));
120
+ this.cacheMisses++;
121
+
122
+ // Try using Intl API if available (best option for browser/Node.js environments)
123
+ if (typeof Intl !== 'undefined' && Intl.DateTimeFormat) {
124
+ try {
125
+ const formatter = new Intl.DateTimeFormat('en-US', {
126
+ timeZone: timezone,
127
+ year: 'numeric',
128
+ month: '2-digit',
129
+ day: '2-digit',
130
+ hour: '2-digit',
131
+ minute: '2-digit',
132
+ second: '2-digit',
133
+ hour12: false
134
+ });
135
+
136
+ // Create same date in target timezone
137
+ const parts = formatter.formatToParts(date);
138
+ const tzDate = new Date(
139
+ parts.find(p => p.type === 'year').value,
140
+ parts.find(p => p.type === 'month').value - 1,
141
+ parts.find(p => p.type === 'day').value,
142
+ parts.find(p => p.type === 'hour').value,
143
+ parts.find(p => p.type === 'minute').value,
144
+ parts.find(p => p.type === 'second').value
145
+ );
146
+
147
+ const offset = (tzDate.getTime() - date.getTime()) / (1000 * 60);
148
+ this.offsetCache.set(cacheKey, -offset);
149
+ this._manageCacheSize();
150
+ return -offset;
151
+ } catch (e) {
152
+ // Fallback to database calculation
153
+ }
84
154
  }
85
155
 
86
- /**
87
- * Convert UTC date to timezone
88
- * @param {Date} utcDate - Date in UTC
89
- * @param {string} timezone - Target timezone
90
- * @returns {Date} Date in specified timezone
91
- */
92
- fromUTC(utcDate, timezone) {
93
- if (!utcDate) return null;
94
- if (timezone === 'UTC') return new Date(utcDate);
95
-
96
- // offset is positive for timezones behind UTC (e.g., NYC = +300)
97
- // To convert UTC to local, we SUBTRACT the offset
98
- const offset = this.getTimezoneOffset(utcDate, timezone);
99
- return new Date(utcDate.getTime() - (offset * 60 * 1000));
156
+ // Fallback: Use timezone database
157
+ const tzData = this.database.getTimezone(timezone);
158
+ if (!tzData) {
159
+ throw new Error(`Unknown timezone: ${timezone}`);
100
160
  }
101
161
 
102
- /**
103
- * Get timezone offset in minutes
104
- * @param {Date} date - Date to check (for DST calculation)
105
- * @param {string} timezone - Timezone identifier
106
- * @returns {number} Offset in minutes from UTC
107
- */
108
- getTimezoneOffset(date, timezone) {
109
- // Resolve any aliases
110
- timezone = this.database.resolveAlias(timezone);
111
-
112
- // Check cache first
113
- const cacheKey = `${timezone}_${date.getFullYear()}_${date.getMonth()}_${date.getDate()}`;
114
- if (this.offsetCache.has(cacheKey)) {
115
- this.cacheHits++;
116
- this._manageCacheSize();
117
- return this.offsetCache.get(cacheKey);
118
- }
119
-
120
- this.cacheMisses++;
121
-
122
- // Try using Intl API if available (best option for browser/Node.js environments)
123
- if (typeof Intl !== 'undefined' && Intl.DateTimeFormat) {
124
- try {
125
- const formatter = new Intl.DateTimeFormat('en-US', {
126
- timeZone: timezone,
127
- year: 'numeric',
128
- month: '2-digit',
129
- day: '2-digit',
130
- hour: '2-digit',
131
- minute: '2-digit',
132
- second: '2-digit',
133
- hour12: false
134
- });
135
-
136
- // Create same date in target timezone
137
- const parts = formatter.formatToParts(date);
138
- const tzDate = new Date(
139
- parts.find(p => p.type === 'year').value,
140
- parts.find(p => p.type === 'month').value - 1,
141
- parts.find(p => p.type === 'day').value,
142
- parts.find(p => p.type === 'hour').value,
143
- parts.find(p => p.type === 'minute').value,
144
- parts.find(p => p.type === 'second').value
145
- );
146
-
147
- const offset = (tzDate.getTime() - date.getTime()) / (1000 * 60);
148
- this.offsetCache.set(cacheKey, -offset);
149
- this._manageCacheSize();
150
- return -offset;
151
- } catch (e) {
152
- // Fallback to database calculation
153
- }
154
- }
155
-
156
- // Fallback: Use timezone database
157
- const tzData = this.database.getTimezone(timezone);
158
- if (!tzData) {
159
- throw new Error(`Unknown timezone: ${timezone}`);
160
- }
161
-
162
- let offset = tzData.offset;
162
+ let offset = tzData.offset;
163
163
 
164
- // Apply DST if applicable
165
- if (tzData.dst && this.isDST(date, timezone, tzData.dst)) {
166
- offset += tzData.dst.offset;
167
- }
168
-
169
- this.offsetCache.set(cacheKey, offset);
170
- this._manageCacheSize();
171
- return offset;
164
+ // Apply DST if applicable
165
+ if (tzData.dst && this.isDST(date, timezone, tzData.dst)) {
166
+ offset += tzData.dst.offset;
172
167
  }
173
168
 
174
- /**
175
- * Check if date is in DST for given timezone
176
- * @param {Date} date - Date to check
177
- * @param {string} timezone - Timezone identifier
178
- * @param {Object} [dstRule] - DST rule object (optional, will fetch if not provided)
179
- * @returns {boolean} True if in DST
180
- */
181
- isDST(date, timezone, dstRule = null) {
182
- // Get DST rule if not provided
183
- if (!dstRule) {
184
- const tzData = this.database.getTimezone(timezone);
185
- if (!tzData || !tzData.dst) return false;
186
- dstRule = tzData.dst;
187
- }
188
-
189
- const year = date.getFullYear();
190
- const dstStart = this.getNthWeekdayOfMonth(year, dstRule.start.month, dstRule.start.week, dstRule.start.day);
191
- const dstEnd = this.getNthWeekdayOfMonth(year, dstRule.end.month, dstRule.end.week, dstRule.end.day);
192
-
193
- // Handle Southern Hemisphere (DST crosses year boundary)
194
- if (dstStart > dstEnd) {
195
- return date >= dstStart || date < dstEnd;
196
- }
197
-
198
- return date >= dstStart && date < dstEnd;
169
+ this.offsetCache.set(cacheKey, offset);
170
+ this._manageCacheSize();
171
+ return offset;
172
+ }
173
+
174
+ /**
175
+ * Check if date is in DST for given timezone
176
+ * @param {Date} date - Date to check
177
+ * @param {string} timezone - Timezone identifier
178
+ * @param {Object} [dstRule] - DST rule object (optional, will fetch if not provided)
179
+ * @returns {boolean} True if in DST
180
+ */
181
+ isDST(date, timezone, dstRule = null) {
182
+ // Get DST rule if not provided
183
+ if (!dstRule) {
184
+ const tzData = this.database.getTimezone(timezone);
185
+ if (!tzData || !tzData.dst) return false;
186
+ dstRule = tzData.dst;
199
187
  }
200
188
 
201
- /**
202
- * Get nth weekday of month
203
- * @private
204
- */
205
- getNthWeekdayOfMonth(year, month, week, dayOfWeek) {
206
- const date = new Date(year, month, 1);
207
- const firstDay = date.getDay();
208
-
209
- let dayOffset = dayOfWeek - firstDay;
210
- if (dayOffset < 0) dayOffset += 7;
211
-
212
- if (week > 0) {
213
- // Nth occurrence from start
214
- date.setDate(1 + dayOffset + (week - 1) * 7);
215
- } else {
216
- // Nth occurrence from end
217
- const lastDay = new Date(year, month + 1, 0).getDate();
218
- date.setDate(lastDay);
219
- const lastDayOfWeek = date.getDay();
220
- let offset = lastDayOfWeek - dayOfWeek;
221
- if (offset < 0) offset += 7;
222
- date.setDate(lastDay - offset + (week + 1) * 7);
223
- }
224
-
225
- return date;
189
+ const year = date.getFullYear();
190
+ const dstStart = this.getNthWeekdayOfMonth(
191
+ year,
192
+ dstRule.start.month,
193
+ dstRule.start.week,
194
+ dstRule.start.day
195
+ );
196
+ const dstEnd = this.getNthWeekdayOfMonth(
197
+ year,
198
+ dstRule.end.month,
199
+ dstRule.end.week,
200
+ dstRule.end.day
201
+ );
202
+
203
+ // Handle Southern Hemisphere (DST crosses year boundary)
204
+ if (dstStart > dstEnd) {
205
+ return date >= dstStart || date < dstEnd;
226
206
  }
227
207
 
228
- /**
229
- * Get list of common timezones
230
- * @returns {Array<{value: string, label: string, offset: string}>}
231
- */
232
- getCommonTimezones() {
233
- const now = new Date();
234
- const timezones = [
235
- { value: 'America/New_York', label: 'Eastern Time (New York)', region: 'Americas' },
236
- { value: 'America/Chicago', label: 'Central Time (Chicago)', region: 'Americas' },
237
- { value: 'America/Denver', label: 'Mountain Time (Denver)', region: 'Americas' },
238
- { value: 'America/Phoenix', label: 'Mountain Time - Arizona (Phoenix)', region: 'Americas' },
239
- { value: 'America/Los_Angeles', label: 'Pacific Time (Los Angeles)', region: 'Americas' },
240
- { value: 'America/Anchorage', label: 'Alaska Time (Anchorage)', region: 'Americas' },
241
- { value: 'Pacific/Honolulu', label: 'Hawaii Time (Honolulu)', region: 'Pacific' },
242
- { value: 'America/Toronto', label: 'Eastern Time (Toronto)', region: 'Americas' },
243
- { value: 'America/Vancouver', label: 'Pacific Time (Vancouver)', region: 'Americas' },
244
- { value: 'America/Mexico_City', label: 'Central Time (Mexico City)', region: 'Americas' },
245
- { value: 'America/Sao_Paulo', label: 'Brasilia Time (São Paulo)', region: 'Americas' },
246
- { value: 'Europe/London', label: 'GMT/BST (London)', region: 'Europe' },
247
- { value: 'Europe/Paris', label: 'Central European Time (Paris)', region: 'Europe' },
248
- { value: 'Europe/Berlin', label: 'Central European Time (Berlin)', region: 'Europe' },
249
- { value: 'Europe/Moscow', label: 'Moscow Time', region: 'Europe' },
250
- { value: 'Asia/Dubai', label: 'Gulf Time (Dubai)', region: 'Asia' },
251
- { value: 'Asia/Kolkata', label: 'India Time (Mumbai)', region: 'Asia' },
252
- { value: 'Asia/Shanghai', label: 'China Time (Shanghai)', region: 'Asia' },
253
- { value: 'Asia/Tokyo', label: 'Japan Time (Tokyo)', region: 'Asia' },
254
- { value: 'Asia/Seoul', label: 'Korea Time (Seoul)', region: 'Asia' },
255
- { value: 'Asia/Singapore', label: 'Singapore Time', region: 'Asia' },
256
- { value: 'Australia/Sydney', label: 'Australian Eastern Time (Sydney)', region: 'Oceania' },
257
- { value: 'Australia/Melbourne', label: 'Australian Eastern Time (Melbourne)', region: 'Oceania' },
258
- { value: 'Pacific/Auckland', label: 'New Zealand Time (Auckland)', region: 'Oceania' },
259
- { value: 'UTC', label: 'UTC', region: 'UTC' }
260
- ];
261
-
262
- // Add current offset to each timezone
263
- return timezones.map(tz => {
264
- const offset = this.getTimezoneOffset(now, tz.value);
265
- const offsetHours = -offset / 60; // Convert to hours from UTC
266
- const hours = Math.floor(Math.abs(offsetHours));
267
- const minutes = Math.round(Math.abs(offsetHours % 1) * 60);
268
- const sign = offsetHours >= 0 ? '+' : '-';
269
- const offsetStr = `UTC${sign}${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`;
270
-
271
- return {
272
- ...tz,
273
- offset: offsetStr,
274
- offsetMinutes: -offset // Store in minutes for sorting
275
- };
276
- }).sort((a, b) => a.offsetMinutes - b.offsetMinutes);
208
+ return date >= dstStart && date < dstEnd;
209
+ }
210
+
211
+ /**
212
+ * Get nth weekday of month
213
+ * @private
214
+ */
215
+ getNthWeekdayOfMonth(year, month, week, dayOfWeek) {
216
+ const date = new Date(year, month, 1);
217
+ const firstDay = date.getDay();
218
+
219
+ let dayOffset = dayOfWeek - firstDay;
220
+ if (dayOffset < 0) dayOffset += 7;
221
+
222
+ if (week > 0) {
223
+ // Nth occurrence from start
224
+ date.setDate(1 + dayOffset + (week - 1) * 7);
225
+ } else {
226
+ // Nth occurrence from end
227
+ const lastDay = new Date(year, month + 1, 0).getDate();
228
+ date.setDate(lastDay);
229
+ const lastDayOfWeek = date.getDay();
230
+ let offset = lastDayOfWeek - dayOfWeek;
231
+ if (offset < 0) offset += 7;
232
+ date.setDate(lastDay - offset + (week + 1) * 7);
277
233
  }
278
234
 
279
- /**
280
- * Format date in specific timezone
281
- * @param {Date} date - Date to format
282
- * @param {string} timezone - Timezone for formatting
283
- * @param {Object} options - Formatting options
284
- * @returns {string} Formatted date string
285
- */
286
- formatInTimezone(date, timezone, options = {}) {
287
- if (!date) return '';
288
-
289
- const defaultOptions = {
290
- year: 'numeric',
291
- month: '2-digit',
292
- day: '2-digit',
293
- hour: '2-digit',
294
- minute: '2-digit',
295
- hour12: true,
296
- timeZone: timezone
297
- };
298
-
299
- const formatOptions = { ...defaultOptions, ...options };
235
+ return date;
236
+ }
237
+
238
+ /**
239
+ * Get list of common timezones
240
+ * @returns {Array<{value: string, label: string, offset: string}>}
241
+ */
242
+ getCommonTimezones() {
243
+ const now = new Date();
244
+ const timezones = [
245
+ { value: 'America/New_York', label: 'Eastern Time (New York)', region: 'Americas' },
246
+ { value: 'America/Chicago', label: 'Central Time (Chicago)', region: 'Americas' },
247
+ { value: 'America/Denver', label: 'Mountain Time (Denver)', region: 'Americas' },
248
+ { value: 'America/Phoenix', label: 'Mountain Time - Arizona (Phoenix)', region: 'Americas' },
249
+ { value: 'America/Los_Angeles', label: 'Pacific Time (Los Angeles)', region: 'Americas' },
250
+ { value: 'America/Anchorage', label: 'Alaska Time (Anchorage)', region: 'Americas' },
251
+ { value: 'Pacific/Honolulu', label: 'Hawaii Time (Honolulu)', region: 'Pacific' },
252
+ { value: 'America/Toronto', label: 'Eastern Time (Toronto)', region: 'Americas' },
253
+ { value: 'America/Vancouver', label: 'Pacific Time (Vancouver)', region: 'Americas' },
254
+ { value: 'America/Mexico_City', label: 'Central Time (Mexico City)', region: 'Americas' },
255
+ { value: 'America/Sao_Paulo', label: 'Brasilia Time (São Paulo)', region: 'Americas' },
256
+ { value: 'Europe/London', label: 'GMT/BST (London)', region: 'Europe' },
257
+ { value: 'Europe/Paris', label: 'Central European Time (Paris)', region: 'Europe' },
258
+ { value: 'Europe/Berlin', label: 'Central European Time (Berlin)', region: 'Europe' },
259
+ { value: 'Europe/Moscow', label: 'Moscow Time', region: 'Europe' },
260
+ { value: 'Asia/Dubai', label: 'Gulf Time (Dubai)', region: 'Asia' },
261
+ { value: 'Asia/Kolkata', label: 'India Time (Mumbai)', region: 'Asia' },
262
+ { value: 'Asia/Shanghai', label: 'China Time (Shanghai)', region: 'Asia' },
263
+ { value: 'Asia/Tokyo', label: 'Japan Time (Tokyo)', region: 'Asia' },
264
+ { value: 'Asia/Seoul', label: 'Korea Time (Seoul)', region: 'Asia' },
265
+ { value: 'Asia/Singapore', label: 'Singapore Time', region: 'Asia' },
266
+ { value: 'Australia/Sydney', label: 'Australian Eastern Time (Sydney)', region: 'Oceania' },
267
+ {
268
+ value: 'Australia/Melbourne',
269
+ label: 'Australian Eastern Time (Melbourne)',
270
+ region: 'Oceania'
271
+ },
272
+ { value: 'Pacific/Auckland', label: 'New Zealand Time (Auckland)', region: 'Oceania' },
273
+ { value: 'UTC', label: 'UTC', region: 'UTC' }
274
+ ];
275
+
276
+ // Add current offset to each timezone
277
+ return timezones
278
+ .map(tz => {
279
+ const offset = this.getTimezoneOffset(now, tz.value);
280
+ const offsetHours = -offset / 60; // Convert to hours from UTC
281
+ const hours = Math.floor(Math.abs(offsetHours));
282
+ const minutes = Math.round(Math.abs(offsetHours % 1) * 60);
283
+ const sign = offsetHours >= 0 ? '+' : '-';
284
+ const offsetStr = `UTC${sign}${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`;
300
285
 
301
- try {
302
- return new Intl.DateTimeFormat('en-US', formatOptions).format(date);
303
- } catch (e) {
304
- // Fallback to basic formatting
305
- const tzDate = this.fromUTC(this.toUTC(date, 'UTC'), timezone);
306
- return tzDate.toLocaleString('en-US', options);
307
- }
286
+ return {
287
+ ...tz,
288
+ offset: offsetStr,
289
+ offsetMinutes: -offset // Store in minutes for sorting
290
+ };
291
+ })
292
+ .sort((a, b) => a.offsetMinutes - b.offsetMinutes);
293
+ }
294
+
295
+ /**
296
+ * Format date in specific timezone
297
+ * @param {Date} date - Date to format
298
+ * @param {string} timezone - Timezone for formatting
299
+ * @param {Object} options - Formatting options
300
+ * @returns {string} Formatted date string
301
+ */
302
+ formatInTimezone(date, timezone, options = {}) {
303
+ if (!date) return '';
304
+
305
+ const defaultOptions = {
306
+ year: 'numeric',
307
+ month: '2-digit',
308
+ day: '2-digit',
309
+ hour: '2-digit',
310
+ minute: '2-digit',
311
+ hour12: true,
312
+ timeZone: timezone
313
+ };
314
+
315
+ const formatOptions = { ...defaultOptions, ...options };
316
+
317
+ try {
318
+ return new Intl.DateTimeFormat('en-US', formatOptions).format(date);
319
+ } catch (e) {
320
+ // Fallback to basic formatting
321
+ const tzDate = this.fromUTC(this.toUTC(date, 'UTC'), timezone);
322
+ return tzDate.toLocaleString('en-US', options);
308
323
  }
309
-
310
- /**
311
- * Get timezone from browser/system
312
- * @returns {string} IANA timezone identifier
313
- */
314
- getSystemTimezone() {
315
- if (typeof Intl !== 'undefined' && Intl.DateTimeFormat) {
316
- try {
317
- return Intl.DateTimeFormat().resolvedOptions().timeZone;
318
- } catch (e) {
319
- // Fallback
320
- }
321
- }
322
-
323
- // Fallback based on offset
324
- const offset = new Date().getTimezoneOffset();
325
- const offsetHours = -offset / 60;
326
-
327
- // Try to match offset to known timezone
328
- for (const [tz, tzData] of Object.entries(this.database.timezones)) {
329
- if (tzData.offset / 60 === offsetHours) {
330
- return tz;
331
- }
332
- }
333
-
334
- return 'UTC';
324
+ }
325
+
326
+ /**
327
+ * Get timezone from browser/system
328
+ * @returns {string} IANA timezone identifier
329
+ */
330
+ getSystemTimezone() {
331
+ if (typeof Intl !== 'undefined' && Intl.DateTimeFormat) {
332
+ try {
333
+ return Intl.DateTimeFormat().resolvedOptions().timeZone;
334
+ } catch (e) {
335
+ // Fallback
336
+ }
335
337
  }
336
338
 
337
- /**
338
- * Parse timezone from string (handles abbreviations)
339
- * @param {string} tzString - Timezone string
340
- * @returns {string} IANA timezone identifier
341
- */
342
- parseTimezone(tzString) {
343
- if (!tzString) return 'UTC';
344
-
345
- // Check if it's already an IANA identifier
346
- if (Object.prototype.hasOwnProperty.call(this.database.timezones, tzString)) {
347
- return tzString;
348
- }
339
+ // Fallback based on offset
340
+ const offset = new Date().getTimezoneOffset();
341
+ const offsetHours = -offset / 60;
349
342
 
350
- // Check abbreviations
351
- const upperTz = tzString.toUpperCase();
352
- if (this.database.abbreviations && Object.prototype.hasOwnProperty.call(this.database.abbreviations, upperTz)) {
353
- return this.database.abbreviations[upperTz];
354
- }
343
+ // Try to match offset to known timezone
344
+ for (const [tz, tzData] of Object.entries(this.database.timezones)) {
345
+ if (tzData.offset / 60 === offsetHours) {
346
+ return tz;
347
+ }
348
+ }
355
349
 
356
- // Try to parse offset format (e.g., "+05:30", "-08:00")
357
- const offsetMatch = tzString.match(/^([+-])(\d{2}):?(\d{2})$/);
358
- if (offsetMatch) {
359
- const sign = offsetMatch[1] === '+' ? 1 : -1;
360
- const hours = parseInt(offsetMatch[2], 10);
361
- const minutes = parseInt(offsetMatch[3], 10);
362
- const totalOffset = sign * (hours + minutes / 60);
363
-
364
- // Find matching timezone
365
- for (const [tz, tzData] of Object.entries(this.database.timezones)) {
366
- if (tzData.offset / 60 === totalOffset) {
367
- return tz;
368
- }
369
- }
370
- }
350
+ return 'UTC';
351
+ }
371
352
 
372
- return 'UTC';
373
- }
353
+ /**
354
+ * Parse timezone from string (handles abbreviations)
355
+ * @param {string} tzString - Timezone string
356
+ * @returns {string} IANA timezone identifier
357
+ */
358
+ parseTimezone(tzString) {
359
+ if (!tzString) return 'UTC';
374
360
 
375
- /**
376
- * Calculate timezone difference in hours
377
- * @param {string} timezone1 - First timezone
378
- * @param {string} timezone2 - Second timezone
379
- * @param {Date} [date] - Date for DST calculation
380
- * @returns {number} Hour difference
381
- */
382
- getTimezoneDifference(timezone1, timezone2, date = new Date()) {
383
- const offset1 = this.getTimezoneOffset(date, timezone1);
384
- const offset2 = this.getTimezoneOffset(date, timezone2);
385
- return (offset2 - offset1) / 60;
361
+ // Check if it's already an IANA identifier
362
+ if (Object.prototype.hasOwnProperty.call(this.database.timezones, tzString)) {
363
+ return tzString;
386
364
  }
387
365
 
388
- /**
389
- * Clear caches (useful when date changes significantly)
390
- */
391
- clearCache() {
392
- this.offsetCache.clear();
393
- this.dstCache.clear();
394
- this.cacheHits = 0;
395
- this.cacheMisses = 0;
366
+ // Check abbreviations
367
+ const upperTz = tzString.toUpperCase();
368
+ if (
369
+ this.database.abbreviations &&
370
+ Object.prototype.hasOwnProperty.call(this.database.abbreviations, upperTz)
371
+ ) {
372
+ return this.database.abbreviations[upperTz];
396
373
  }
397
374
 
398
- /**
399
- * Validate timezone identifier
400
- * @param {string} timezone - Timezone to validate
401
- * @returns {boolean} True if valid
402
- */
403
- isValidTimezone(timezone) {
404
- return this.database.isValidTimezone(timezone);
375
+ // Try to parse offset format (e.g., "+05:30", "-08:00")
376
+ const offsetMatch = tzString.match(/^([+-])(\d{2}):?(\d{2})$/);
377
+ if (offsetMatch) {
378
+ const sign = offsetMatch[1] === '+' ? 1 : -1;
379
+ const hours = parseInt(offsetMatch[2], 10);
380
+ const minutes = parseInt(offsetMatch[3], 10);
381
+ const totalOffset = sign * (hours + minutes / 60);
382
+
383
+ // Find matching timezone
384
+ for (const [tz, tzData] of Object.entries(this.database.timezones)) {
385
+ if (tzData.offset / 60 === totalOffset) {
386
+ return tz;
387
+ }
388
+ }
405
389
  }
406
390
 
407
- /**
408
- * Get cache statistics
409
- * @returns {Object} Cache stats
410
- */
411
- getCacheStats() {
412
- const hitRate = this.cacheHits + this.cacheMisses > 0
413
- ? (this.cacheHits / (this.cacheHits + this.cacheMisses) * 100).toFixed(2)
414
- : 0;
415
-
416
- return {
417
- offsetCacheSize: this.offsetCache.size,
418
- dstCacheSize: this.dstCache.size,
419
- maxCacheSize: this.maxCacheSize,
420
- cacheHits: this.cacheHits,
421
- cacheMisses: this.cacheMisses,
422
- hitRate: `${hitRate}%`
423
- };
391
+ return 'UTC';
392
+ }
393
+
394
+ /**
395
+ * Calculate timezone difference in hours
396
+ * @param {string} timezone1 - First timezone
397
+ * @param {string} timezone2 - Second timezone
398
+ * @param {Date} [date] - Date for DST calculation
399
+ * @returns {number} Hour difference
400
+ */
401
+ getTimezoneDifference(timezone1, timezone2, date = new Date()) {
402
+ const offset1 = this.getTimezoneOffset(date, timezone1);
403
+ const offset2 = this.getTimezoneOffset(date, timezone2);
404
+ return (offset2 - offset1) / 60;
405
+ }
406
+
407
+ /**
408
+ * Clear caches (useful when date changes significantly)
409
+ */
410
+ clearCache() {
411
+ this.offsetCache.clear();
412
+ this.dstCache.clear();
413
+ this.cacheHits = 0;
414
+ this.cacheMisses = 0;
415
+ }
416
+
417
+ /**
418
+ * Validate timezone identifier
419
+ * @param {string} timezone - Timezone to validate
420
+ * @returns {boolean} True if valid
421
+ */
422
+ isValidTimezone(timezone) {
423
+ return this.database.isValidTimezone(timezone);
424
+ }
425
+
426
+ /**
427
+ * Get cache statistics
428
+ * @returns {Object} Cache stats
429
+ */
430
+ getCacheStats() {
431
+ const hitRate =
432
+ this.cacheHits + this.cacheMisses > 0
433
+ ? ((this.cacheHits / (this.cacheHits + this.cacheMisses)) * 100).toFixed(2)
434
+ : 0;
435
+
436
+ return {
437
+ offsetCacheSize: this.offsetCache.size,
438
+ dstCacheSize: this.dstCache.size,
439
+ maxCacheSize: this.maxCacheSize,
440
+ cacheHits: this.cacheHits,
441
+ cacheMisses: this.cacheMisses,
442
+ hitRate: `${hitRate}%`
443
+ };
444
+ }
445
+
446
+ /**
447
+ * Manage cache size - evict old entries if needed
448
+ * @private
449
+ */
450
+ _manageCacheSize() {
451
+ // Clear caches if they get too large
452
+ if (this.offsetCache.size > this.maxCacheSize) {
453
+ // Remove first half of entries (oldest)
454
+ const entriesToRemove = Math.floor(this.offsetCache.size / 2);
455
+ const keys = Array.from(this.offsetCache.keys());
456
+ for (let i = 0; i < entriesToRemove; i++) {
457
+ this.offsetCache.delete(keys[i]);
458
+ }
424
459
  }
425
460
 
426
- /**
427
- * Manage cache size - evict old entries if needed
428
- * @private
429
- */
430
- _manageCacheSize() {
431
- // Clear caches if they get too large
432
- if (this.offsetCache.size > this.maxCacheSize) {
433
- // Remove first half of entries (oldest)
434
- const entriesToRemove = Math.floor(this.offsetCache.size / 2);
435
- const keys = Array.from(this.offsetCache.keys());
436
- for (let i = 0; i < entriesToRemove; i++) {
437
- this.offsetCache.delete(keys[i]);
438
- }
439
- }
440
-
441
- if (this.dstCache.size > this.maxCacheSize / 2) {
442
- const entriesToRemove = Math.floor(this.dstCache.size / 2);
443
- const keys = Array.from(this.dstCache.keys());
444
- for (let i = 0; i < entriesToRemove; i++) {
445
- this.dstCache.delete(keys[i]);
446
- }
447
- }
461
+ if (this.dstCache.size > this.maxCacheSize / 2) {
462
+ const entriesToRemove = Math.floor(this.dstCache.size / 2);
463
+ const keys = Array.from(this.dstCache.keys());
464
+ for (let i = 0; i < entriesToRemove; i++) {
465
+ this.dstCache.delete(keys[i]);
466
+ }
448
467
  }
449
- }
468
+ }
469
+ }