@forcecalendar/core 0.2.0 → 0.2.1

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