ultimate-jekyll-manager 1.0.2 → 1.0.4

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 (30) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/CLAUDE.md +64 -1
  3. package/TODO.md +13 -0
  4. package/dist/assets/css/pages/admin/calendar/index.scss +212 -18
  5. package/dist/assets/js/pages/admin/calendar/calendar-core.js +535 -95
  6. package/dist/assets/js/pages/admin/calendar/calendar-events.js +631 -124
  7. package/dist/assets/js/pages/admin/calendar/calendar-renderer.js +238 -69
  8. package/dist/assets/js/pages/admin/calendar/campaign-preview.js +100 -0
  9. package/dist/assets/js/pages/admin/calendar/index.js +3 -16
  10. package/dist/assets/js/pages/contact/index.js +5 -1
  11. package/dist/defaults/dist/_includes/admin/sections/sidebar.json +0 -34
  12. package/dist/defaults/dist/_includes/admin/sections/topbar.json +0 -34
  13. package/dist/defaults/dist/_includes/themes/classy/backend/sections/topbar.html +1 -72
  14. package/dist/defaults/dist/_includes/themes/classy/frontend/sections/nav.html +7 -140
  15. package/dist/defaults/dist/_includes/themes/classy/global/sections/account.html +72 -0
  16. package/dist/defaults/dist/_layouts/blueprint/admin/calendar/index.html +442 -159
  17. package/dist/defaults/src/_includes/backend/sections/topbar.json +0 -34
  18. package/dist/defaults/src/_includes/frontend/sections/nav.json +0 -34
  19. package/dist/defaults/src/_includes/global/sections/account.json +36 -0
  20. package/package.json +2 -1
  21. package/dist/assets/js/pages/admin/notifications/index.js +0 -53
  22. package/dist/assets/js/pages/admin/notifications/new/index.js +0 -492
  23. package/dist/defaults/dist/_layouts/blueprint/admin/newsletters/index.html +0 -59
  24. package/dist/defaults/dist/_layouts/blueprint/admin/newsletters/new.html +0 -46
  25. package/dist/defaults/dist/_layouts/blueprint/admin/notifications/index.html +0 -103
  26. package/dist/defaults/dist/_layouts/blueprint/admin/notifications/new.html +0 -399
  27. package/dist/defaults/dist/pages/admin/newsletters/index.html +0 -7
  28. package/dist/defaults/dist/pages/admin/newsletters/new.html +0 -7
  29. package/dist/defaults/dist/pages/admin/notifications/index.html +0 -7
  30. package/dist/defaults/dist/pages/admin/notifications/new.html +0 -7
@@ -1,19 +1,12 @@
1
1
  /**
2
2
  * Calendar Core
3
- * State management, date math, localStorage persistence, and public API.
3
+ * State management, date math, Firestore real-time sync, recurrence
4
+ * computation, and public API.
5
+ * Reads from Firestore marketing-campaigns collection directly.
4
6
  */
5
7
 
6
- // Storage key
7
- const STORAGE_KEY = '_admin.calendar.events';
8
-
9
- // Default event colors
10
- export const EVENT_COLORS = [
11
- '#4CAF50', '#2196F3', '#FF9800', '#F44336',
12
- '#9C27B0', '#00BCD4', '#795548', '#607D8B',
13
- ];
14
-
15
8
  // View modes
16
- export const VIEW_MODES = ['day', 'week', 'month', 'year'];
9
+ export const VIEW_MODES = ['day', 'week', 'month', 'year', 'list'];
17
10
 
18
11
  // Day abbreviations
19
12
  export const DAY_ABBREVS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
@@ -24,16 +17,40 @@ export const MONTH_NAMES = [
24
17
  'July', 'August', 'September', 'October', 'November', 'December',
25
18
  ];
26
19
 
