@getmicdrop/venue-calendar 3.3.1 → 3.3.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.
Files changed (49) hide show
  1. package/dist/{VenueCalendar-Xppig0q_.js → VenueCalendar-BMSfRl2d.js} +10 -13
  2. package/dist/{VenueCalendar-Xppig0q_.js.map → VenueCalendar-BMSfRl2d.js.map} +1 -1
  3. package/dist/api/api.cjs +2 -0
  4. package/dist/api/api.cjs.map +1 -0
  5. package/dist/api/api.mjs +787 -0
  6. package/dist/api/api.mjs.map +1 -0
  7. package/dist/api/client.d.ts +46 -0
  8. package/dist/api/events.d.ts +102 -0
  9. package/dist/api/index.d.ts +38 -0
  10. package/dist/api/orders.d.ts +104 -0
  11. package/dist/api/promo.d.ts +45 -0
  12. package/dist/api/transformers/event.d.ts +86 -0
  13. package/dist/api/transformers/index.d.ts +9 -0
  14. package/dist/api/transformers/order.d.ts +105 -0
  15. package/dist/api/transformers/venue.d.ts +48 -0
  16. package/dist/api/venues.d.ts +33 -0
  17. package/dist/{index-BjErG0CG.js → index-CoJaem3n.js} +2 -2
  18. package/dist/{index-BjErG0CG.js.map → index-CoJaem3n.js.map} +1 -1
  19. package/dist/venue-calendar.css +1 -1
  20. package/dist/venue-calendar.es.js +1 -1
  21. package/dist/venue-calendar.iife.js +4 -4
  22. package/dist/venue-calendar.iife.js.map +1 -1
  23. package/dist/venue-calendar.umd.js +4 -4
  24. package/dist/venue-calendar.umd.js.map +1 -1
  25. package/package.json +96 -94
  26. package/src/lib/api/client.ts +0 -210
  27. package/src/lib/api/events.ts +0 -358
  28. package/src/lib/api/index.ts +0 -182
  29. package/src/lib/api/orders.ts +0 -390
  30. package/src/lib/api/promo.ts +0 -164
  31. package/src/lib/api/transformers/event.ts +0 -248
  32. package/src/lib/api/transformers/index.ts +0 -29
  33. package/src/lib/api/transformers/order.ts +0 -207
  34. package/src/lib/api/transformers/venue.ts +0 -118
  35. package/src/lib/api/venues.ts +0 -100
  36. package/src/lib/utils/api.js +0 -790
  37. package/src/lib/utils/api.test.js +0 -1284
  38. package/src/lib/utils/constants.js +0 -8
  39. package/src/lib/utils/constants.test.js +0 -39
  40. package/src/lib/utils/datetime.js +0 -266
  41. package/src/lib/utils/datetime.test.js +0 -340
  42. package/src/lib/utils/event-transform.js +0 -464
  43. package/src/lib/utils/event-transform.test.js +0 -413
  44. package/src/lib/utils/logger.js +0 -105
  45. package/src/lib/utils/timezone.js +0 -109
  46. package/src/lib/utils/timezone.test.js +0 -222
  47. package/src/lib/utils/utils.js +0 -806
  48. package/src/lib/utils/utils.test.js +0 -959
  49. /package/{src/lib/api/types.ts → dist/api/types.d.ts} +0 -0
