UNPKG

ultimate-jekyll-manager

Version:
684 lines (577 loc) 26.4 kB
/** * Calendar Renderer * Renders the toolbar and calendar grid for all view modes. * All dates/times are UTC. No local time APIs. */ import { getPrerenderedIcon } from '__main_assets__/js/libs/prerendered-icons.js'; import webManager from 'web-manager'; import { VIEW_MODES, DAY_ABBREVS, MONTH_NAMES, TYPE_COLORS, formatDateUTC, formatTimeUTC, parseDateUTC } from './calendar-core.js'; export default class CalendarRenderer { constructor(core) { this.core = core; this.$toolbar = document.getElementById('calendar-toolbar'); this.$grid = document.getElementById('calendar-grid'); this._nowLineInterval = null; } // ============================================ // Main Render // ============================================ render() { this._renderToolbar(); this._renderGrid(); this._startNowLine(); } // ============================================ // Toolbar // ============================================ _renderToolbar() { const core = this.core; this.$toolbar.innerHTML = ` <div class="calendar-toolbar-nav"> <button type="button" class="btn btn-sm btn-outline-adaptive" data-calendar-nav="-1" title="Previous"> ${getPrerenderedIcon('chevron-left', 'fa-sm')} </button> <button type="button" class="btn btn-sm btn-outline-adaptive" data-calendar-nav="1" title="Next"> ${getPrerenderedIcon('chevron-right', 'fa-sm')} </button> <button type="button" class="btn btn-sm btn-outline-adaptive ms-1" data-calendar-today> Today </button> </div> <div class="calendar-toolbar-period"> ${core.formatPeriodLabel()} </div> <div class="calendar-toolbar-views btn-group" role="group" aria-label="Calendar view"> ${VIEW_MODES.map((mode) => ` <button type="button" class="btn btn-sm btn-outline-adaptive ${core.viewMode === mode ? 'active' : ''}" data-calendar-view="${mode}"> ${this._getViewIcon(mode)} ${mode.charAt(0).toUpperCase() + mode.slice(1)} </button> `).join('')} </div> `; this.$toolbar.querySelectorAll('[data-calendar-nav]').forEach(($btn) => { $btn.addEventListener('click', () => core.navigate(parseInt($btn.dataset.calendarNav, 10))); }); this.$toolbar.querySelector('[data-calendar-today]').addEventListener('click', () => core.goToToday()); this.$toolbar.querySelectorAll('[data-calendar-view]').forEach(($btn) => { $btn.addEventListener('click', () => core.setView($btn.dataset.calendarView)); }); } _getViewIcon(mode) { const icons = { day: 'calendar-day', week: 'calendar-week', month: 'calendar', year: 'grid-2', list: 'table-list' }; return getPrerenderedIcon(icons[mode], 'fa-sm'); } // ============================================ // Grid Delegation // ============================================ _renderGrid() { switch (this.core.viewMode) { case 'month': this._renderMonthView(); break; case 'week': this._renderWeekView(); break; case 'day': this._renderDayView(); break; case 'year': this._renderYearView(); break; case 'list': this._renderListView(); break; } } // ============================================ // Month View // ============================================ _renderMonthView() { const core = this.core; const cells = core.getMonthGrid(); let html = '<div class="calendar-day-header">'; DAY_ABBREVS.forEach((day) => { html += `<div>${day}</div>`; }); html += '</div><div class="calendar-month">'; cells.forEach((cell) => { const dateStr = formatDateUTC(cell.date); const isToday = core.isToday(cell.date); const campaigns = core.getCampaignsForDate(dateStr); let classes = 'calendar-cell'; if (isToday) { classes += ' calendar-cell--today'; } if (cell.outside) { classes += ' calendar-cell--outside'; } html += `<div class="${classes}" data-date="${dateStr}">`; html += `<div class="calendar-cell-date">${cell.date.getUTCDate()}</div>`; html += '<div class="calendar-cell-events">'; const maxVisible = 3; campaigns.slice(0, maxVisible).forEach((c) => { html += this._renderEventPill(c); }); if (campaigns.length > maxVisible) { html += `<div class="calendar-cell-more" data-date="${dateStr}">+${campaigns.length - maxVisible} more</div>`; } html += '</div></div>'; }); html += '</div>'; this.$grid.innerHTML = html; this._bindCellClicks(); this._bindCampaignClicks(); this._bindDragAndDrop(); } // ============================================ // Week View // ============================================ _renderWeekView() { const core = this.core; const weekDates = core.getWeekDates(); const hours = this._getHours(); let html = '<div class="calendar-week-header"><div></div>'; weekDates.forEach((date) => { const isToday = core.isToday(date); html += `<div class="calendar-week-header-cell ${isToday ? 'calendar-cell--today' : ''}">${DAY_ABBREVS[date.getUTCDay()]}<span class="calendar-week-header-date">${date.getUTCDate()}</span></div>`; }); html += '</div>'; html += '<div class="calendar-week-allday"><div class="calendar-week-time-label">all-day</div>'; weekDates.forEach((date) => { html += `<div class="calendar-cell" data-date="${formatDateUTC(date)}" data-allday="true" style="min-height:auto;border-bottom:none;"></div>`; }); html += '</div>'; html += '<div class="calendar-week-body"><div class="calendar-week-time-col">'; hours.forEach((hour) => { html += `<div class="calendar-week-time-label">${hour.label}</div>`; }); html += '</div>'; weekDates.forEach((date) => { const dateStr = formatDateUTC(date); const campaigns = core.getCampaignsForDate(dateStr); html += `<div class="calendar-week-day-col" data-date="${dateStr}">`; hours.forEach((hour) => { html += `<div class="calendar-week-time-slot" data-date="${dateStr}" data-hour="${hour.value}"></div>`; }); const layout = this._calculateOverlapLayout(campaigns); campaigns.forEach((c) => { html += this._renderTimeEvent(c, layout.get(c.id)); }); html += '</div>'; }); html += '</div>'; this.$grid.innerHTML = html; this._bindCellClicks(); this._bindCampaignClicks(); this._bindDragAndDrop(); } // ============================================ // Day View // ============================================ _renderDayView() { const core = this.core; const dateStr = formatDateUTC(core.currentDate); const campaigns = core.getCampaignsForDate(dateStr); const hours = this._getHours(); let html = '<div class="calendar-day-body"><div class="calendar-week-time-col">'; hours.forEach((hour) => { html += `<div class="calendar-week-time-label">${hour.label}</div>`; }); html += '</div>'; html += `<div class="calendar-day-col" data-date="${dateStr}">`; hours.forEach((hour) => { html += `<div class="calendar-week-time-slot" data-date="${dateStr}" data-hour="${hour.value}"></div>`; }); const layout = this._calculateOverlapLayout(campaigns); campaigns.forEach((c) => { html += this._renderTimeEvent(c, layout.get(c.id)); }); html += '</div></div>'; this.$grid.innerHTML = html; this._bindCellClicks(); this._bindCampaignClicks(); this._bindDragAndDrop(); } // ============================================ // Year View // ============================================ _renderYearView() { const core = this.core; const year = core.currentDate.getUTCFullYear(); let html = '<div class="calendar-year">'; for (let month = 0; month < 12; month++) { html += `<div class="calendar-mini-month" data-month="${month}">`; html += `<div class="calendar-mini-month-title">${MONTH_NAMES[month]}</div>`; html += '<div class="calendar-mini-month-grid">'; DAY_ABBREVS.forEach((day) => { html += `<div class="calendar-mini-day-header">${day.charAt(0)}</div>`; }); const firstDay = new Date(Date.UTC(year, month, 1)).getUTCDay(); const daysInMonth = core.getDaysInMonth(year, month); for (let i = 0; i < firstDay; i++) { html += '<div class="calendar-mini-day calendar-cell--outside"></div>'; } for (let d = 1; d <= daysInMonth; d++) { const date = new Date(Date.UTC(year, month, d)); const isToday = core.isToday(date); const hasCampaigns = core.getCampaignsForDate(formatDateUTC(date)).length > 0; let classes = 'calendar-mini-day'; if (isToday) { classes += ' calendar-cell--today'; } if (hasCampaigns) { classes += ' has-events'; } html += `<div class="${classes}">${d}</div>`; } html += '</div></div>'; } html += '</div>'; this.$grid.innerHTML = html; this.$grid.querySelectorAll('.calendar-mini-month').forEach(($mini) => { $mini.addEventListener('click', () => { core.currentDate.setUTCMonth(parseInt($mini.dataset.month, 10)); core.setView('month'); }); }); } // ============================================ // List View // ============================================ _renderListView() { const core = this.core; const campaigns = core.getAllCampaignsSorted(); if (campaigns.length === 0) { this.$grid.innerHTML = '<div class="calendar-list-empty"><div class="text-center"><p>No campaigns in this date range</p></div></div>'; return; } let html = '<div class="calendar-list"><table>'; let currentDateStr = null; campaigns.forEach((campaign) => { const dateStr = formatDateUTC(campaign.sendAt); if (dateStr !== currentDateStr) { currentDateStr = dateStr; const d = new Date(campaign.sendAt * 1000); const dayName = DAY_ABBREVS[d.getUTCDay()]; const monthName = MONTH_NAMES[d.getUTCMonth()].slice(0, 3); const isToday = core.isToday(d); const todayBadge = isToday ? ' <span class="badge bg-danger ms-2">Today</span>' : ''; html += `<tr class="calendar-list-date-header"><td colspan="4">${dayName}, ${monthName} ${d.getUTCDate()}, ${d.getUTCFullYear()}${todayBadge}</td></tr>`; } const timeStr = this._formatTime(formatTimeUTC(campaign.sendAt)); const name = (campaign.settings && campaign.settings.name) || 'Untitled'; const statusStyle = core.campaignStatusStyle(campaign); const isRecurring = core.isRecurring(campaign); const typeBadge = campaign.type === 'email' ? `<span class="badge" style="background-color: ${TYPE_COLORS.email}">${getPrerenderedIcon('envelope', 'fa-xs me-1')} Email</span>` : `<span class="badge" style="background-color: ${TYPE_COLORS.push}">${getPrerenderedIcon('bell', 'fa-xs me-1')} Push</span>`; let statusBadge = '<span class="badge bg-secondary">Pending</span>'; if (campaign.status === 'sent') { statusBadge = `<span class="badge bg-success">${getPrerenderedIcon('circle-check', 'fa-xs me-1')} Sent</span>`; } else if (campaign.status === 'failed') { statusBadge = `<span class="badge bg-danger">${getPrerenderedIcon('triangle-exclamation', 'fa-xs me-1')} Failed</span>`; } const recurringIcon = isRecurring ? getPrerenderedIcon('repeat', 'fa-xs me-1 text-muted') : ''; html += `<tr class="calendar-list-row" data-campaign-id="${campaign.id}" style="opacity: ${statusStyle.opacity}"> <td style="width: 60px">${timeStr}</td> <td>${recurringIcon}${webManager.utilities().escapeHTML(name)}</td> <td style="width: 80px">${typeBadge}</td> <td style="width: 80px">${statusBadge}</td> </tr>`; }); html += '</table></div>'; this.$grid.innerHTML = html; this.$grid.querySelectorAll('.calendar-list-row').forEach(($row) => { $row.addEventListener('click', (e) => { e.stopPropagation(); core.eventsManager.openEditModal($row.dataset.campaignId); }); }); } // ============================================ // Campaign Rendering Helpers // ============================================ _renderEventPill(campaign) { const core = this.core; const timeStr = this._formatTime(formatTimeUTC(campaign.sendAt)); const color = core.campaignColor(campaign); const statusStyle = core.campaignStatusStyle(campaign); const name = (campaign.settings && campaign.settings.name) || 'Untitled'; const isDraggable = core.isEditable(campaign) || campaign._virtual; const isRecurring = core.isRecurring(campaign); const statusIcon = statusStyle.icon ? getPrerenderedIcon(statusStyle.icon, 'fa-xs') : ''; const typeIcon = campaign.type === 'email' ? getPrerenderedIcon('envelope', 'fa-xs') : getPrerenderedIcon('bell', 'fa-xs'); const recurringIcon = isRecurring ? getPrerenderedIcon('repeat', 'fa-xs') : ''; let pillClass = 'calendar-event'; if (campaign.status === 'failed') { pillClass += ' calendar-event--failed'; } if (campaign.status === 'sent') { pillClass += ' calendar-event--sent'; } if (campaign._virtual) { pillClass += ' calendar-event--virtual'; } if (isRecurring && !campaign._virtual) { pillClass += ' calendar-event--recurring'; } return ` <div class="${pillClass}" data-campaign-id="${campaign.id}" data-send-at="${campaign.sendAt}" ${isDraggable ? 'draggable="true"' : ''} style="background-color: ${color}; opacity: ${statusStyle.opacity};" title="${webManager.utilities().escapeHTML(name)}${isRecurring ? ' (recurring)' : ''}"> ${statusIcon} ${recurringIcon} <span class="calendar-event-time">${timeStr}</span> <span class="calendar-event-type-icon">${typeIcon}</span> <span class="calendar-event-title">${webManager.utilities().escapeHTML(name)}</span> </div> `; } _renderTimeEvent(campaign, layout) { const core = this.core; const time = formatTimeUTC(campaign.sendAt); const [hours, minutes] = time.split(':').map(Number); const topPx = (hours * 60 + minutes); const heightPx = Math.max(core.campaignDuration(), 15); const timeStr = this._formatTime(time); const color = core.campaignColor(campaign); const statusStyle = core.campaignStatusStyle(campaign); const name = (campaign.settings && campaign.settings.name) || 'Untitled'; const isDraggable = core.isEditable(campaign) || campaign._virtual; const isRecurring = core.isRecurring(campaign); const statusIcon = statusStyle.icon ? getPrerenderedIcon(statusStyle.icon, 'fa-xs me-1') : ''; const recurringIcon = isRecurring ? getPrerenderedIcon('repeat', 'fa-xs me-1') : ''; const col = layout ? layout.col : 0; const totalCols = layout ? layout.totalCols : 1; const widthPct = 100 / totalCols; const leftPct = col * widthPct; const sizeStyle = totalCols > 1 ? `left: ${leftPct}%; width: calc(${widthPct}% - 2px);` : ''; let eventClass = 'calendar-week-event'; if (campaign.status === 'failed') { eventClass += ' calendar-event--failed'; } if (campaign.status === 'sent') { eventClass += ' calendar-event--sent'; } if (campaign._virtual) { eventClass += ' calendar-event--virtual'; } if (isRecurring && !campaign._virtual) { eventClass += ' calendar-event--recurring'; } return ` <div class="${eventClass}" data-campaign-id="${campaign.id}" data-send-at="${campaign.sendAt}" ${isDraggable ? 'draggable="true"' : ''} style="background-color: ${color}; opacity: ${statusStyle.opacity}; top: ${topPx}px; height: ${heightPx}px; ${sizeStyle}" title="${webManager.utilities().escapeHTML(name)}${isRecurring ? ' (recurring)' : ''}"> ${statusIcon}${recurringIcon}<strong>${timeStr}</strong> ${webManager.utilities().escapeHTML(name)} </div> `; } _calculateOverlapLayout(campaigns) { const layout = new Map(); if (campaigns.length === 0) { return layout; } const duration = this.core.campaignDuration(); const intervals = campaigns.map((c) => { const time = formatTimeUTC(c.sendAt); const [h, m] = time.split(':').map(Number); const start = h * 60 + m; return { id: c.id, start, end: start + duration }; }).sort((a, b) => a.start - b.start || a.end - b.end); const clusters = []; let currentCluster = [intervals[0]]; for (let i = 1; i < intervals.length; i++) { const clusterEnd = Math.max(...currentCluster.map((iv) => iv.end)); if (intervals[i].start < clusterEnd) { currentCluster.push(intervals[i]); } else { clusters.push(currentCluster); currentCluster = [intervals[i]]; } } clusters.push(currentCluster); clusters.forEach((cluster) => { const columns = []; cluster.forEach((interval) => { let placed = false; for (let c = 0; c < columns.length; c++) { if (interval.start >= columns[c].end) { columns[c] = interval; layout.set(interval.id, { col: c, totalCols: 0 }); placed = true; break; } } if (!placed) { layout.set(interval.id, { col: columns.length, totalCols: 0 }); columns.push(interval); } }); const totalCols = columns.length; cluster.forEach((interval) => { layout.get(interval.id).totalCols = totalCols; }); }); return layout; } // ============================================ // Now Line (UTC) // ============================================ _startNowLine() { clearInterval(this._nowLineInterval); if (this.core.viewMode !== 'day' && this.core.viewMode !== 'week') { return; } this._updateNowLine(); this._nowLineInterval = setInterval(() => this._updateNowLine(), 60000); } _updateNowLine() { this.$grid.querySelectorAll('.calendar-now-line').forEach(($el) => $el.remove()); const now = new Date(); const todayStr = formatDateUTC(now); const minutesSinceMidnight = now.getUTCHours() * 60 + now.getUTCMinutes(); const $cols = this.$grid.querySelectorAll( `.calendar-week-day-col[data-date="${todayStr}"], .calendar-day-col[data-date="${todayStr}"]` ); $cols.forEach(($col) => { const $line = document.createElement('div'); $line.className = 'calendar-now-line'; $line.style.top = `${minutesSinceMidnight}px`; $col.appendChild($line); }); } // ============================================ // Interaction Bindings // ============================================ _bindCellClicks() { const core = this.core; let clickTimer = null; this.$grid.querySelectorAll('.calendar-cell').forEach(($cell) => { $cell.addEventListener('click', (e) => { if (e.target.closest('[data-campaign-id]')) { return; } const date = $cell.dataset.date; if (!date || core.viewMode !== 'month') { return; } clearTimeout(clickTimer); clickTimer = setTimeout(() => { core.currentDate = parseDateUTC(date); core.setView('day'); }, 250); }); $cell.addEventListener('dblclick', (e) => { if (e.target.closest('[data-campaign-id]')) { return; } clearTimeout(clickTimer); const date = $cell.dataset.date; if (!date) { return; } core.eventsManager.openCreateModal(date, null); }); }); // Week/day view: single click → day view, double click → create event let slotClickTimer = null; this.$grid.querySelectorAll('.calendar-week-time-slot').forEach(($slot) => { $slot.addEventListener('click', (e) => { if (e.target.closest('[data-campaign-id]')) { return; } if (core.viewMode !== 'week') { return; } const date = $slot.dataset.date; if (!date) { return; } clearTimeout(slotClickTimer); slotClickTimer = setTimeout(() => { core.currentDate = parseDateUTC(date); core.setView('day'); }, 250); }); $slot.addEventListener('dblclick', (e) => { if (e.target.closest('[data-campaign-id]')) { return; } clearTimeout(slotClickTimer); const date = $slot.dataset.date; if (!date) { return; } const time = $slot.dataset.hour != null ? String($slot.dataset.hour).padStart(2, '0') + ':00' : null; core.eventsManager.openCreateModal(date, time); }); }); // Week header cells: click to go to day view this.$grid.querySelectorAll('.calendar-week-header-cell').forEach(($header) => { $header.style.cursor = 'pointer'; $header.addEventListener('click', () => { // Find the matching date from the first time slot in that column const index = Array.from($header.parentElement.children).indexOf($header) - 1; // -1 for time gutter const weekDates = core.getWeekDates(); if (index >= 0 && index < weekDates.length) { core.currentDate = weekDates[index]; core.setView('day'); } }); }); this.$grid.querySelectorAll('.calendar-cell-more').forEach(($more) => { $more.addEventListener('click', (e) => { e.stopPropagation(); core.currentDate = parseDateUTC($more.dataset.date); core.setView('day'); }); }); } _bindCampaignClicks() { this.$grid.querySelectorAll('[data-campaign-id]').forEach(($pill) => { $pill.addEventListener('click', (e) => { e.stopPropagation(); this.core.eventsManager.openEditModal($pill.dataset.campaignId); }); }); } _bindDragAndDrop() { const core = this.core; // Drag start this.$grid.querySelectorAll('[data-campaign-id][draggable]').forEach(($pill) => { $pill.addEventListener('dragstart', (e) => { e.dataTransfer.setData('text/plain', `${$pill.dataset.campaignId}|${$pill.dataset.sendAt}`); e.dataTransfer.effectAllowed = 'move'; $pill.classList.add('calendar-event--dragging'); requestAnimationFrame(() => { this.$grid.classList.add('calendar-grid--dragging'); }); }); $pill.addEventListener('dragend', () => { $pill.classList.remove('calendar-event--dragging'); this.$grid.classList.remove('calendar-grid--dragging'); this.$grid.querySelectorAll('.calendar-cell--drag-over').forEach(($el) => $el.classList.remove('calendar-cell--drag-over')); }); }); // Drop targets const $dropTargets = this.$grid.querySelectorAll('.calendar-cell, .calendar-week-time-slot'); $dropTargets.forEach(($cell) => { $cell.addEventListener('dragover', (e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; $cell.classList.add('calendar-cell--drag-over'); }); $cell.addEventListener('dragleave', () => $cell.classList.remove('calendar-cell--drag-over')); $cell.addEventListener('drop', (e) => { e.preventDefault(); $cell.classList.remove('calendar-cell--drag-over'); const dragData = e.dataTransfer.getData('text/plain'); const targetDateStr = $cell.dataset.date; if (!dragData || !targetDateStr) { return; } const [campaignId, dragSendAtStr] = dragData.split('|'); const dragSendAt = parseInt(dragSendAtStr, 10); if (!campaignId || !dragSendAt) { return; } const campaign = core.getCampaign(campaignId); if (!campaign) { return; } // Pure unix math: calculate day shift const dragDateStr = formatDateUTC(dragSendAt); const fromDayUTC = parseDateUTC(dragDateStr).getTime() / 1000; const toDayUTC = parseDateUTC(targetDateStr).getTime() / 1000; const dayShift = toDayUTC - fromDayUTC; // New sendAt = old + day shift (keeps same time-of-day) let newSendAtUNIX = dragSendAt + dayShift; // If dropped on a time slot, override the hour (UTC) if ($cell.dataset.hour != null) { const timeOfDay = dragSendAt - fromDayUTC; newSendAtUNIX = newSendAtUNIX - timeOfDay + (parseInt($cell.dataset.hour) * 3600); } // Virtual recurring: shift the template's seed if (campaign._virtual) { const templateId = campaign._recurringSourceId; const template = core.getCampaign(templateId); if (!template) { return; } let templateNewSendAt = template.sendAt + dayShift; if ($cell.dataset.hour != null) { const templateDayUTC = parseDateUTC(formatDateUTC(template.sendAt)).getTime() / 1000; const templateTimeOfDay = template.sendAt - templateDayUTC; templateNewSendAt = templateNewSendAt - templateTimeOfDay + (parseInt($cell.dataset.hour) * 3600); } core.eventsManager.rescheduleRecurring(templateId, campaign, templateNewSendAt) .catch((err) => console.error('Failed to reschedule recurring campaign:', err)); return; } // One-off: only editable campaigns if (!core.isEditable(campaign)) { return; } const rollback = core.optimisticUpdateSendAt(campaignId, newSendAtUNIX); const newSendAtISO = new Date(newSendAtUNIX * 1000).toISOString(); core.eventsManager.rescheduleCampaign(campaignId, newSendAtISO) .catch((err) => { console.error('Failed to reschedule campaign:', err); if (rollback) { rollback(); } }); }); }); } // ============================================ // Utility // ============================================ _getHours() { const hours = []; for (let h = 0; h < 24; h++) { const period = h >= 12 ? 'PM' : 'AM'; const display = h === 0 ? 12 : h > 12 ? h - 12 : h; hours.push({ value: h, label: `${display} ${period}` }); } return hours; } _formatTime(timeStr) { if (!timeStr) { return ''; } const [h, m] = timeStr.split(':').map(Number); const period = h >= 12 ? 'p' : 'a'; const display = h === 0 ? 12 : h > 12 ? h - 12 : h; return `${display}${m > 0 ? ':' + String(m).padStart(2, '0') : ''}${period}`; } }