ultimate-jekyll-manager 1.0.4 → 1.0.5

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.
@@ -1,8 +1,11 @@
1
1
  /**
2
2
  * Calendar Core
3
- * State management, date math, Firestore real-time sync, recurrence
4
- * computation, and public API.
3
+ * State management, date math (all UTC), Firestore real-time sync,
4
+ * recurrence computation, and public API.
5
5
  * Reads from Firestore marketing-campaigns collection directly.
6
+ *
7
+ * IMPORTANT: All dates/times in this module are UTC.
8
+ * No local time APIs (getHours, getDate, etc.) are used anywhere.
6
9
  */
7
10
 
8
11
  // View modes
@@ -17,9 +20,6 @@ export const MONTH_NAMES = [
17
20
  'July', 'August', 'September', 'October', 'November', 'December',
18
21
  ];
19
22
 
20
- // Recurrence pattern options
21
- export const RECURRENCE_PATTERNS = ['daily', 'weekly', 'monthly', 'quarterly', 'yearly'];
22
-
23
23
  // Campaign type colors
24
24
  export const TYPE_COLORS = {
25
25
  email: '#2196F3',
@@ -40,17 +40,56 @@ export const DISPLAY_TYPES = {
40
40
  RECURRING_HISTORY: 'recurring-history',
41
41
  };
42
42
 
43
+ // ============================================
44
+ // Shared UTC date utilities
45
+ // ============================================
46
+
47
+ /**
48
+ * Format a Date or unix timestamp to YYYY-MM-DD (UTC).
49
+ */
50
+ export function formatDateUTC(input) {
51
+ const d = typeof input === 'number' ? new Date(input * 1000) : input;
52
+ const y = d.getUTCFullYear();
53
+ const m = String(d.getUTCMonth() + 1).padStart(2, '0');
54
+ const day = String(d.getUTCDate()).padStart(2, '0');
55
+ return `${y}-${m}-${day}`;
56
+ }
57
+
58
+ /**
59
+ * Format a Date or unix timestamp to HH:MM (UTC).
60
+ */
61
+ export function formatTimeUTC(input) {
62
+ const d = typeof input === 'number' ? new Date(input * 1000) : input;
63
+ return String(d.getUTCHours()).padStart(2, '0') + ':' + String(d.getUTCMinutes()).padStart(2, '0');
64
+ }
65
+
66
+ /**
67
+ * Parse a YYYY-MM-DD string as a UTC midnight Date.
68
+ */
69
+ export function parseDateUTC(dateStr) {
70
+ const [y, m, d] = dateStr.split('-').map(Number);
71
+ return new Date(Date.UTC(y, m - 1, d));
72
+ }
73
+
74
+ /**
75
+ * Get today's date as YYYY-MM-DD (UTC).
76
+ */
77
+ export function todayUTC() {
78
+ return formatDateUTC(new Date());
79
+ }
80
+
43
81
  export default class CalendarCore {
44
82
  constructor(webManager) {
45
83
  this.webManager = webManager;
46
84
  this.currentDate = new Date();
47
85
  this.viewMode = 'month';
48
- this.campaigns = new Map(); // Real Firestore docs
49
- this._virtualEvents = null; // Cached virtual events (regenerated on data change)
86
+ this.campaigns = new Map();
87
+ this._virtualEvents = null;
50
88
  this.renderer = null;
51
89
  this.eventsManager = null;
52
90
  this.$root = document.getElementById('calendar-root');
53
- this._unsubscribe = null;
91
+ this._unsubscribeRange = null;
92
+ this._unsubscribeRecurring = null;
54
93
  }
55
94
 
56
95
  // ============================================
@@ -81,32 +120,32 @@ export default class CalendarCore {
81
120
  }
82
121
 
83
122
  // ============================================
84
- // Navigation
123
+ // Navigation (all UTC)
85
124
  // ============================================
86
125
  navigate(direction) {
87
126
  const d = this.currentDate;
88
127
 
89
128
  switch (this.viewMode) {
90
129
  case 'day':
91
- d.setDate(d.getDate() + direction);
130
+ d.setUTCDate(d.getUTCDate() + direction);
92
131
  break;
93
132
  case 'week':
94
- d.setDate(d.getDate() + (7 * direction));
133
+ d.setUTCDate(d.getUTCDate() + (7 * direction));
95
134
  break;
96
135
  case 'month':
97
- d.setMonth(d.getMonth() + direction);
136
+ d.setUTCMonth(d.getUTCMonth() + direction);
98
137
  break;
99
138
  case 'year':
100
- d.setFullYear(d.getFullYear() + direction);
139
+ d.setUTCFullYear(d.getUTCFullYear() + direction);
101
140
  break;
102
141
  case 'list':
103
- d.setDate(d.getDate() + (30 * direction));
142
+ d.setUTCDate(d.getUTCDate() + (30 * direction));
104
143
  break;
105
144
  }
106
145
 
107
146
  this._virtualEvents = null;
108
- this.renderer.render(); // Instant render from cache
109
- this._subscribeToRange(); // Background fetch for accurate data
147
+ this.renderer.render();
148
+ this._subscribeToRange();
110
149
  this._dispatch('calendar:navigate');
111
150
  }
112
151
 
@@ -130,6 +169,32 @@ export default class CalendarCore {
130
169
  this._dispatch('calendar:viewchange');
131
170
  }
132
171
 
172
+ // ============================================
173
+ // Optimistic Updates
174
+ // ============================================
175
+
176
+ /**
177
+ * Optimistically update a campaign's sendAt locally and re-render.
178
+ * Returns a rollback function.
179
+ */
180
+ optimisticUpdateSendAt(id, newSendAtUNIX) {
181
+ const campaign = this.campaigns.get(id);
182
+ if (!campaign) {
183
+ return null;
184
+ }
185
+
186
+ const originalSendAt = campaign.sendAt;
187
+ campaign.sendAt = newSendAtUNIX;
188
+ this._virtualEvents = null;
189
+ this.renderer.render();
190
+
191
+ return () => {
192
+ campaign.sendAt = originalSendAt;
193
+ this._virtualEvents = null;
194
+ this.renderer.render();
195
+ };
196
+ }
197
+
133
198
  // ============================================
134
199
  // Campaign Display Type Detection
135
200
  // ============================================
@@ -144,60 +209,32 @@ export default class CalendarCore {
144
209
  }
145
210
 
146
211
  isEditable(doc) {
147
- // Virtual occurrences are not editable (they don't exist as docs)
148
212
  if (doc._virtual) {
149
213
  return false;
150
214
  }
151
- // History records are read-only
152
215
  if (doc.recurringId) {
153
216
  return false;
154
217
  }
155
- // Sent/failed are read-only
156
218
  if (doc.status === 'sent' || doc.status === 'failed') {
157
219
  return false;
158
220
  }
159
- // Pending one-offs and recurring templates are editable
160
221
  return true;
161
222
  }
162
223
 
163
- // ============================================
164
- // Optimistic Updates
165
- // ============================================
166
-
167
- /**
168
- * Optimistically update a campaign's sendAt locally and re-render.
169
- * Returns a rollback function that restores the original value.
170
- */
171
- optimisticUpdateSendAt(id, newSendAtUNIX) {
172
- const campaign = this.campaigns.get(id);
173
- if (!campaign) {
174
- return null;
175
- }
176
-
177
- const originalSendAt = campaign.sendAt;
178
- campaign.sendAt = newSendAtUNIX;
179
- this._virtualEvents = null; // Clear virtual cache
180
- this.renderer.render();
181
-
182
- // Return rollback function
183
- return () => {
184
- campaign.sendAt = originalSendAt;
185
- this._virtualEvents = null;
186
- this.renderer.render();
187
- };
224
+ isRecurring(campaign) {
225
+ return !!(campaign.recurrence || campaign.recurringId || campaign._virtual);
188
226
  }
189
227
 
190
228
  // ============================================
191
229
  // Campaign Accessors
192
230
  // ============================================
193
231
  getCampaign(id) {
194
- // Check real Firestore docs first
195
232
  const real = this.campaigns.get(id);
196
233
  if (real) {
197
234
  return real;
198
235
  }
199
236
 
200
- // Check virtual events (synthetic IDs like "templateId__virtual__timestamp")
237
+ // Check virtual events
201
238
  const virtuals = this._getVirtualEvents();
202
239
  for (const v of virtuals) {
203
240
  if (v.id === id) {
@@ -209,31 +246,31 @@ export default class CalendarCore {
209
246
  }
210
247
 
211
248
  /**
212
- * Get all display items for a given date, including virtual recurring occurrences.
213
- * Merges: one-off docs + recurring history docs + virtual occurrences (not overlapping with history).
249
+ * Get all display items for a date (YYYY-MM-DD UTC string).
250
+ * Merges real docs + virtual recurring occurrences.
214
251
  */
215
252
  getCampaignsForDate(dateStr) {
216
253
  const items = [];
217
254
  const historyDatesById = this._getHistoryDateMap();
218
255
 
219
- // Real Firestore docs that land on this date (skip recurring templates — they render via virtuals)
256
+ // Real non-recurring docs
220
257
  this.campaigns.forEach((c) => {
221
258
  if (c.recurrence) {
222
259
  return;
223
260
  }
224
- if (this._campaignDate(c) === dateStr) {
261
+ if (formatDateUTC(c.sendAt) === dateStr) {
225
262
  items.push(c);
226
263
  }
227
264
  });
228
265
 
229
- // Virtual recurring occurrences for this date
266
+ // Virtual recurring occurrences
230
267
  const virtuals = this._getVirtualEvents();
231
268
  virtuals.forEach((v) => {
232
- if (this._campaignDate(v) !== dateStr) {
269
+ const vDate = formatDateUTC(v.sendAt);
270
+ if (vDate !== dateStr) {
233
271
  return;
234
272
  }
235
- // Don't add a virtual if a history record already covers this date for this recurring template
236
- const key = `${v._recurringSourceId}:${dateStr}`;
273
+ const key = `${v._recurringSourceId}:${vDate}`;
237
274
  if (historyDatesById.has(key)) {
238
275
  return;
239
276
  }
@@ -245,13 +282,11 @@ export default class CalendarCore {
245
282
 
246
283
  /**
247
284
  * Get all display items sorted chronologically (for list view).
248
- * Merges non-recurring docs + virtual recurring occurrences.
249
285
  */
250
286
  getAllCampaignsSorted() {
251
287
  const items = [];
252
288
  const historyDatesById = this._getHistoryDateMap();
253
289
 
254
- // Real non-recurring docs
255
290
  this.campaigns.forEach((c) => {
256
291
  if (c.recurrence) {
257
292
  return;
@@ -259,10 +294,9 @@ export default class CalendarCore {
259
294
  items.push(c);
260
295
  });
261
296
 
262
- // Virtual recurring occurrences (suppress where history exists)
263
297
  const virtuals = this._getVirtualEvents();
264
298
  virtuals.forEach((v) => {
265
- const dateStr = this._campaignDate(v);
299
+ const dateStr = formatDateUTC(v.sendAt);
266
300
  const key = `${v._recurringSourceId}:${dateStr}`;
267
301
  if (historyDatesById.has(key)) {
268
302
  return;
@@ -276,68 +310,32 @@ export default class CalendarCore {
276
310
  // ============================================
277
311
  // Campaign Helpers
278
312
  // ============================================
279
-
280
- /**
281
- * Get YYYY-MM-DD string from a campaign's sendAt timestamp
282
- */
283
- _campaignDate(campaign) {
284
- const d = new Date(campaign.sendAt * 1000);
285
- return this._formatDate(d);
286
- }
287
-
288
- /**
289
- * Get HH:MM string from a campaign's sendAt timestamp
290
- */
291
- campaignTime(campaign) {
292
- const d = new Date(campaign.sendAt * 1000);
293
- return String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0');
294
- }
295
-
296
- /**
297
- * Get duration in minutes (default 60 for display purposes)
298
- */
299
- campaignDuration() {
300
- return 60;
301
- }
302
-
303
- /**
304
- * Get color based on campaign type
305
- */
306
313
  campaignColor(campaign) {
307
314
  return TYPE_COLORS[campaign.type] || TYPE_COLORS.email;
308
315
  }
309
316
 
310
- /**
311
- * Get status style info
312
- */
313
317
  campaignStatusStyle(campaign) {
314
318
  return STATUS_STYLES[campaign.status] || STATUS_STYLES.pending;
315
319
  }
316
320
 
321
+ campaignDuration() {
322
+ return 60;
323
+ }
324
+
317
325
  // ============================================
318
326
  // Recurrence: Virtual Event Generation
319
327
  // ============================================
320
-
321
- /**
322
- * Build a map of recurringId:dateStr → true for all history records.
323
- * Used to suppress virtual occurrences on dates that have real history docs.
324
- */
325
328
  _getHistoryDateMap() {
326
329
  const map = new Map();
327
330
  this.campaigns.forEach((c) => {
328
331
  if (!c.recurringId) {
329
332
  return;
330
333
  }
331
- const dateStr = this._campaignDate(c);
332
- map.set(`${c.recurringId}:${dateStr}`, true);
334
+ map.set(`${c.recurringId}:${formatDateUTC(c.sendAt)}`, true);
333
335
  });
334
336
  return map;
335
337
  }
336
338
 
337
- /**
338
- * Get virtual events for recurring templates within the visible range.
339
- * Cached per render cycle; cleared when Firestore data changes.
340
- */
341
339
  _getVirtualEvents() {
342
340
  if (this._virtualEvents) {
343
341
  return this._virtualEvents;
@@ -359,172 +357,81 @@ export default class CalendarCore {
359
357
  }
360
358
 
361
359
  /**
362
- * Generate virtual occurrence items for a recurring template within a date range.
360
+ * Generate virtual occurrences using the template's sendAt as seed.
361
+ * Pure unix math — no Date objects, no timezone issues.
363
362
  */
364
363
  _generateOccurrences(template, startUNIX, endUNIX) {
365
- const recurrence = template.recurrence;
366
- const hour = recurrence.hour || 0;
364
+ const interval = this._getIntervalSeconds(template.recurrence.pattern);
367
365
  const occurrences = [];
366
+ const seedUNIX = template.sendAt;
368
367
 
369
- // Start from the beginning of the visible range and iterate forward
370
- const startDate = new Date(startUNIX * 1000);
371
- const endDate = new Date(endUNIX * 1000);
372
-
373
- // Find the first occurrence at or after startDate based on pattern
374
- let cursor = this._findFirstOccurrence(recurrence, startDate);
368
+ // Walk backward from seed to find first occurrence >= startUNIX
369
+ let cursorUNIX = seedUNIX;
370
+ while (cursorUNIX > startUNIX + interval) {
371
+ cursorUNIX -= interval;
372
+ }
373
+ while (cursorUNIX < startUNIX) {
374
+ cursorUNIX += interval;
375
+ }
375
376
 
376
- // Safety: limit iterations
377
377
  let maxIterations = 400;
378
+ while (cursorUNIX <= endUNIX && maxIterations-- > 0) {
379
+ occurrences.push({
380
+ id: `${template.id}__virtual__${cursorUNIX}`,
381
+ sendAt: cursorUNIX,
382
+ status: 'pending',
383
+ type: template.type,
384
+ settings: template.settings,
385
+ recurrence: template.recurrence,
386
+ _virtual: true,
387
+ _recurringSourceId: template.id,
388
+ });
378
389
 
379
- while (cursor <= endDate && maxIterations-- > 0) {
380
- const cursorUNIX = Math.floor(cursor.getTime() / 1000);
381
-
382
- if (cursorUNIX >= startUNIX && cursorUNIX <= endUNIX) {
383
- occurrences.push({
384
- id: `${template.id}__virtual__${cursorUNIX}`,
385
- sendAt: cursorUNIX,
386
- status: 'pending',
387
- type: template.type,
388
- settings: template.settings,
389
- recurrence: template.recurrence,
390
- _virtual: true,
391
- _recurringSourceId: template.id,
392
- });
393
- }
394
-
395
- cursor = this._advanceCursor(recurrence, cursor);
390
+ cursorUNIX += interval;
396
391
  }
397
392
 
398
393
  return occurrences;
399
394
  }
400
395
 
401
- /**
402
- * Find the first occurrence at or after the given date.
403
- */
404
- _findFirstOccurrence(recurrence, startDate) {
405
- const hour = recurrence.hour || 0;
406
- const day = recurrence.day || 1;
407
- const month = recurrence.month || 1;
408
-
409
- switch (recurrence.pattern) {
410
- case 'daily': {
411
- const d = new Date(startDate);
412
- d.setHours(hour, 0, 0, 0);
413
- if (d < startDate) {
414
- d.setDate(d.getDate() + 1);
415
- }
416
- return d;
417
- }
418
- case 'weekly': {
419
- // day = day of week (0=Sun, 1=Mon, ..., 6=Sat)
420
- const d = new Date(startDate);
421
- d.setHours(hour, 0, 0, 0);
422
- const currentDay = d.getDay();
423
- let daysUntil = day - currentDay;
424
- if (daysUntil < 0 || (daysUntil === 0 && d < startDate)) {
425
- daysUntil += 7;
426
- }
427
- d.setDate(d.getDate() + daysUntil);
428
- return d;
429
- }
430
- case 'monthly': {
431
- // day = day of month
432
- const d = new Date(startDate.getFullYear(), startDate.getMonth(), day, hour, 0, 0, 0);
433
- if (d < startDate) {
434
- d.setMonth(d.getMonth() + 1);
435
- }
436
- return d;
437
- }
438
- case 'quarterly': {
439
- // Fires on day of month every 3 months, starting from month 0, 3, 6, 9
440
- const quarterMonths = [0, 3, 6, 9];
441
- for (const qm of quarterMonths) {
442
- const d = new Date(startDate.getFullYear(), qm, day, hour, 0, 0, 0);
443
- if (d >= startDate) {
444
- return d;
445
- }
446
- }
447
- // Next year's first quarter
448
- return new Date(startDate.getFullYear() + 1, 0, day, hour, 0, 0, 0);
449
- }
450
- case 'yearly': {
451
- // month = month (1-12), day = day of month
452
- const m = month - 1; // Convert 1-based to 0-based
453
- const d = new Date(startDate.getFullYear(), m, day, hour, 0, 0, 0);
454
- if (d < startDate) {
455
- d.setFullYear(d.getFullYear() + 1);
456
- }
457
- return d;
458
- }
459
- default:
460
- return new Date(startDate);
461
- }
462
- }
463
-
464
- /**
465
- * Advance the cursor to the next occurrence.
466
- */
467
- _advanceCursor(recurrence, cursor) {
468
- const d = new Date(cursor);
469
-
470
- switch (recurrence.pattern) {
471
- case 'daily':
472
- d.setDate(d.getDate() + 1);
473
- break;
474
- case 'weekly':
475
- d.setDate(d.getDate() + 7);
476
- break;
477
- case 'monthly':
478
- d.setMonth(d.getMonth() + 1);
479
- break;
480
- case 'quarterly':
481
- d.setMonth(d.getMonth() + 3);
482
- break;
483
- case 'yearly':
484
- d.setFullYear(d.getFullYear() + 1);
485
- break;
486
- default:
487
- // Prevent infinite loop
488
- d.setDate(d.getDate() + 1);
489
- break;
396
+ _getIntervalSeconds(pattern) {
397
+ const DAY = 86400;
398
+ switch (pattern) {
399
+ case 'daily': return DAY;
400
+ case 'weekly': return DAY * 7;
401
+ case 'monthly': return DAY * 30;
402
+ case 'quarterly': return DAY * 91;
403
+ case 'yearly': return DAY * 365;
404
+ default: return DAY;
490
405
  }
491
-
492
- return d;
493
406
  }
494
407
 
495
408
  // ============================================
496
- // Date Utilities
409
+ // Date Utilities (all UTC)
497
410
  // ============================================
498
411
  getDaysInMonth(year, month) {
499
- return new Date(year, month + 1, 0).getDate();
412
+ return new Date(Date.UTC(year, month + 1, 0)).getUTCDate();
500
413
  }
501
414
 
502
415
  getMonthGrid() {
503
- const year = this.currentDate.getFullYear();
504
- const month = this.currentDate.getMonth();
505
- const firstDay = new Date(year, month, 1).getDay();
416
+ const year = this.currentDate.getUTCFullYear();
417
+ const month = this.currentDate.getUTCMonth();
418
+ const firstDay = new Date(Date.UTC(year, month, 1)).getUTCDay();
506
419
  const daysInMonth = this.getDaysInMonth(year, month);
507
420
  const daysInPrevMonth = this.getDaysInMonth(year, month - 1);
508
421
 
509
422
  const cells = [];
510
423
 
511
- // Previous month trailing days
512
424
  for (let i = firstDay - 1; i >= 0; i--) {
513
- const d = new Date(year, month - 1, daysInPrevMonth - i);
514
- cells.push({ date: d, outside: true });
425
+ cells.push({ date: new Date(Date.UTC(year, month - 1, daysInPrevMonth - i)), outside: true });
515
426
  }
516
427
 
517
- // Current month days
518
428
  for (let i = 1; i <= daysInMonth; i++) {
519
- const d = new Date(year, month, i);
520
- cells.push({ date: d, outside: false });
429
+ cells.push({ date: new Date(Date.UTC(year, month, i)), outside: false });
521
430
  }
522
431
 
523
- // Next month leading days (fill to complete 6 rows = 42 cells)
524
432
  const remaining = 42 - cells.length;
525
433
  for (let i = 1; i <= remaining; i++) {
526
- const d = new Date(year, month + 1, i);
527
- cells.push({ date: d, outside: true });
434
+ cells.push({ date: new Date(Date.UTC(year, month + 1, i)), outside: true });
528
435
  }
529
436
 
530
437
  return cells;
@@ -532,13 +439,13 @@ export default class CalendarCore {
532
439
 
533
440
  getWeekDates() {
534
441
  const d = new Date(this.currentDate);
535
- const day = d.getDay();
536
- d.setDate(d.getDate() - day);
442
+ const day = d.getUTCDay();
443
+ d.setUTCDate(d.getUTCDate() - day);
537
444
 
538
445
  const dates = [];
539
446
  for (let i = 0; i < 7; i++) {
540
447
  dates.push(new Date(d));
541
- d.setDate(d.getDate() + 1);
448
+ d.setUTCDate(d.getUTCDate() + 1);
542
449
  }
543
450
 
544
451
  return dates;
@@ -546,22 +453,22 @@ export default class CalendarCore {
546
453
 
547
454
  formatPeriodLabel() {
548
455
  const d = this.currentDate;
549
- const month = MONTH_NAMES[d.getMonth()];
550
- const year = d.getFullYear();
456
+ const month = MONTH_NAMES[d.getUTCMonth()];
457
+ const year = d.getUTCFullYear();
551
458
 
552
459
  switch (this.viewMode) {
553
460
  case 'day':
554
- return `${DAY_ABBREVS[d.getDay()]}, ${month} ${d.getDate()}, ${year}`;
461
+ return `${DAY_ABBREVS[d.getUTCDay()]}, ${month} ${d.getUTCDate()}, ${year}`;
555
462
  case 'week': {
556
463
  const weekDates = this.getWeekDates();
557
464
  const start = weekDates[0];
558
465
  const end = weekDates[6];
559
- const startMonth = MONTH_NAMES[start.getMonth()].slice(0, 3);
560
- const endMonth = MONTH_NAMES[end.getMonth()].slice(0, 3);
561
- if (start.getMonth() === end.getMonth()) {
562
- return `${startMonth} ${start.getDate()} – ${end.getDate()}, ${year}`;
466
+ const startMonth = MONTH_NAMES[start.getUTCMonth()].slice(0, 3);
467
+ const endMonth = MONTH_NAMES[end.getUTCMonth()].slice(0, 3);
468
+ if (start.getUTCMonth() === end.getUTCMonth()) {
469
+ return `${startMonth} ${start.getUTCDate()} – ${end.getUTCDate()}, ${year}`;
563
470
  }
564
- return `${startMonth} ${start.getDate()} – ${endMonth} ${end.getDate()}, ${year}`;
471
+ return `${startMonth} ${start.getUTCDate()} – ${endMonth} ${end.getUTCDate()}, ${year}`;
565
472
  }
566
473
  case 'month':
567
474
  return `${month} ${year}`;
@@ -569,11 +476,9 @@ export default class CalendarCore {
569
476
  return `${year}`;
570
477
  case 'list': {
571
478
  const { startUNIX, endUNIX } = this._getVisibleRange();
572
- const startDate = new Date(startUNIX * 1000);
573
- const endDate = new Date(endUNIX * 1000);
574
- const startLabel = `${MONTH_NAMES[startDate.getMonth()].slice(0, 3)} ${startDate.getDate()}`;
575
- const endLabel = `${MONTH_NAMES[endDate.getMonth()].slice(0, 3)} ${endDate.getDate()}, ${endDate.getFullYear()}`;
576
- return `${startLabel} – ${endLabel}`;
479
+ const s = new Date(startUNIX * 1000);
480
+ const e = new Date(endUNIX * 1000);
481
+ return `${MONTH_NAMES[s.getUTCMonth()].slice(0, 3)} ${s.getUTCDate()} – ${MONTH_NAMES[e.getUTCMonth()].slice(0, 3)} ${e.getUTCDate()}, ${e.getUTCFullYear()}`;
577
482
  }
578
483
  default:
579
484
  return '';
@@ -582,20 +487,9 @@ export default class CalendarCore {
582
487
 
583
488
  isToday(date) {
584
489
  const today = new Date();
585
- return date.getFullYear() === today.getFullYear()
586
- && date.getMonth() === today.getMonth()
587
- && date.getDate() === today.getDate();
588
- }
589
-
590
- _formatDate(date) {
591
- const y = date.getFullYear();
592
- const m = String(date.getMonth() + 1).padStart(2, '0');
593
- const d = String(date.getDate()).padStart(2, '0');
594
- return `${y}-${m}-${d}`;
595
- }
596
-
597
- formatDate(date) {
598
- return this._formatDate(date);
490
+ return date.getUTCFullYear() === today.getUTCFullYear()
491
+ && date.getUTCMonth() === today.getUTCMonth()
492
+ && date.getUTCDate() === today.getUTCDate();
599
493
  }
600
494
 
601
495
  // ============================================
@@ -607,8 +501,6 @@ export default class CalendarCore {
607
501
  const db = this.webManager.firebaseFirestore;
608
502
  const colRef = collection(db, 'marketing-campaigns');
609
503
 
610
- // Fetch all recurring templates (IDs prefixed with _recurring-)
611
- // These are loaded once and kept in memory since virtuals are computed client-side
612
504
  const recurringDocs = await getDocs(query(
613
505
  colRef,
614
506
  where('__name__', '>=', '_recurring-'),
@@ -618,17 +510,14 @@ export default class CalendarCore {
618
510
  recurringDocs.forEach((doc) => {
619
511
  const data = doc.data();
620
512
  data.id = doc.id;
621
- console.log('[Calendar] Recurring template:', doc.id, data);
622
513
  this.campaigns.set(doc.id, data);
623
514
  });
624
515
 
625
- // Also listen for changes to recurring templates in real-time
626
516
  this._unsubscribeRecurring = onSnapshot(query(
627
517
  colRef,
628
518
  where('__name__', '>=', '_recurring-'),
629
519
  where('__name__', '<=', '_recurring-\uf8ff'),
630
520
  ), (snapshot) => {
631
- // Remove old recurring templates, add fresh ones
632
521
  for (const id of this.campaigns.keys()) {
633
522
  if (id.startsWith('_recurring-')) {
634
523
  this.campaigns.delete(id);
@@ -650,10 +539,9 @@ export default class CalendarCore {
650
539
  }
651
540
 
652
541
  // ============================================
653
- // Firestore: Range-based subscription (view-dependent)
542
+ // Firestore: Range-based subscription
654
543
  // ============================================
655
544
  async _subscribeToRange() {
656
- // Unsubscribe from previous range listener
657
545
  if (this._unsubscribeRange) {
658
546
  this._unsubscribeRange();
659
547
  this._unsubscribeRange = null;
@@ -673,22 +561,18 @@ export default class CalendarCore {
673
561
  );
674
562
 
675
563
  this._unsubscribeRange = onSnapshot(rangeQuery, (snapshot) => {
676
- // Remove old non-recurring docs (keep recurring templates)
677
564
  for (const id of this.campaigns.keys()) {
678
565
  if (!id.startsWith('_recurring-')) {
679
566
  this.campaigns.delete(id);
680
567
  }
681
568
  }
682
569
 
683
- // Add range-matched docs
684
570
  snapshot.forEach((doc) => {
685
571
  const data = doc.data();
686
572
  data.id = doc.id;
687
- console.log('[Calendar] Firestore doc:', doc.id, data);
688
573
  this.campaigns.set(doc.id, data);
689
574
  });
690
575
 
691
- console.log('[Calendar] Total docs:', this.campaigns.size);
692
576
  this._virtualEvents = null;
693
577
  this._dispatch('calendar:datachange');
694
578
  this.renderer.render();
@@ -701,8 +585,7 @@ export default class CalendarCore {
701
585
  }
702
586
 
703
587
  /**
704
- * Calculate the visible date range as unix timestamps.
705
- * Adds buffer to capture events on boundary days.
588
+ * Calculate visible date range as unix timestamps (UTC).
706
589
  */
707
590
  _getVisibleRange() {
708
591
  const d = this.currentDate;
@@ -710,31 +593,30 @@ export default class CalendarCore {
710
593
 
711
594
  switch (this.viewMode) {
712
595
  case 'day':
713
- start = new Date(d.getFullYear(), d.getMonth(), d.getDate());
714
- end = new Date(d.getFullYear(), d.getMonth(), d.getDate() + 1);
596
+ start = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
597
+ end = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate() + 1));
715
598
  break;
716
599
  case 'week': {
717
600
  const weekDates = this.getWeekDates();
718
601
  start = weekDates[0];
719
602
  end = new Date(weekDates[6]);
720
- end.setDate(end.getDate() + 1);
603
+ end.setUTCDate(end.getUTCDate() + 1);
721
604
  break;
722
605
  }
723
606
  case 'month': {
724
- // Include overflow days from prev/next month (6 rows of 7 = 42 days)
725
- const firstDay = new Date(d.getFullYear(), d.getMonth(), 1).getDay();
726
- start = new Date(d.getFullYear(), d.getMonth(), 1 - firstDay);
607
+ const firstDay = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1)).getUTCDay();
608
+ start = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1 - firstDay));
727
609
  end = new Date(start);
728
- end.setDate(end.getDate() + 42);
610
+ end.setUTCDate(end.getUTCDate() + 42);
729
611
  break;
730
612
  }
731
613
  case 'year':
732
- start = new Date(d.getFullYear(), 0, 1);
733
- end = new Date(d.getFullYear() + 1, 0, 1);
614
+ start = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
615
+ end = new Date(Date.UTC(d.getUTCFullYear() + 1, 0, 1));
734
616
  break;
735
617
  case 'list':
736
- start = new Date(d.getFullYear(), d.getMonth(), d.getDate() - 30);
737
- end = new Date(d.getFullYear(), d.getMonth(), d.getDate() + 60);
618
+ start = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate() - 30));
619
+ end = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate() + 60));
738
620
  break;
739
621
  }
740
622