20
+ // Recurrence pattern options
21
+ export const RECURRENCE_PATTERNS = ['daily', 'weekly', 'monthly', 'quarterly', 'yearly'];
22
+
23
+ // Campaign type colors
24
+ export const TYPE_COLORS = {
25
+ email: '#2196F3',
26
+ push: '#4CAF50',
27
+ };
28
+
29
+ // Campaign status styles
30
+ export const STATUS_STYLES = {
31
+ pending: { opacity: 1, icon: null },
32
+ sent: { opacity: 0.55, icon: 'circle-check' },
33
+ failed: { opacity: 1, icon: 'triangle-exclamation' },
34
+ };
35
+
36
+ // Display type constants
37
+ export const DISPLAY_TYPES = {
38
+ ONE_OFF: 'one-off',
39
+ RECURRING_TEMPLATE: 'recurring-template',
40
+ RECURRING_HISTORY: 'recurring-history',
41
+ };
42
+
27
43
  export default class CalendarCore {
28
- constructor() {
44
+ constructor(webManager) {
45
+ this.webManager = webManager;
29
46
  this.currentDate = new Date();
30
47
  this.viewMode = 'month';
31
- this.events = new Map();
48
+ this.campaigns = new Map(); // Real Firestore docs
49
+ this._virtualEvents = null; // Cached virtual events (regenerated on data change)
32
50
  this.renderer = null;
33
51
  this.eventsManager = null;
34
52
  this.$root = document.getElementById('calendar-root');
35
-
36
- this._loadEvents();
53
+ this._unsubscribe = null;
37
54
  }
38
55
 
39
56
  // ============================================
@@ -47,8 +64,20 @@ export default class CalendarCore {
47
64
  this.eventsManager = eventsManager;
48
65
  }
49
66
 
50
- initialize() {
51
- this.renderer.render();
67
+ async initialize() {
68
+ await this._loadRecurringTemplates();
69
+ this._subscribeToRange();
70
+ }
71
+
72
+ destroy() {
73
+ if (this._unsubscribeRange) {
74
+ this._unsubscribeRange();
75
+ this._unsubscribeRange = null;
76
+ }
77
+ if (this._unsubscribeRecurring) {
78
+ this._unsubscribeRecurring();
79
+ this._unsubscribeRecurring = null;
80
+ }
52
81
  }
53
82
 
54
83
  // ============================================
@@ -70,16 +99,23 @@ export default class CalendarCore {
70
99
  case 'year':
71
100
  d.setFullYear(d.getFullYear() + direction);
72
101
  break;
102
+ case 'list':
103
+ d.setDate(d.getDate() + (30 * direction));
104
+ break;
73
105
  }
74
106
 
107
+ this._virtualEvents = null;
108
+ this.renderer.render(); // Instant render from cache
109
+ this._subscribeToRange(); // Background fetch for accurate data
75
110
  this._dispatch('calendar:navigate');
76
- this.renderer.render();
77
111
  }
78
112
 
79
113
  goToToday() {
80
114
  this.currentDate = new Date();
81
- this._dispatch('calendar:navigate');
115
+ this._virtualEvents = null;
82
116
  this.renderer.render();
117
+ this._subscribeToRange();
118
+ this._dispatch('calendar:navigate');
83
119
  }
84
120
 
85
121
  setView(mode) {
@@ -88,94 +124,372 @@ export default class CalendarCore {
88
124
  }
89
125
 
90
126
  this.viewMode = mode;
91
- this._dispatch('calendar:viewchange');
127
+ this._virtualEvents = null;
92
128
  this.renderer.render();
129
+ this._subscribeToRange();
130
+ this._dispatch('calendar:viewchange');
93
131
  }
94
132
 
95
133
  // ============================================
96
- // Event CRUD
134
+ // Campaign Display Type Detection
97
135
  // ============================================
98
- addEvent(eventData) {
99
- const id = 'evt_' + Date.now() + '_' + Math.random().toString(36).slice(2, 7);
100
- const event = {
101
- id,
102
- title: eventData.title || 'Untitled',
103
- type: eventData.type || 'newsletter',
104
- date: eventData.date || this._formatDate(this.currentDate),
105
- time: eventData.time || '09:00',
106
- duration: eventData.duration || 60,
107
- status: eventData.status || 'draft',
108
- color: eventData.color || EVENT_COLORS[0],
109
- data: eventData.data || {},
110
- };
136
+ getCampaignDisplayType(doc) {
137
+ if (doc.recurrence) {
138
+ return DISPLAY_TYPES.RECURRING_TEMPLATE;
139
+ }
140
+ if (doc.recurringId) {
141
+ return DISPLAY_TYPES.RECURRING_HISTORY;
142
+ }
143
+ return DISPLAY_TYPES.ONE_OFF;
144
+ }
145
+
146
+ isEditable(doc) {
147
+ // Virtual occurrences are not editable (they don't exist as docs)
148
+ if (doc._virtual) {
149
+ return false;
150
+ }
151
+ // History records are read-only
152
+ if (doc.recurringId) {
153
+ return false;
154
+ }
155
+ // Sent/failed are read-only
156
+ if (doc.status === 'sent' || doc.status === 'failed') {
157
+ return false;
158
+ }
159
+ // Pending one-offs and recurring templates are editable
160
+ return true;
161
+ }
111
162
 
112
- this.events.set(id, event);
113
- this._saveEvents();
114
- this._dispatch('calendar:eventchange', { action: 'add', event });
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
115
180
  this.renderer.render();
116
181
 
117
- return id;
182
+ // Return rollback function
183
+ return () => {
184
+ campaign.sendAt = originalSendAt;
185
+ this._virtualEvents = null;
186
+ this.renderer.render();
187
+ };
118
188
  }
119
189
 
120
- updateEvent(id, changes) {
121
- const event = this.events.get(id);
122
- if (!event) {
123
- return null;
190
+ // ============================================
191
+ // Campaign Accessors
192
+ // ============================================
193
+ getCampaign(id) {
194
+ // Check real Firestore docs first
195
+ const real = this.campaigns.get(id);
196
+ if (real) {
197
+ return real;
124
198
  }
125
199
 
126
- // Deep merge data if provided
127
- if (changes.data) {
128
- changes.data = { ...event.data, ...changes.data };
200
+ // Check virtual events (synthetic IDs like "templateId__virtual__timestamp")
201
+ const virtuals = this._getVirtualEvents();
202
+ for (const v of virtuals) {
203
+ if (v.id === id) {
204
+ return v;
205
+ }
129
206
  }
130
207
 
131
- Object.assign(event, changes);
132
- this._saveEvents();
133
- this._dispatch('calendar:eventchange', { action: 'update', event });
134
- this.renderer.render();
208
+ return null;
209
+ }
210
+
211
+ /**
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).
214
+ */
215
+ getCampaignsForDate(dateStr) {
216
+ const items = [];
217
+ const historyDatesById = this._getHistoryDateMap();
218
+
219
+ // Real Firestore docs that land on this date (skip recurring templates — they render via virtuals)
220
+ this.campaigns.forEach((c) => {
221
+ if (c.recurrence) {
222
+ return;
223
+ }
224
+ if (this._campaignDate(c) === dateStr) {
225
+ items.push(c);
226
+ }
227
+ });
135
228
 
136
- return event;
229
+ // Virtual recurring occurrences for this date
230
+ const virtuals = this._getVirtualEvents();
231
+ virtuals.forEach((v) => {
232
+ if (this._campaignDate(v) !== dateStr) {
233
+ return;
234
+ }
235
+ // Don't add a virtual if a history record already covers this date for this recurring template
236
+ const key = `${v._recurringSourceId}:${dateStr}`;
237
+ if (historyDatesById.has(key)) {
238
+ return;
239
+ }
240
+ items.push(v);
241
+ });
242
+
243
+ return items.sort((a, b) => a.sendAt - b.sendAt);
137
244
  }
138
245
 
139
- removeEvent(id) {
140
- const event = this.events.get(id);
141
- if (!event) {
142
- return false;
143
- }
246
+ /**
247
+ * Get all display items sorted chronologically (for list view).
248
+ * Merges non-recurring docs + virtual recurring occurrences.
249
+ */
250
+ getAllCampaignsSorted() {
251
+ const items = [];
252
+ const historyDatesById = this._getHistoryDateMap();
253
+
254
+ // Real non-recurring docs
255
+ this.campaigns.forEach((c) => {
256
+ if (c.recurrence) {
257
+ return;
258
+ }
259
+ items.push(c);
260
+ });
144
261
 
145
- this.events.delete(id);
146
- this._saveEvents();
147
- this._dispatch('calendar:eventchange', { action: 'remove', event });
148
- this.renderer.render();
262
+ // Virtual recurring occurrences (suppress where history exists)
263
+ const virtuals = this._getVirtualEvents();
264
+ virtuals.forEach((v) => {
265
+ const dateStr = this._campaignDate(v);
266
+ const key = `${v._recurringSourceId}:${dateStr}`;
267
+ if (historyDatesById.has(key)) {
268
+ return;
269
+ }
270
+ items.push(v);
271
+ });
149
272
 
150
- return true;
273
+ return items.sort((a, b) => a.sendAt - b.sendAt);
274
+ }
275
+
276
+ // ============================================
277
+ // Campaign Helpers
278
+ // ============================================
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
+ campaignColor(campaign) {
307
+ return TYPE_COLORS[campaign.type] || TYPE_COLORS.email;
308
+ }
309
+
310
+ /**
311
+ * Get status style info
312
+ */
313
+ campaignStatusStyle(campaign) {
314
+ return STATUS_STYLES[campaign.status] || STATUS_STYLES.pending;
151
315
  }
152
316
 
153
- getEvent(id) {
154
- return this.events.get(id) || null;
317
+ // ============================================
318
+ // Recurrence: Virtual Event Generation
319
+ // ============================================
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
+ _getHistoryDateMap() {
326
+ const map = new Map();
327
+ this.campaigns.forEach((c) => {
328
+ if (!c.recurringId) {
329
+ return;
330
+ }
331
+ const dateStr = this._campaignDate(c);
332
+ map.set(`${c.recurringId}:${dateStr}`, true);
333
+ });
334
+ return map;
155
335
  }
156
336
 
157
- getEvents(filter) {
158
- const events = Array.from(this.events.values());
159
- if (!filter) {
160
- return events;
337
+ /**
338
+ * Get virtual events for recurring templates within the visible range.
339
+ * Cached per render cycle; cleared when Firestore data changes.
340
+ */
341
+ _getVirtualEvents() {
342
+ if (this._virtualEvents) {
343
+ return this._virtualEvents;
161
344
  }
162
345
 
163
- return events.filter((evt) => {
164
- if (filter.date && evt.date !== filter.date) {
165
- return false;
346
+ const { startUNIX, endUNIX } = this._getVisibleRange();
347
+ const virtuals = [];
348
+
349
+ this.campaigns.forEach((c) => {
350
+ if (!c.recurrence) {
351
+ return;
166
352
  }
167
- if (filter.type && evt.type !== filter.type) {
168
- return false;
353
+ const occurrences = this._generateOccurrences(c, startUNIX, endUNIX);
354
+ virtuals.push(...occurrences);
355
+ });
356
+
357
+ this._virtualEvents = virtuals;
358
+ return virtuals;
359
+ }
360
+
361
+ /**
362
+ * Generate virtual occurrence items for a recurring template within a date range.
363
+ */
364
+ _generateOccurrences(template, startUNIX, endUNIX) {
365
+ const recurrence = template.recurrence;
366
+ const hour = recurrence.hour || 0;
367
+ const occurrences = [];
368
+
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);
375
+
376
+ // Safety: limit iterations
377
+ let maxIterations = 400;
378
+
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
+ });
169
393
  }
170
- if (filter.status && evt.status !== filter.status) {
171
- return false;
394
+
395
+ cursor = this._advanceCursor(recurrence, cursor);
396
+ }
397
+
398
+ return occurrences;
399
+ }
400
+
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;
172
417
  }
173
- return true;
174
- });
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
+ }
175
462
  }
176
463
 
177
- getEventsForDate(dateStr) {
178
- return this.getEvents({ date: dateStr }).sort((a, b) => a.time.localeCompare(b.time));
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;
490
+ }
491
+
492
+ return d;
179
493
  }
180
494
 
181
495
  // ============================================
@@ -253,6 +567,16 @@ export default class CalendarCore {
253
567
  return `${month} ${year}`;
254
568
  case 'year':
255
569
  return `${year}`;
570
+ case 'list': {
571
+ 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}`;
577
+ }
578
+ default:
579
+ return '';
256
580
  }
257
581
  }
258
582
 
@@ -263,7 +587,6 @@ export default class CalendarCore {
263
587
  && date.getDate() === today.getDate();
264
588
  }
265
589
 
266
- // Format Date to YYYY-MM-DD
267
590
  _formatDate(date) {
268
591
  const y = date.getFullYear();
269
592
  const m = String(date.getMonth() + 1).padStart(2, '0');
@@ -276,32 +599,149 @@ export default class CalendarCore {
276
599
  }
277
600
 
278
601
  // ============================================
279
- // Persistence
602
+ // Firestore: One-time recurring template load
280
603
  // ============================================
281
- _loadEvents() {
604
+ async _loadRecurringTemplates() {
282
605
  try {
283
- const raw = localStorage.getItem(STORAGE_KEY);
284
- if (!raw) {
285
- return;
286
- }
606
+ const { collection, query, where, getDocs, onSnapshot } = await import('firebase/firestore');
607
+ const db = this.webManager.firebaseFirestore;
608
+ const colRef = collection(db, 'marketing-campaigns');
609
+
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
+ const recurringDocs = await getDocs(query(
613
+ colRef,
614
+ where('__name__', '>=', '_recurring-'),
615
+ where('__name__', '<=', '_recurring-\uf8ff'),
616
+ ));
617
+
618
+ recurringDocs.forEach((doc) => {
619
+ const data = doc.data();
620
+ data.id = doc.id;
621
+ console.log('[Calendar] Recurring template:', doc.id, data);
622
+ this.campaigns.set(doc.id, data);
623
+ });
287
624
 
288
- const arr = JSON.parse(raw);
289
- arr.forEach((evt) => {
290
- this.events.set(evt.id, evt);
625
+ // Also listen for changes to recurring templates in real-time
626
+ this._unsubscribeRecurring = onSnapshot(query(
627
+ colRef,
628
+ where('__name__', '>=', '_recurring-'),
629
+ where('__name__', '<=', '_recurring-\uf8ff'),
630
+ ), (snapshot) => {
631
+ // Remove old recurring templates, add fresh ones
632
+ for (const id of this.campaigns.keys()) {
633
+ if (id.startsWith('_recurring-')) {
634
+ this.campaigns.delete(id);
635
+ }
636
+ }
637
+ snapshot.forEach((doc) => {
638
+ const data = doc.data();
639
+ data.id = doc.id;
640
+ this.campaigns.set(doc.id, data);
641
+ });
642
+ this._virtualEvents = null;
643
+ this.renderer.render();
291
644
  });
292
- } catch (e) {
293
- // Corrupt data, start fresh
294
- console.warn('Calendar: Failed to load events from localStorage', e);
645
+
646
+ this.renderer.render();
647
+ } catch (error) {
648
+ console.error('Calendar: Failed to load recurring templates', error);
295
649
  }
296
650
  }
297
651
 
298
- _saveEvents() {
652
+ // ============================================
653
+ // Firestore: Range-based subscription (view-dependent)
654
+ // ============================================
655
+ async _subscribeToRange() {
656
+ // Unsubscribe from previous range listener
657
+ if (this._unsubscribeRange) {
658
+ this._unsubscribeRange();
659
+ this._unsubscribeRange = null;
660
+ }
661
+
662
+ const { startUNIX, endUNIX } = this._getVisibleRange();
663
+
299
664
  try {
300
- const arr = Array.from(this.events.values());
301
- localStorage.setItem(STORAGE_KEY, JSON.stringify(arr));
302
- } catch (e) {
303
- console.warn('Calendar: Failed to save events to localStorage', e);
665
+ const { collection, query, where, orderBy, onSnapshot } = await import('firebase/firestore');
666
+ const db = this.webManager.firebaseFirestore;
667
+
668
+ const rangeQuery = query(
669
+ collection(db, 'marketing-campaigns'),
670
+ where('sendAt', '>=', startUNIX),
671
+ where('sendAt', '<=', endUNIX),
672
+ orderBy('sendAt', 'asc'),
673
+ );
674
+
675
+ this._unsubscribeRange = onSnapshot(rangeQuery, (snapshot) => {
676
+ // Remove old non-recurring docs (keep recurring templates)
677
+ for (const id of this.campaigns.keys()) {
678
+ if (!id.startsWith('_recurring-')) {
679
+ this.campaigns.delete(id);
680
+ }
681
+ }
682
+
683
+ // Add range-matched docs
684
+ snapshot.forEach((doc) => {
685
+ const data = doc.data();
686
+ data.id = doc.id;
687
+ console.log('[Calendar] Firestore doc:', doc.id, data);
688
+ this.campaigns.set(doc.id, data);
689
+ });
690
+
691
+ console.log('[Calendar] Total docs:', this.campaigns.size);
692
+ this._virtualEvents = null;
693
+ this._dispatch('calendar:datachange');
694
+ this.renderer.render();
695
+ }, (error) => {
696
+ console.error('Calendar: Firestore range subscription error', error);
697
+ });
698
+ } catch (error) {
699
+ console.error('Calendar: Failed to subscribe to range', error);
700
+ }
701
+ }
702
+
703
+ /**
704
+ * Calculate the visible date range as unix timestamps.
705
+ * Adds buffer to capture events on boundary days.
706
+ */
707
+ _getVisibleRange() {
708
+ const d = this.currentDate;
709
+ let start, end;
710
+
711
+ switch (this.viewMode) {
712
+ case 'day':
713
+ start = new Date(d.getFullYear(), d.getMonth(), d.getDate());
714
+ end = new Date(d.getFullYear(), d.getMonth(), d.getDate() + 1);
715
+ break;
716
+ case 'week': {
717
+ const weekDates = this.getWeekDates();
718
+ start = weekDates[0];
719
+ end = new Date(weekDates[6]);
720
+ end.setDate(end.getDate() + 1);
721
+ break;
722
+ }
723
+ 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);
727
+ end = new Date(start);
728
+ end.setDate(end.getDate() + 42);
729
+ break;
730
+ }
731
+ case 'year':
732
+ start = new Date(d.getFullYear(), 0, 1);
733
+ end = new Date(d.getFullYear() + 1, 0, 1);
734
+ break;
735
+ case 'list':
736
+ start = new Date(d.getFullYear(), d.getMonth(), d.getDate() - 30);
737
+ end = new Date(d.getFullYear(), d.getMonth(), d.getDate() + 60);
738
+ break;
304
739
  }
740
+
741
+ return {
742
+ startUNIX: Math.floor(start.getTime() / 1000),
743
+ endUNIX: Math.floor(end.getTime() / 1000),
744
+ };
305
745
  }
306
746
 
307
747
  // ============================================