/** * Statistics page functionality * Handles Chart.js initialization with historical, real-time, and predicted data */ // Store chart instance globally for updates let statsChart = null; let chartContext = null; let lastReloadTime = 0; const RELOAD_COOLDOWN_MS = 2000; // 2 second cooldown between reloads let allTimePoints = []; // Store globally so segment callbacks can access it // Initialize stats chart if on stats page document.addEventListener('DOMContentLoaded', function() { const chartCanvas = document.getElementById('statsChart'); if (!chartCanvas) return; // Not on stats page // Get data from global variables (set by template) if (typeof window.initialStatsData === 'undefined') { return; } const realtimeData = window.initialStatsData || []; const historicalData = window.historicalData || []; const predictionData = window.predictionData || []; // Set up Socket.IO connection for real-time updates with rate limiting if (typeof io !== 'undefined') { const socket = io(); socket.on('stats-update', () => { const now = Date.now(); if (now - lastReloadTime >= RELOAD_COOLDOWN_MS) { lastReloadTime = now; reloadStatsData(); } }); } // Combine all data and create labels const now = Date.now(); // Merge historical and realtime into a single continuous dataset // Historical will be blue, current will be green // Only show historical data that doesn't overlap with realtime (exclude any matching timestamps) const realtimeTimestamps = new Set(realtimeData.map(d => d.timestamp)); const filteredHistorical = historicalData.filter(d => !realtimeTimestamps.has(d.timestamp)); allTimePoints = [ ...filteredHistorical.map(d => ({...d, type: 'historical' })), ...realtimeData.map(d => ({...d, type: 'realtime' })), ...predictionData.map(d => ({...d, type: 'prediction' })) ].sort((a, b) => a.timestamp - b.timestamp); // Create labels const labels = allTimePoints.map(d => { const date = new Date(d.timestamp); return date.toLocaleString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); }); // Merge historical and realtime into one dataset with segment coloring const combinedPoints = allTimePoints.map(d => (d.type === 'historical' || d.type === 'realtime') ? d.receives : null ); const predictionPoints = allTimePoints.map(d => d.type === 'prediction' ? d.receives : null); // Create gradient for fading effect on historical data const ctx = chartCanvas.getContext('2d'); chartContext = ctx; // Track visibility state for each dataset const datasetVisibility = [true, true]; statsChart = new Chart(ctx, { type: 'line', data: { labels: labels, datasets: [{ label: 'Email Activity', data: combinedPoints, segment: { borderColor: (context) => { const index = context.p0DataIndex; const point = allTimePoints[index]; // Blue for historical, green for current return point && point.type === 'historical' ? 'rgba(100, 149, 237, 0.8)' : '#2ecc71'; }, backgroundColor: (context) => { const index = context.p0DataIndex; const point = allTimePoints[index]; return point && point.type === 'historical' ? 'rgba(100, 149, 237, 0.15)' : 'rgba(46, 204, 113, 0.15)'; } }, borderColor: '#2ecc71', // Default to green backgroundColor: 'rgba(46, 204, 113, 0.15)', borderWidth: 3, tension: 0.4, pointRadius: (context) => { const index = context.dataIndex; const point = allTimePoints[index]; return point && point.type === 'historical' ? 3 : 4; }, pointBackgroundColor: (context) => { const index = context.dataIndex; const point = allTimePoints[index]; return point && point.type === 'historical' ? 'rgba(100, 149, 237, 0.8)' : '#2ecc71'; }, spanGaps: true, fill: true, hidden: false }, { label: 'Predicted', data: predictionPoints, borderColor: '#ff9f43', backgroundColor: 'rgba(255, 159, 67, 0.08)', borderWidth: 3, borderDash: [8, 4], tension: 0.4, pointRadius: 4, pointBackgroundColor: '#ff9f43', spanGaps: true, fill: true, hidden: false } ] }, options: { responsive: true, maintainAspectRatio: false, interaction: { mode: 'index', intersect: false }, plugins: { legend: { display: false // Disable default legend, we'll create custom }, tooltip: { mode: 'index', intersect: false, callbacks: { title: function(context) { if (!context || !context[0] || context[0].dataIndex === undefined) return ''; const dataIndex = context[0].dataIndex; if (!allTimePoints[dataIndex]) return ''; const point = allTimePoints[dataIndex]; const date = new Date(point.timestamp); return date.toLocaleString('en-US', { dateStyle: 'medium', timeStyle: 'short' }); }, label: function(context) { let label = context.dataset.label || ''; if (label) { label += ': '; } if (context.parsed.y !== null) { label += context.parsed.y + ' emails'; } return label; } } } }, scales: { y: { beginAtZero: true, ticks: { color: getComputedStyle(document.documentElement).getPropertyValue('--color-text-dim'), stepSize: 1, callback: function(value) { return Math.round(value); } }, grid: { color: 'rgba(255, 255, 255, 0.1)' }, title: { display: true, text: 'Emails Received', color: getComputedStyle(document.documentElement).getPropertyValue('--color-text-light') } }, x: { ticks: { color: getComputedStyle(document.documentElement).getPropertyValue('--color-text-dim'), maxRotation: 45, minRotation: 45, maxTicksLimit: 20 }, grid: { color: 'rgba(255, 255, 255, 0.05)' } } } } }); // Create custom legend buttons const chartContainer = chartCanvas.parentElement; const legendContainer = document.createElement('div'); legendContainer.className = 'chart-legend-custom'; legendContainer.innerHTML = ` `; chartContainer.insertBefore(legendContainer, chartCanvas); // Handle legend button clicks legendContainer.querySelectorAll('.legend-btn').forEach(btn => { btn.addEventListener('click', function() { const index = parseInt(this.getAttribute('data-index')); const isActive = this.classList.contains('active'); // Toggle button state this.classList.toggle('active'); // Toggle dataset visibility with fade effect const meta = statsChart.getDatasetMeta(index); const dataset = statsChart.data.datasets[index]; if (isActive) { // Fade out meta.hidden = true; datasetVisibility[index] = false; } else { // Fade in meta.hidden = false; datasetVisibility[index] = true; } statsChart.update('active'); }); }); // Lazy load full stats data if placeholder detected lazyLoadStats(); }); /** * Rebuild chart with new data */ function rebuildStatsChart() { if (!statsChart || !chartContext) { return; } const realtimeData = window.initialStatsData || []; const historicalData = window.historicalData || []; const predictionData = window.predictionData || []; // Only show historical data that doesn't overlap with realtime (exclude any matching timestamps) const realtimeTimestamps = new Set(realtimeData.map(d => d.timestamp)); const filteredHistorical = historicalData.filter(d => !realtimeTimestamps.has(d.timestamp)); allTimePoints = [ ...filteredHistorical.map(d => ({...d, type: 'historical' })), ...realtimeData.map(d => ({...d, type: 'realtime' })), ...predictionData.map(d => ({...d, type: 'prediction' })) ].sort((a, b) => a.timestamp - b.timestamp); if (allTimePoints.length === 0) { return; } // Create labels const labels = allTimePoints.map(d => { const date = new Date(d.timestamp); return date.toLocaleString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); }); // Merge historical and realtime into one dataset with segment coloring const combinedPoints = allTimePoints.map(d => (d.type === 'historical' || d.type === 'realtime') ? d.receives : null ); const predictionPoints = allTimePoints.map(d => d.type === 'prediction' ? d.receives : null); // Update chart data statsChart.data.labels = labels; statsChart.data.datasets[0].data = combinedPoints; statsChart.data.datasets[1].data = predictionPoints; // Update the chart statsChart.update(); } /** * Lazy load full statistics data and update DOM */ function lazyLoadStats() { // Check if this is a lazy-loaded page (has placeholder data) const currentCountEl = document.getElementById('currentCount'); if (!currentCountEl) { return; } const currentText = currentCountEl.textContent.trim(); if (currentText !== '...') { return; // Already loaded with real data } reloadStatsData(); } /** * Reload statistics data from API and update DOM */ function reloadStatsData() { fetch('/stats/api') .then(response => response.json()) .then(data => { updateStatsDOM(data); }) .catch(error => { console.error('Error reloading stats:', error); }); } /** * Update DOM with stats data */ function updateStatsDOM(data) { // Update main stat cards const elCurrent = document.getElementById('currentCount'); if (elCurrent) elCurrent.textContent = data.currentCount || '0'; const elTotal = document.getElementById('historicalTotal'); if (elTotal) elTotal.textContent = data.allTimeTotal || '0'; const elReceives = document.getElementById('receivesPurgeWindow'); if (elReceives) elReceives.textContent = (data.purgeWindow && data.purgeWindow.receives) || '0'; const elDeletes = document.getElementById('deletesPurgeWindow'); if (elDeletes) elDeletes.textContent = (data.purgeWindow && data.purgeWindow.deletes) || '0'; const elForwards = document.getElementById('forwardsPurgeWindow'); if (elForwards) elForwards.textContent = (data.purgeWindow && data.purgeWindow.forwards) || '0'; // Update enhanced stats if available if (data.enhanced) { const topSenderDomains = document.querySelector('[data-stats="top-sender-domains"]'); const topRecipientDomains = document.querySelector('[data-stats="top-recipient-domains"]'); const busiestHours = document.querySelector('[data-stats="busiest-hours"]'); if (topSenderDomains && data.enhanced.topSenderDomains && data.enhanced.topSenderDomains.length > 0) { let html = ''; data.enhanced.topSenderDomains.slice(0, 5).forEach(item => { html += `