@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.
- package/core/calendar/Calendar.js +7 -9
- package/core/calendar/DateUtils.js +10 -9
- package/core/conflicts/ConflictDetector.js +24 -24
- package/core/events/Event.js +14 -20
- package/core/events/EventStore.js +70 -19
- package/core/events/RRuleParser.js +423 -394
- package/core/events/RecurrenceEngine.js +33 -21
- package/core/events/RecurrenceEngineV2.js +536 -562
- package/core/ics/ICSHandler.js +348 -348
- package/core/ics/ICSParser.js +433 -435
- package/core/index.js +1 -1
- package/core/integration/EnhancedCalendar.js +363 -398
- package/core/performance/AdaptiveMemoryManager.js +310 -308
- package/core/performance/LRUCache.js +3 -4
- package/core/performance/PerformanceOptimizer.js +4 -6
- package/core/search/EventSearch.js +409 -417
- package/core/search/SearchWorkerManager.js +338 -338
- package/core/state/StateManager.js +4 -2
- package/core/timezone/TimezoneDatabase.js +574 -271
- package/core/timezone/TimezoneManager.js +422 -402
- package/core/types.js +1 -1
- package/package.json +1 -1
|
@@ -11,439 +11,459 @@ import { TimezoneDatabase } from './TimezoneDatabase.js';
|
|
|
11
11
|
let sharedInstance = null;
|
|
12
12
|
|
|
13
13
|
export class TimezoneManager {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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
|
-
|
|
339
|
-
|
|
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
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
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
|
-
|
|
357
|
-
|
|
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
|
-
|
|
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
|
-
|
|
377
|
-
|
|
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
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
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
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
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
|
+
}
|