mirror of
https://github.com/Crazyco-xyz/48hr.email.git
synced 2026-01-08 18:59:36 +01:00
434 lines
No EOL
17 KiB
JavaScript
434 lines
No EOL
17 KiB
JavaScript
/**
|
|
* 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 = `
|
|
<button class="legend-btn active" data-index="0">
|
|
<span class="legend-indicator" style="background: linear-gradient(to right, rgba(100, 149, 237, 0.8) 0%, #2ecc71 100%);"></span>
|
|
<span class="legend-label">Email Activity</span>
|
|
</button>
|
|
<button class="legend-btn active" data-index="1">
|
|
<span class="legend-indicator" style="background: #ff9f43; border: 2px dashed rgba(255, 159, 67, 0.5);"></span>
|
|
<span class="legend-label">Predicted</span>
|
|
</button>
|
|
`;
|
|
|
|
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 += `<li class="stat-list-item"><span class="stat-list-label">${item.domain}</span><span class="stat-list-value">${item.count}</span></li>`;
|
|
});
|
|
topSenderDomains.innerHTML = html;
|
|
}
|
|
|
|
if (topRecipientDomains && data.enhanced.topRecipientDomains && data.enhanced.topRecipientDomains.length > 0) {
|
|
let html = '';
|
|
data.enhanced.topRecipientDomains.slice(0, 5).forEach(item => {
|
|
html += `<li class="stat-list-item"><span class="stat-list-label">${item.domain}</span><span class="stat-list-value">${item.count}</span></li>`;
|
|
});
|
|
topRecipientDomains.innerHTML = html;
|
|
}
|
|
|
|
if (busiestHours && data.enhanced.busiestHours && data.enhanced.busiestHours.length > 0) {
|
|
let html = '';
|
|
data.enhanced.busiestHours.forEach(item => {
|
|
html += `<li class="stat-list-item"><span class="stat-list-label">${item.hour}:00 - ${item.hour + 1}:00</span><span class="stat-list-value">${item.count}</span></li>`;
|
|
});
|
|
busiestHours.innerHTML = html;
|
|
}
|
|
|
|
// Update unique domains count
|
|
const uniqueSenderDomains = document.querySelector('[data-stats="unique-sender-domains"]');
|
|
if (uniqueSenderDomains && data.enhanced.uniqueSenderDomains !== undefined) {
|
|
uniqueSenderDomains.textContent = data.enhanced.uniqueSenderDomains;
|
|
}
|
|
|
|
const uniqueRecipientDomains = document.querySelector('[data-stats="unique-recipient-domains"]');
|
|
if (uniqueRecipientDomains && data.enhanced.uniqueRecipientDomains !== undefined) {
|
|
uniqueRecipientDomains.textContent = data.enhanced.uniqueRecipientDomains;
|
|
}
|
|
|
|
// Update Quick Insights values
|
|
const avgSubjectLength = document.querySelector('[data-stats="average-subject-length"]');
|
|
if (avgSubjectLength && data.enhanced.averageSubjectLength !== undefined) {
|
|
avgSubjectLength.textContent = data.enhanced.averageSubjectLength;
|
|
}
|
|
|
|
const uniqueSenderDomainsValue = document.querySelector('[data-stats="unique-sender-domains-value"]');
|
|
if (uniqueSenderDomainsValue && data.enhanced.uniqueSenderDomains !== undefined) {
|
|
uniqueSenderDomainsValue.textContent = data.enhanced.uniqueSenderDomains;
|
|
}
|
|
|
|
const uniqueRecipientDomainsValue = document.querySelector('[data-stats="unique-recipient-domains-value"]');
|
|
if (uniqueRecipientDomainsValue && data.enhanced.uniqueRecipientDomains !== undefined) {
|
|
uniqueRecipientDomainsValue.textContent = data.enhanced.uniqueRecipientDomains;
|
|
}
|
|
|
|
const peakHourPercentage = document.querySelector('[data-stats="peak-hour-percentage"]');
|
|
if (peakHourPercentage && data.enhanced.peakHourPercentage !== undefined) {
|
|
peakHourPercentage.textContent = data.enhanced.peakHourPercentage + '%';
|
|
}
|
|
|
|
const emailsPerHour = document.querySelector('[data-stats="emails-per-hour"]');
|
|
if (emailsPerHour && data.enhanced.emailsPerHour !== undefined) {
|
|
emailsPerHour.textContent = data.enhanced.emailsPerHour;
|
|
}
|
|
|
|
const dayPercentage = document.querySelector('[data-stats="day-percentage"]');
|
|
if (dayPercentage && data.enhanced.dayPercentage !== undefined) {
|
|
dayPercentage.textContent = data.enhanced.dayPercentage + '%';
|
|
}
|
|
}
|
|
|
|
// Update window data for charts
|
|
window.initialStatsData = (data.purgeWindow && data.purgeWindow.timeline) || [];
|
|
window.historicalData = data.historical || [];
|
|
window.predictionData = data.prediction || [];
|
|
|
|
// Rebuild chart with new data
|
|
rebuildStatsChart();
|
|
} |