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.
- package/CHANGELOG.md +4 -0
- package/CLAUDE.md +64 -1
- package/TODO.md +13 -0
- package/dist/assets/css/pages/admin/calendar/index.scss +212 -18
- package/dist/assets/js/pages/admin/calendar/calendar-core.js +535 -95
- package/dist/assets/js/pages/admin/calendar/calendar-events.js +631 -124
- package/dist/assets/js/pages/admin/calendar/calendar-renderer.js +238 -69
- package/dist/assets/js/pages/admin/calendar/campaign-preview.js +100 -0
- package/dist/assets/js/pages/admin/calendar/index.js +3 -16
- package/dist/assets/js/pages/contact/index.js +5 -1
- package/dist/defaults/dist/_includes/admin/sections/sidebar.json +0 -34
- package/dist/defaults/dist/_includes/admin/sections/topbar.json +0 -34
- package/dist/defaults/dist/_includes/themes/classy/backend/sections/topbar.html +1 -72
- package/dist/defaults/dist/_includes/themes/classy/frontend/sections/nav.html +7 -140
- package/dist/defaults/dist/_includes/themes/classy/global/sections/account.html +72 -0
- package/dist/defaults/dist/_layouts/blueprint/admin/calendar/index.html +442 -159
- package/dist/defaults/src/_includes/backend/sections/topbar.json +0 -34
- package/dist/defaults/src/_includes/frontend/sections/nav.json +0 -34
- package/dist/defaults/src/_includes/global/sections/account.json +36 -0
- package/package.json +2 -1
- package/dist/assets/js/pages/admin/notifications/index.js +0 -53
- package/dist/assets/js/pages/admin/notifications/new/index.js +0 -492
- package/dist/defaults/dist/_layouts/blueprint/admin/newsletters/index.html +0 -59
- package/dist/defaults/dist/_layouts/blueprint/admin/newsletters/new.html +0 -46
- package/dist/defaults/dist/_layouts/blueprint/admin/notifications/index.html +0 -103
- package/dist/defaults/dist/_layouts/blueprint/admin/notifications/new.html +0 -399
- package/dist/defaults/dist/pages/admin/newsletters/index.html +0 -7
- package/dist/defaults/dist/pages/admin/newsletters/new.html +0 -7
- package/dist/defaults/dist/pages/admin/notifications/index.html +0 -7
- 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,
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
127
|
+
this._virtualEvents = null;
|
|
92
128
|
this.renderer.render();
|
|
129
|
+
this._subscribeToRange();
|
|
130
|
+
this._dispatch('calendar:viewchange');
|
|
93
131
|
}
|
|
94
132
|
|
|
95
133
|
// ============================================
|
|
96
|
-
//
|
|
134
|
+
// Campaign Display Type Detection
|
|
97
135
|
// ============================================
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
182
|
+
// Return rollback function
|
|
183
|
+
return () => {
|
|
184
|
+
campaign.sendAt = originalSendAt;
|
|
185
|
+
this._virtualEvents = null;
|
|
186
|
+
this.renderer.render();
|
|
187
|
+
};
|
|
118
188
|
}
|
|
119
189
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
//
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
146
|
-
this.
|
|
147
|
-
|
|
148
|
-
|
|
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
|
|
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
|
-
|
|
154
|
-
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
346
|
+
const { startUNIX, endUNIX } = this._getVisibleRange();
|
|
347
|
+
const virtuals = [];
|
|
348
|
+
|
|
349
|
+
this.campaigns.forEach((c) => {
|
|
350
|
+
if (!c.recurrence) {
|
|
351
|
+
return;
|
|
166
352
|
}
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
171
|
-
|
|
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
|
-
|
|
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
|
-
|
|
178
|
-
|
|
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
|
-
//
|
|
602
|
+
// Firestore: One-time recurring template load
|
|
280
603
|
// ============================================
|
|
281
|
-
|
|
604
|
+
async _loadRecurringTemplates() {
|
|
282
605
|
try {
|
|
283
|
-
const
|
|
284
|
-
|
|
285
|
-
|
|
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
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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
|
-
|
|
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
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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
|
// ============================================
|