UNPKG

ultimate-jekyll-manager

Version:
636 lines (552 loc) 17.7 kB
/** * Calendar Core * State management, date math (all UTC), Firestore real-time sync, * recurrence computation, and public API. * Reads from Firestore marketing-campaigns collection directly. * * IMPORTANT: All dates/times in this module are UTC. * No local time APIs (getHours, getDate, etc.) are used anywhere. */ import webManager from 'web-manager'; // View modes export const VIEW_MODES = ['day', 'week', 'month', 'year', 'list']; // Day abbreviations export const DAY_ABBREVS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; // Month names export const MONTH_NAMES = [ 'January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December', ]; // Campaign type colors export const TYPE_COLORS = { email: '#2196F3', push: '#4CAF50', }; // Campaign status styles export const STATUS_STYLES = { pending: { opacity: 1, icon: null }, sent: { opacity: 0.55, icon: 'circle-check' }, failed: { opacity: 1, icon: 'triangle-exclamation' }, }; // Display type constants export const DISPLAY_TYPES = { ONE_OFF: 'one-off', RECURRING_TEMPLATE: 'recurring-template', RECURRING_HISTORY: 'recurring-history', }; // ============================================ // Shared UTC date utilities // ============================================ /** * Format a Date or unix timestamp to YYYY-MM-DD (UTC). */ export function formatDateUTC(input) { const d = typeof input === 'number' ? new Date(input * 1000) : input; const y = d.getUTCFullYear(); const m = String(d.getUTCMonth() + 1).padStart(2, '0'); const day = String(d.getUTCDate()).padStart(2, '0'); return `${y}-${m}-${day}`; } /** * Format a Date or unix timestamp to HH:MM (UTC). */ export function formatTimeUTC(input) { const d = typeof input === 'number' ? new Date(input * 1000) : input; return String(d.getUTCHours()).padStart(2, '0') + ':' + String(d.getUTCMinutes()).padStart(2, '0'); } /** * Parse a YYYY-MM-DD string as a UTC midnight Date. */ export function parseDateUTC(dateStr) { const [y, m, d] = dateStr.split('-').map(Number); return new Date(Date.UTC(y, m - 1, d)); } /** * Get today's date as YYYY-MM-DD (UTC). */ export function todayUTC() { return formatDateUTC(new Date()); } export default class CalendarCore { constructor() { this.currentDate = new Date(); this.viewMode = 'month'; this.campaigns = new Map(); this._virtualEvents = null; this.renderer = null; this.eventsManager = null; this.$root = document.getElementById('calendar-root'); this._unsubscribeRange = null; this._unsubscribeRecurring = null; } // ============================================ // Initialization // ============================================ setRenderer(renderer) { this.renderer = renderer; } setEventsManager(eventsManager) { this.eventsManager = eventsManager; } async initialize() { await this._loadRecurringTemplates(); this._subscribeToRange(); } destroy() { if (this._unsubscribeRange) { this._unsubscribeRange(); this._unsubscribeRange = null; } if (this._unsubscribeRecurring) { this._unsubscribeRecurring(); this._unsubscribeRecurring = null; } } // ============================================ // Navigation (all UTC) // ============================================ navigate(direction) { const d = this.currentDate; switch (this.viewMode) { case 'day': d.setUTCDate(d.getUTCDate() + direction); break; case 'week': d.setUTCDate(d.getUTCDate() + (7 * direction)); break; case 'month': d.setUTCMonth(d.getUTCMonth() + direction); break; case 'year': d.setUTCFullYear(d.getUTCFullYear() + direction); break; case 'list': d.setUTCDate(d.getUTCDate() + (30 * direction)); break; } this._virtualEvents = null; this.renderer.render(); this._subscribeToRange(); this._dispatch('calendar:navigate'); } goToToday() { this.currentDate = new Date(); this._virtualEvents = null; this.renderer.render(); this._subscribeToRange(); this._dispatch('calendar:navigate'); } setView(mode) { if (!VIEW_MODES.includes(mode)) { return; } this.viewMode = mode; this._virtualEvents = null; this.renderer.render(); this._subscribeToRange(); this._dispatch('calendar:viewchange'); } // ============================================ // Optimistic Updates // ============================================ /** * Optimistically update a campaign's sendAt locally and re-render. * Returns a rollback function. */ optimisticUpdateSendAt(id, newSendAtUNIX) { const campaign = this.campaigns.get(id); if (!campaign) { return null; } const originalSendAt = campaign.sendAt; campaign.sendAt = newSendAtUNIX; this._virtualEvents = null; this.renderer.render(); return () => { campaign.sendAt = originalSendAt; this._virtualEvents = null; this.renderer.render(); }; } // ============================================ // Campaign Display Type Detection // ============================================ getCampaignDisplayType(doc) { if (doc.recurrence) { return DISPLAY_TYPES.RECURRING_TEMPLATE; } if (doc.recurringId) { return DISPLAY_TYPES.RECURRING_HISTORY; } return DISPLAY_TYPES.ONE_OFF; } isEditable(doc) { if (doc._virtual) { return false; } if (doc.recurringId) { return false; } if (doc.status === 'sent' || doc.status === 'failed') { return false; } return true; } isRecurring(campaign) { return !!(campaign.recurrence || campaign.recurringId || campaign._virtual); } // ============================================ // Campaign Accessors // ============================================ getCampaign(id) { const real = this.campaigns.get(id); if (real) { return real; } // Check virtual events const virtuals = this._getVirtualEvents(); for (const v of virtuals) { if (v.id === id) { return v; } } return null; } /** * Get all display items for a date (YYYY-MM-DD UTC string). * Merges real docs + virtual recurring occurrences. */ getCampaignsForDate(dateStr) { const items = []; const historyDatesById = this._getHistoryDateMap(); // Real non-recurring docs this.campaigns.forEach((c) => { if (c.recurrence) { return; } if (formatDateUTC(c.sendAt) === dateStr) { items.push(c); } }); // Virtual recurring occurrences const virtuals = this._getVirtualEvents(); virtuals.forEach((v) => { const vDate = formatDateUTC(v.sendAt); if (vDate !== dateStr) { return; } const key = `${v._recurringSourceId}:${vDate}`; if (historyDatesById.has(key)) { return; } items.push(v); }); return items.sort((a, b) => a.sendAt - b.sendAt); } /** * Get all display items sorted chronologically (for list view). */ getAllCampaignsSorted() { const items = []; const historyDatesById = this._getHistoryDateMap(); this.campaigns.forEach((c) => { if (c.recurrence) { return; } items.push(c); }); const virtuals = this._getVirtualEvents(); virtuals.forEach((v) => { const dateStr = formatDateUTC(v.sendAt); const key = `${v._recurringSourceId}:${dateStr}`; if (historyDatesById.has(key)) { return; } items.push(v); }); return items.sort((a, b) => a.sendAt - b.sendAt); } // ============================================ // Campaign Helpers // ============================================ campaignColor(campaign) { return TYPE_COLORS[campaign.type] || TYPE_COLORS.email; } campaignStatusStyle(campaign) { return STATUS_STYLES[campaign.status] || STATUS_STYLES.pending; } campaignDuration() { return 60; } // ============================================ // Recurrence: Virtual Event Generation // ============================================ _getHistoryDateMap() { const map = new Map(); this.campaigns.forEach((c) => { if (!c.recurringId) { return; } map.set(`${c.recurringId}:${formatDateUTC(c.sendAt)}`, true); }); return map; } _getVirtualEvents() { if (this._virtualEvents) { return this._virtualEvents; } const { startUNIX, endUNIX } = this._getVisibleRange(); const virtuals = []; this.campaigns.forEach((c) => { if (!c.recurrence) { return; } const occurrences = this._generateOccurrences(c, startUNIX, endUNIX); virtuals.push(...occurrences); }); this._virtualEvents = virtuals; return virtuals; } /** * Generate virtual occurrences using the template's sendAt as seed. * Pure unix math — no Date objects, no timezone issues. */ _generateOccurrences(template, startUNIX, endUNIX) { const interval = this._getIntervalSeconds(template.recurrence.pattern); const occurrences = []; const seedUNIX = template.sendAt; // Walk backward from seed to find first occurrence >= startUNIX let cursorUNIX = seedUNIX; while (cursorUNIX > startUNIX + interval) { cursorUNIX -= interval; } while (cursorUNIX < startUNIX) { cursorUNIX += interval; } let maxIterations = 400; while (cursorUNIX <= endUNIX && maxIterations-- > 0) { occurrences.push({ id: `${template.id}__virtual__${cursorUNIX}`, sendAt: cursorUNIX, status: 'pending', type: template.type, settings: template.settings, recurrence: template.recurrence, _virtual: true, _recurringSourceId: template.id, }); cursorUNIX += interval; } return occurrences; } _getIntervalSeconds(pattern) { const DAY = 86400; switch (pattern) { case 'daily': return DAY; case 'weekly': return DAY * 7; case 'monthly': return DAY * 30; case 'quarterly': return DAY * 91; case 'yearly': return DAY * 365; default: return DAY; } } // ============================================ // Date Utilities (all UTC) // ============================================ getDaysInMonth(year, month) { return new Date(Date.UTC(year, month + 1, 0)).getUTCDate(); } getMonthGrid() { const year = this.currentDate.getUTCFullYear(); const month = this.currentDate.getUTCMonth(); const firstDay = new Date(Date.UTC(year, month, 1)).getUTCDay(); const daysInMonth = this.getDaysInMonth(year, month); const daysInPrevMonth = this.getDaysInMonth(year, month - 1); const cells = []; for (let i = firstDay - 1; i >= 0; i--) { cells.push({ date: new Date(Date.UTC(year, month - 1, daysInPrevMonth - i)), outside: true }); } for (let i = 1; i <= daysInMonth; i++) { cells.push({ date: new Date(Date.UTC(year, month, i)), outside: false }); } const remaining = 42 - cells.length; for (let i = 1; i <= remaining; i++) { cells.push({ date: new Date(Date.UTC(year, month + 1, i)), outside: true }); } return cells; } getWeekDates() { const d = new Date(this.currentDate); const day = d.getUTCDay(); d.setUTCDate(d.getUTCDate() - day); const dates = []; for (let i = 0; i < 7; i++) { dates.push(new Date(d)); d.setUTCDate(d.getUTCDate() + 1); } return dates; } formatPeriodLabel() { const d = this.currentDate; const month = MONTH_NAMES[d.getUTCMonth()]; const year = d.getUTCFullYear(); switch (this.viewMode) { case 'day': return `${DAY_ABBREVS[d.getUTCDay()]}, ${month} ${d.getUTCDate()}, ${year}`; case 'week': { const weekDates = this.getWeekDates(); const start = weekDates[0]; const end = weekDates[6]; const startMonth = MONTH_NAMES[start.getUTCMonth()].slice(0, 3); const endMonth = MONTH_NAMES[end.getUTCMonth()].slice(0, 3); if (start.getUTCMonth() === end.getUTCMonth()) { return `${startMonth} ${start.getUTCDate()} – ${end.getUTCDate()}, ${year}`; } return `${startMonth} ${start.getUTCDate()} – ${endMonth} ${end.getUTCDate()}, ${year}`; } case 'month': return `${month} ${year}`; case 'year': return `${year}`; case 'list': { const { startUNIX, endUNIX } = this._getVisibleRange(); const s = new Date(startUNIX * 1000); const e = new Date(endUNIX * 1000); return `${MONTH_NAMES[s.getUTCMonth()].slice(0, 3)} ${s.getUTCDate()} – ${MONTH_NAMES[e.getUTCMonth()].slice(0, 3)} ${e.getUTCDate()}, ${e.getUTCFullYear()}`; } default: return ''; } } isToday(date) { const today = new Date(); return date.getUTCFullYear() === today.getUTCFullYear() && date.getUTCMonth() === today.getUTCMonth() && date.getUTCDate() === today.getUTCDate(); } // ============================================ // Firestore: One-time recurring template load // ============================================ async _loadRecurringTemplates() { try { const { collection, query, where, getDocs, onSnapshot } = await import('firebase/firestore'); const db = webManager.firebaseFirestore; const colRef = collection(db, 'marketing-campaigns'); const recurringDocs = await getDocs(query( colRef, where('__name__', '>=', '_recurring-'), where('__name__', '<=', '_recurring-\uf8ff'), )); recurringDocs.forEach((doc) => { const data = doc.data(); data.id = doc.id; this.campaigns.set(doc.id, data); }); this._unsubscribeRecurring = onSnapshot(query( colRef, where('__name__', '>=', '_recurring-'), where('__name__', '<=', '_recurring-\uf8ff'), ), (snapshot) => { for (const id of this.campaigns.keys()) { if (id.startsWith('_recurring-')) { this.campaigns.delete(id); } } snapshot.forEach((doc) => { const data = doc.data(); data.id = doc.id; this.campaigns.set(doc.id, data); }); this._virtualEvents = null; this.renderer.render(); }); this.renderer.render(); } catch (error) { console.error('Calendar: Failed to load recurring templates', error); } } // ============================================ // Firestore: Range-based subscription // ============================================ async _subscribeToRange() { if (this._unsubscribeRange) { this._unsubscribeRange(); this._unsubscribeRange = null; } const { startUNIX, endUNIX } = this._getVisibleRange(); try { const { collection, query, where, orderBy, onSnapshot } = await import('firebase/firestore'); const db = webManager.firebaseFirestore; const rangeQuery = query( collection(db, 'marketing-campaigns'), where('sendAt', '>=', startUNIX), where('sendAt', '<=', endUNIX), orderBy('sendAt', 'asc'), ); this._unsubscribeRange = onSnapshot(rangeQuery, (snapshot) => { for (const id of this.campaigns.keys()) { if (!id.startsWith('_recurring-')) { this.campaigns.delete(id); } } snapshot.forEach((doc) => { const data = doc.data(); data.id = doc.id; this.campaigns.set(doc.id, data); }); this._virtualEvents = null; this._dispatch('calendar:datachange'); this.renderer.render(); }, (error) => { console.error('Calendar: Firestore range subscription error', error); }); } catch (error) { console.error('Calendar: Failed to subscribe to range', error); } } /** * Calculate visible date range as unix timestamps (UTC). */ _getVisibleRange() { const d = this.currentDate; let start, end; switch (this.viewMode) { case 'day': start = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate())); end = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate() + 1)); break; case 'week': { const weekDates = this.getWeekDates(); start = weekDates[0]; end = new Date(weekDates[6]); end.setUTCDate(end.getUTCDate() + 1); break; } case 'month': { const firstDay = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1)).getUTCDay(); start = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1 - firstDay)); end = new Date(start); end.setUTCDate(end.getUTCDate() + 42); break; } case 'year': start = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)); end = new Date(Date.UTC(d.getUTCFullYear() + 1, 0, 1)); break; case 'list': start = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate() - 30)); end = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate() + 60)); break; } return { startUNIX: Math.floor(start.getTime() / 1000), endUNIX: Math.floor(end.getTime() / 1000), }; } // ============================================ // Event Dispatching // ============================================ _dispatch(eventName, detail) { this.$root.dispatchEvent(new CustomEvent(eventName, { detail })); } }