@@ -1,413 +0,0 @@
1
- import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
- import {
3
- getCDNImageUrl,
4
- getEventImageUrl,
5
- calculateTicketStatus,
6
- filterPublicTickets,
7
- calculateScarcity,
8
- findLowestPrice,
9
- formatInTimezone,
10
- formatEventTimeRange,
11
- getDateParts,
12
- TransformMode,
13
- transformEvent,
14
- transformEventData,
15
- } from './event-transform.js';
16
-
17
- describe('event-transform.js', () => {
18
- describe('getCDNImageUrl', () => {
19
- it('prepends CDN URL to relative paths', () => {
20
- const result = getCDNImageUrl('/images/poster.jpg');
21
- expect(result).toBe('https://moxy.sfo3.digitaloceanspaces.com/images/poster.jpg');
22
- });
23
-
24
- it('returns absolute URLs unchanged', () => {
25
- const url = 'https://example.com/image.jpg';
26
- expect(getCDNImageUrl(url)).toBe(url);
27
- });
28
-
29
- it('returns empty string for falsy input', () => {
30
- expect(getCDNImageUrl('')).toBe('');
31
- expect(getCDNImageUrl(null)).toBe('');
32
- expect(getCDNImageUrl(undefined)).toBe('');
33
- });
34
- });
35
-
36
- describe('getEventImageUrl', () => {
37
- it('uses image field first', () => {
38
- const event = { image: '/poster.jpg' };
39
- const result = getEventImageUrl(event);
40
- expect(result).toContain('/poster.jpg');
41
- });
42
-
43
- it('falls back to coverImage', () => {
44
- const event = { coverImage: '/cover.jpg' };
45
- const result = getEventImageUrl(event);
46
- expect(result).toContain('/cover.jpg');
47
- });
48
-
49
- it('falls back to imageUrl', () => {
50
- const event = { imageUrl: '/url.jpg' };
51
- const result = getEventImageUrl(event);
52
- expect(result).toContain('/url.jpg');
53
- });
54
-
55
- it('falls back to image_url', () => {
56
- const event = { image_url: '/snake_case.jpg' };
57
- const result = getEventImageUrl(event);
58
- expect(result).toContain('/snake_case.jpg');
59
- });
60
-
61
- it('returns placeholder for no image', () => {
62
- const result = getEventImageUrl({});
63
- // Uses data URI SVG placeholder to avoid third-party dependencies
64
- expect(result).toContain('data:image/svg+xml');
65
- });
66
- });
67
-
68
- describe('calculateTicketStatus', () => {
69
- beforeEach(() => {
70
- vi.useFakeTimers();
71
- vi.setSystemTime(new Date('2024-06-15T12:00:00Z'));
72
- });
73
-
74
- afterEach(() => {
75
- vi.useRealTimers();
76
- });
77
-
78
- it('returns not_started for future sale start date', () => {
79
- const ticket = {
80
- saleStartDate: '2024-07-01T00:00:00Z',
81
- remainingCapacity: 100,
82
- };
83
- expect(calculateTicketStatus(ticket)).toBe('not_started');
84
- });
85
-
86
- it('returns ended for past sale end date', () => {
87
- const ticket = {
88
- saleEndDate: '2024-06-01T00:00:00Z',
89
- remainingCapacity: 100,
90
- };
91
- expect(calculateTicketStatus(ticket)).toBe('ended');
92
- });
93
-
94
- it('returns sold_out when remainingCapacity is 0', () => {
95
- const ticket = { remainingCapacity: 0, totalCapacity: 100 };
96
- expect(calculateTicketStatus(ticket)).toBe('sold_out');
97
- });
98
-
99
- it('returns sold_out when quantityRemaining is 0', () => {
100
- const ticket = { quantityRemaining: 0, quantity: 50 };
101
- expect(calculateTicketStatus(ticket)).toBe('sold_out');
102
- });
103
-
104
- it('returns low when less than 10% remaining', () => {
105
- const ticket = { remainingCapacity: 5, totalCapacity: 100 };
106
- expect(calculateTicketStatus(ticket)).toBe('low');
107
- });
108
-
109
- it('returns available when 10%+ remaining', () => {
110
- const ticket = { remainingCapacity: 50, totalCapacity: 100 };
111
- expect(calculateTicketStatus(ticket)).toBe('available');
112
- });
113
-
114
- it('handles ticket with only quantity field', () => {
115
- expect(calculateTicketStatus({ quantity: 0 })).toBe('sold_out');
116
- expect(calculateTicketStatus({ quantity: 50 })).toBe('available');
117
- });
118
- });
119
-
120
- describe('filterPublicTickets', () => {
121
- const tickets = [
122
- { id: 1, visibility: 0, salesChannel: 1 },
123
- { id: 2, visibility: 0, salesChannel: 2 }, // door only
124
- { id: 3, visibility: 1, salesChannel: 1 }, // hidden
125
- { id: 4, visibility: 0, salesChannel: 1 },
126
- ];
127
-
128
- it('returns empty array for non-array input', () => {
129
- expect(filterPublicTickets(null)).toEqual([]);
130
- expect(filterPublicTickets(undefined)).toEqual([]);
131
- expect(filterPublicTickets('not array')).toEqual([]);
132
- });
133
-
134
- it('returns all tickets with no options', () => {
135
- expect(filterPublicTickets(tickets)).toHaveLength(4);
136
- });
137
-
138
- it('excludes door-only tickets when excludeDoorOnly is true', () => {
139
- const result = filterPublicTickets(tickets, { excludeDoorOnly: true });
140
- expect(result).toHaveLength(3);
141
- expect(result.every(t => t.salesChannel !== 2)).toBe(true);
142
- });
143
-
144
- it('includes only public visibility tickets when publicOnly is true', () => {
145
- const result = filterPublicTickets(tickets, { publicOnly: true });
146
- expect(result).toHaveLength(3);
147
- expect(result.every(t => t.visibility === 0)).toBe(true);
148
- });
149
-
150
- it('combines filters', () => {
151
- const result = filterPublicTickets(tickets, { excludeDoorOnly: true, publicOnly: true });
152
- expect(result).toHaveLength(2);
153
- expect(result.map(t => t.id)).toEqual([1, 4]);
154
- });
155
- });
156
-
157
- describe('calculateScarcity', () => {
158
- it('returns zeros for empty/invalid input', () => {
159
- expect(calculateScarcity([])).toEqual({ totalRemaining: 0, totalCapacity: 0, isSoldOut: false });
160
- expect(calculateScarcity(null)).toEqual({ totalRemaining: 0, totalCapacity: 0, isSoldOut: false });
161
- });
162
-
163
- it('sums remaining and capacity across tickets', () => {
164
- const tickets = [
165
- { remainingCapacity: 10, totalCapacity: 50 },
166
- { remainingCapacity: 20, totalCapacity: 100 },
167
- ];
168
- const result = calculateScarcity(tickets);
169
- expect(result.totalRemaining).toBe(30);
170
- expect(result.totalCapacity).toBe(150);
171
- expect(result.isSoldOut).toBe(false);
172
- });
173
-
174
- it('detects sold out when total remaining is 0', () => {
175
- const tickets = [
176
- { remainingCapacity: 0, totalCapacity: 50 },
177
- { remainingCapacity: 0, totalCapacity: 100 },
178
- ];
179
- const result = calculateScarcity(tickets);
180
- expect(result.isSoldOut).toBe(true);
181
- });
182
-
183
- it('handles quantityRemaining fallback', () => {
184
- const tickets = [{ quantityRemaining: 15, quantity: 30 }];
185
- const result = calculateScarcity(tickets);
186
- expect(result.totalRemaining).toBe(15);
187
- expect(result.totalCapacity).toBe(30);
188
- });
189
-
190
- it('handles quantity-only fallback', () => {
191
- const tickets = [{ quantity: 25 }];
192
- const result = calculateScarcity(tickets);
193
- expect(result.totalRemaining).toBe(25);
194
- expect(result.totalCapacity).toBe(25);
195
- });
196
- });
197
-
198
- describe('findLowestPrice', () => {
199
- it('returns 0 for empty/invalid input', () => {
200
- expect(findLowestPrice([])).toBe(0);
201
- expect(findLowestPrice(null)).toBe(0);
202
- expect(findLowestPrice(undefined)).toBe(0);
203
- });
204
-
205
- it('finds lowest price among tickets', () => {
206
- const tickets = [{ price: 50 }, { price: 20 }, { price: 35 }];
207
- expect(findLowestPrice(tickets)).toBe(20);
208
- });
209
-
210
- it('excludes zero prices (free tickets)', () => {
211
- const tickets = [{ price: 0 }, { price: 25 }, { price: 15 }];
212
- expect(findLowestPrice(tickets)).toBe(15);
213
- });
214
-
215
- it('supports ticketPrice field', () => {
216
- const tickets = [{ ticketPrice: 30 }, { ticketPrice: 10 }];
217
- expect(findLowestPrice(tickets)).toBe(10);
218
- });
219
-
220
- it('returns 0 when all tickets are free', () => {
221
- const tickets = [{ price: 0 }, { price: 0 }];
222
- expect(findLowestPrice(tickets)).toBe(0);
223
- });
224
- });
225
-
226
- describe('formatInTimezone', () => {
227
- it('formats date in specified timezone', () => {
228
- const result = formatInTimezone('2024-01-15T19:00:00Z', 'UTC', {
229
- hour: 'numeric',
230
- minute: '2-digit',
231
- hour12: true,
232
- });
233
- expect(result).toBe('7:00 PM');
234
- });
235
-
236
- it('returns empty string for invalid date', () => {
237
- expect(formatInTimezone('invalid', 'UTC', {})).toBe('');
238
- expect(formatInTimezone(null, 'UTC', {})).toBe('');
239
- expect(formatInTimezone('', 'UTC', {})).toBe('');
240
- });
241
- });
242
-
243
- describe('formatEventTimeRange', () => {
244
- it('formats time range', () => {
245
- const event = {
246
- startDateTime: '2024-01-15T19:00:00Z',
247
- endDateTime: '2024-01-15T22:00:00Z',
248
- };
249
- const result = formatEventTimeRange(event, 'UTC');
250
- expect(result).toBe('7:00 PM - 10:00 PM');
251
- });
252
-
253
- it('returns just start time if no end', () => {
254
- const event = { startDateTime: '2024-01-15T19:00:00Z' };
255
- const result = formatEventTimeRange(event, 'UTC');
256
- expect(result).toBe('7:00 PM');
257
- });
258
-
259
- it('returns empty for missing start', () => {
260
- const result = formatEventTimeRange({}, 'UTC');
261
- expect(result).toBe('');
262
- });
263
- });
264
-
265
- describe('getDateParts', () => {
266
- it('returns date parts correctly', () => {
267
- const result = getDateParts('2024-01-15T12:00:00Z', 'UTC');
268
- expect(result.day).toBe('Mon');
269
- expect(result.month).toBe('Jan');
270
- expect(result.dateOfMonth).toBe('15');
271
- });
272
-
273
- it('returns empty/fallback strings for invalid date', () => {
274
- const result = getDateParts('invalid', 'UTC');
275
- expect(result.day).toBe('');
276
- expect(result.month).toBe('');
277
- // Note: dateOfMonth returns '0' for invalid dates per implementation
278
- expect(result.dateOfMonth).toBe('0');
279
- });
280
-
281
- it('returns empty strings for null', () => {
282
- const result = getDateParts(null, 'UTC');
283
- expect(result.day).toBe('');
284
- expect(result.month).toBe('');
285
- expect(result.dateOfMonth).toBe('');
286
- });
287
- });
288
-
289
- describe('TransformMode', () => {
290
- it('has BROWSE and DETAIL modes', () => {
291
- expect(TransformMode.BROWSE).toBe('browse');
292
- expect(TransformMode.DETAIL).toBe('detail');
293
- });
294
- });
295
-
296
- describe('transformEvent', () => {
297
- const mockEvent = {
298
- id: 123,
299
- ID: 123,
300
- title: 'Test Event',
301
- description: 'A test event',
302
- startDateTime: '2024-06-15T19:00:00Z',
303
- endDateTime: '2024-06-15T22:00:00Z',
304
- timeZone: 'America/New_York',
305
- image: '/poster.jpg',
306
- status: 'On Sale',
307
- venueId: 456,
308
- availableTickets: [
309
- { ID: 1, price: 25, remainingCapacity: 50, totalCapacity: 100, visibility: 0, salesChannel: 1 },
310
- { ID: 2, price: 15, remainingCapacity: 10, totalCapacity: 50, visibility: 0, salesChannel: 1 },
311
- ],
312
- venue: { googleLocationNameCache: '123 Main St' },
313
- eventSummary: 'Event summary text',
314
- performers: [{ name: 'Test Performer' }],
315
- };
316
-
317
- describe('BROWSE mode', () => {
318
- it('returns minimal data for browse mode', () => {
319
- const result = transformEvent(mockEvent, { mode: TransformMode.BROWSE });
320
- expect(result.id).toBe(123);
321
- expect(result.name).toBe('Test Event');
322
- expect(result.description).toBe('A test event');
323
- expect(result.status).toBe('On Sale');
324
- expect(result.venueId).toBe(456);
325
- });
326
-
327
- it('calculates scarcity for browse mode', () => {
328
- const result = transformEvent(mockEvent, { mode: TransformMode.BROWSE });
329
- expect(result.ticketsRemaining).toBe(60);
330
- expect(result.ticketsTotal).toBe(150);
331
- expect(result.isSoldOut).toBe(false);
332
- });
333
-
334
- it('formats timeline', () => {
335
- const result = transformEvent(mockEvent, { mode: TransformMode.BROWSE });
336
- expect(result.timeline).toContain('PM');
337
- });
338
- });
339
-
340
- describe('DETAIL mode', () => {
341
- it('returns full data for detail mode', () => {
342
- const result = transformEvent(mockEvent, { mode: TransformMode.DETAIL });
343
- expect(result.id).toBe(123);
344
- expect(result.name).toBe('Test Event');
345
- expect(result.venue).toEqual(mockEvent.venue);
346
- expect(result.performers).toEqual(mockEvent.performers);
347
- expect(result.eventSummary).toBe('Event summary text');
348
- });
349
-
350
- it('calculates lowest price', () => {
351
- const result = transformEvent(mockEvent, { mode: TransformMode.DETAIL });
352
- expect(result.price).toBe(15);
353
- });
354
-
355
- it('includes ticket data with status', () => {
356
- const result = transformEvent(mockEvent, { mode: TransformMode.DETAIL });
357
- expect(result.availableTickets).toHaveLength(2);
358
- expect(result.availableTickets[0].status).toBeDefined();
359
- });
360
-
361
- it('includes date parts', () => {
362
- const result = transformEvent(mockEvent, { mode: TransformMode.DETAIL });
363
- expect(result.day).toBeDefined();
364
- expect(result.month).toBeDefined();
365
- expect(result.dateOfMonth).toBeDefined();
366
- });
367
- });
368
-
369
- describe('null handling', () => {
370
- it('returns empty browse event for null', () => {
371
- const result = transformEvent(null, { mode: TransformMode.BROWSE });
372
- expect(result.id).toBeNull();
373
- expect(result.name).toBe('');
374
- expect(result.status).toBe('unavailable');
375
- });
376
-
377
- it('returns empty detail event for null', () => {
378
- const result = transformEvent(null, { mode: TransformMode.DETAIL });
379
- expect(result.id).toBeNull();
380
- expect(result.name).toBe('');
381
- expect(result.availableTickets).toEqual([]);
382
- });
383
-
384
- it('defaults to DETAIL mode', () => {
385
- const result = transformEvent(null);
386
- expect(result.availableTickets).toEqual([]);
387
- });
388
- });
389
-
390
- describe('field fallbacks', () => {
391
- it('uses name if title missing', () => {
392
- const event = { name: 'Event Name' };
393
- const result = transformEvent(event, { mode: TransformMode.BROWSE });
394
- expect(result.name).toBe('Event Name');
395
- });
396
-
397
- it('uses ID if id missing', () => {
398
- const event = { ID: 999 };
399
- const result = transformEvent(event, { mode: TransformMode.BROWSE });
400
- expect(result.id).toBe(999);
401
- });
402
- });
403
- });
404
-
405
- describe('transformEventData (deprecated)', () => {
406
- it('calls transformEvent with BROWSE mode', () => {
407
- const event = { id: 1, title: 'Test' };
408
- const result = transformEventData(event);
409
- expect(result.id).toBe(1);
410
- expect(result.name).toBe('Test');
411
- });
412
- });
413
- });
@@ -1,105 +0,0 @@
1
- /**
2
- * Centralized logger utility for MicDrop applications
3
- * Can be disabled in production or configured for different log levels
4
- */
5
-
6
- /**
7
- * @typedef {'debug' | 'info' | 'warn' | 'error'} LogLevel
8
- */
9
-
10
- /**
11
- * @typedef {Object} LoggerConfig
12
- * @property {boolean} enabled
13
- * @property {LogLevel} level
14
- * @property {string} prefix
15
- */
16
-
17
- /** @type {Record<LogLevel, number>} */
18
- const LOG_LEVELS = {
19
- debug: 0,
20
- info: 1,
21
- warn: 2,
22
- error: 3,
23
- };
24
-
25
- /** @type {LoggerConfig} */
26
- let config = {
27
- enabled: typeof window !== 'undefined' && import.meta.env?.DEV !== false,
28
- level: 'debug',
29
- prefix: '[MicDrop]',
30
- };
31
-
32
- /**
33
- * Configure the logger
34
- * @param {Partial<LoggerConfig>} options
35
- */
36
- export function configureLogger(options) {
37
- config = { ...config, ...options };
38
- }
39
-
40
- /**
41
- * Check if a log level should be logged
42
- * @param {LogLevel} level
43
- * @returns {boolean}
44
- */
45
- function shouldLog(level) {
46
- return config.enabled && LOG_LEVELS[level] >= LOG_LEVELS[config.level];
47
- }
48
-
49
- /**
50
- * Format a log message
51
- * @param {LogLevel} level
52
- * @param {string} message
53
- * @returns {string}
54
- */
55
- function formatMessage(level, message) {
56
- return `${config.prefix} [${level.toUpperCase()}] ${message}`;
57
- }
58
-
59
- export const logger = {
60
- /**
61
- * Log debug message
62
- * @param {string} message
63
- * @param {...unknown} args
64
- */
65
- debug: (message, ...args) => {
66
- if (shouldLog('debug')) {
67
- console.log(formatMessage('debug', message), ...args);
68
- }
69
- },
70
-
71
- /**
72
- * Log info message
73
- * @param {string} message
74
- * @param {...unknown} args
75
- */
76
- info: (message, ...args) => {
77
- if (shouldLog('info')) {
78
- console.info(formatMessage('info', message), ...args);
79
- }
80
- },
81
-
82
- /**
83
- * Log warning message
84
- * @param {string} message
85
- * @param {...unknown} args
86
- */
87
- warn: (message, ...args) => {
88
- if (shouldLog('warn')) {
89
- console.warn(formatMessage('warn', message), ...args);
90
- }
91
- },
92
-
93
- /**
94
- * Log error message
95
- * @param {string} message
96
- * @param {...unknown} args
97
- */
98
- error: (message, ...args) => {
99
- if (shouldLog('error')) {
100
- console.error(formatMessage('error', message), ...args);
101
- }
102
- },
103
- };
104
-
105
- export default logger;
@@ -1,109 +0,0 @@
1
- /**
2
- * Timezone Utilities
3
- *
4
- * Provides timezone normalization and validation for venue calendar.
5
- * Handles legacy timezone formats and normalizes to IANA timezone IDs.
6
- */
7
-
8
- /** Default timezone when none specified */
9
- export const DEFAULT_TIMEZONE = 'UTC';
10
-
11
- /**
12
- * Map of legacy timezone values to IANA timezone IDs.
13
- * Used to normalize various timezone formats from the API.
14
- */
15
- export const LEGACY_TIMEZONE_MAP = {
16
- // US timezones - common abbreviations
17
- 'PST': 'America/Los_Angeles',
18
- 'PDT': 'America/Los_Angeles',
19
- 'MST': 'America/Denver',
20
- 'MDT': 'America/Denver',
21
- 'CST': 'America/Chicago',
22
- 'CDT': 'America/Chicago',
23
- 'EST': 'America/New_York',
24
- 'EDT': 'America/New_York',
25
- 'HST': 'Pacific/Honolulu',
26
- 'AKST': 'America/Anchorage',
27
- 'AKDT': 'America/Anchorage',
28
-
29
- // Common city names
30
- 'Pacific': 'America/Los_Angeles',
31
- 'Mountain': 'America/Denver',
32
- 'Central': 'America/Chicago',
33
- 'Eastern': 'America/New_York',
34
-
35
- // UTC variants
36
- 'UTC': 'UTC',
37
- 'GMT': 'UTC',
38
- 'Z': 'UTC',
39
-
40
- // Numeric offsets (common ones)
41
- '-08:00': 'America/Los_Angeles',
42
- '-07:00': 'America/Denver',
43
- '-06:00': 'America/Chicago',
44
- '-05:00': 'America/New_York',
45
- '-04:00': 'America/New_York', // EDT
46
- '+00:00': 'UTC',
47
- };
48
-
49
- /**
50
- * Check if a timezone string is valid IANA timezone.
51
- * @param {string} tz - Timezone string to validate
52
- * @returns {boolean}
53
- */
54
- export function isValidTimezone(tz) {
55
- if (!tz || typeof tz !== 'string') return false;
56
- try {
57
- Intl.DateTimeFormat(undefined, { timeZone: tz });
58
- return true;
59
- } catch {
60
- return false;
61
- }
62
- }
63
-
64
- export const isValidIANATimezone = isValidTimezone;
65
-
66
- /**
67
- * Normalize a timezone value to IANA format.
68
- * Handles legacy abbreviations, city names, and UTC offsets.
69
- *
70
- * @param {string} tz - Timezone string (may be legacy format)
71
- * @returns {string} IANA timezone ID (or 'UTC' if invalid)
72
- *
73
- * @example
74
- * getIANATimezone('PST') // 'America/Los_Angeles'
75
- * getIANATimezone('America/New_York') // 'America/New_York'
76
- * getIANATimezone('Eastern') // 'America/New_York'
77
- * getIANATimezone(null) // 'UTC'
78
- */
79
- export function getIANATimezone(tz) {
80
- // Handle null/undefined/empty
81
- if (!tz || typeof tz !== 'string') {
82
- return DEFAULT_TIMEZONE;
83
- }
84
-
85
- const trimmed = tz.trim();
86
-
87
- // Check legacy map FIRST (before isValidTimezone)
88
- // This ensures that offset strings like '-08:00' get converted to IANA timezones
89
- // rather than being returned as-is (since Intl.DateTimeFormat accepts offsets)
90
-
91
- // Check legacy map (case-insensitive for abbreviations)
92
- const upper = trimmed.toUpperCase();
93
- if (LEGACY_TIMEZONE_MAP[upper]) {
94
- return LEGACY_TIMEZONE_MAP[upper];
95
- }
96
-
97
- // Check legacy map with original case (for city names and offsets)
98
- if (LEGACY_TIMEZONE_MAP[trimmed]) {
99
- return LEGACY_TIMEZONE_MAP[trimmed];
100
- }
101
-
102
- // Already valid IANA timezone (check after legacy map)
103
- if (isValidTimezone(trimmed)) {
104
- return trimmed;
105
- }
106
-
107
- // Default to UTC for unknown timezones
108
- return DEFAULT_TIMEZONE;
109
- }