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.
- package/dist/assets/js/pages/admin/calendar/calendar-core.js +168 -286
- package/dist/assets/js/pages/admin/calendar/calendar-events.js +60 -38
- package/dist/assets/js/pages/admin/calendar/calendar-renderer.js +183 -330
- package/dist/defaults/dist/_layouts/blueprint/admin/calendar/index.html +3 -2
- package/package.json +1 -1
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Calendar Core
|
|
3
|
-
* State management, date math, Firestore real-time sync,
|
|
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();
|
|
49
|
-
this._virtualEvents = null;
|
|
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.
|
|
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.
|
|
130
|
+
d.setUTCDate(d.getUTCDate() + direction);
|
|
92
131
|
break;
|
|
93
132
|
case 'week':
|
|
94
|
-
d.
|
|
133
|
+
d.setUTCDate(d.getUTCDate() + (7 * direction));
|
|
95
134
|
break;
|
|
96
135
|
case 'month':
|
|
97
|
-
d.
|
|
136
|
+
d.setUTCMonth(d.getUTCMonth() + direction);
|
|
98
137
|
break;
|
|
99
138
|
case 'year':
|
|
100
|
-
d.
|
|
139
|
+
d.setUTCFullYear(d.getUTCFullYear() + direction);
|
|
101
140
|
break;
|
|
102
141
|
case 'list':
|
|
103
|
-
d.
|
|
142
|
+
d.setUTCDate(d.getUTCDate() + (30 * direction));
|
|
104
143
|
break;
|
|
105
144
|
}
|
|
106
145
|
|
|
107
146
|
this._virtualEvents = null;
|
|
108
|
-
this.renderer.render();
|
|
109
|
-
this._subscribeToRange();
|
|
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
|
-
|
|
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
|
|
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
|
|
213
|
-
* Merges
|
|
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
|
|
256
|
+
// Real non-recurring docs
|
|
220
257
|
this.campaigns.forEach((c) => {
|
|
221
258
|
if (c.recurrence) {
|
|
222
259
|
return;
|
|
223
260
|
}
|
|
224
|
-
if (
|
|
261
|
+
if (formatDateUTC(c.sendAt) === dateStr) {
|
|
225
262
|
items.push(c);
|
|
226
263
|
}
|
|
227
264
|
});
|
|
228
265
|
|
|
229
|
-
// Virtual recurring occurrences
|
|
266
|
+
// Virtual recurring occurrences
|
|
230
267
|
const virtuals = this._getVirtualEvents();
|
|
231
268
|
virtuals.forEach((v) => {
|
|
232
|
-
|
|
269
|
+
const vDate = formatDateUTC(v.sendAt);
|
|
270
|
+
if (vDate !== dateStr) {
|
|
233
271
|
return;
|
|
234
272
|
}
|
|
235
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
//
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
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
|
-
|
|
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
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
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).
|
|
412
|
+
return new Date(Date.UTC(year, month + 1, 0)).getUTCDate();
|
|
500
413
|
}
|
|
501
414
|
|
|
502
415
|
getMonthGrid() {
|
|
503
|
-
const year = this.currentDate.
|
|
504
|
-
const month = this.currentDate.
|
|
505
|
-
const firstDay = new Date(year, month, 1).
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
536
|
-
d.
|
|
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.
|
|
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.
|
|
550
|
-
const year = d.
|
|
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.
|
|
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.
|
|
560
|
-
const endMonth = MONTH_NAMES[end.
|
|
561
|
-
if (start.
|
|
562
|
-
return `${startMonth} ${start.
|
|
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.
|
|
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
|
|
573
|
-
const
|
|
574
|
-
|
|
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.
|
|
586
|
-
&& date.
|
|
587
|
-
&& date.
|
|
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
|
|
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
|
|
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.
|
|
714
|
-
end = new Date(d.
|
|
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.
|
|
603
|
+
end.setUTCDate(end.getUTCDate() + 1);
|
|
721
604
|
break;
|
|
722
605
|
}
|
|
723
606
|
case 'month': {
|
|
724
|
-
|
|
725
|
-
|
|
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.
|
|
610
|
+
end.setUTCDate(end.getUTCDate() + 42);
|
|
729
611
|
break;
|
|
730
612
|
}
|
|
731
613
|
case 'year':
|
|
732
|
-
start = new Date(d.
|
|
733
|
-
end = new Date(d.
|
|
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.
|
|
737
|
-
end = new Date(d.
|
|
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
|
|