/** * Analytics Dashboard * Privacy-respecting, self-hosted analytics */ let currentPeriod = '24h'; let trendChart = null; let refreshInterval = null; // Initialize dashboard document.addEventListener('DOMContentLoaded', () => { initializePeriodSelector(); initializeRefreshButton(); loadDashboard(); startAutoRefresh(); }); /** * Initialize period selector */ function initializePeriodSelector() { const selector = document.getElementById('periodSelector'); const buttons = selector.querySelectorAll('button'); buttons.forEach(button => { button.addEventListener('click', () => { // Update active state buttons.forEach(b => b.classList.remove('active')); button.classList.add('active'); // Update period and reload currentPeriod = button.dataset.period; loadDashboard(); }); }); } /** * Initialize refresh button */ function initializeRefreshButton() { const refreshBtn = document.getElementById('refreshBtn'); const refreshIcon = document.getElementById('refreshIcon'); refreshBtn.addEventListener('click', () => { refreshIcon.style.display = 'inline-block'; refreshIcon.classList.add('loading'); loadDashboard(); }); } /** * Start auto-refresh (every 30 seconds) */ function startAutoRefresh() { refreshInterval = setInterval(() => { loadDashboard(true); // silent refresh }, 30000); } /** * Load complete dashboard */ async function loadDashboard(silent = false) { try { // Load data in parallel await Promise.all([ loadOverview(), loadHourlyTrend(), loadLiveVisitors() ]); // Update last updated timestamp updateLastUpdated(); // Remove loading state from refresh button if (!silent) { const refreshIcon = document.getElementById('refreshIcon'); refreshIcon.classList.remove('loading'); } } catch (error) { console.error('Dashboard load error:', error); if (!silent) { showError('Failed to load analytics data'); } } } /** * Load overview statistics */ async function loadOverview() { const response = await fetch(`/api/analytics/overview?period=${currentPeriod}`, { credentials: 'include' }); if (!response.ok) { throw new Error('Failed to fetch overview'); } const result = await response.json(); const data = result.data; // Update metrics document.getElementById('totalPageViews').textContent = formatNumber(data.totalPageViews); document.getElementById('uniqueVisitors').textContent = formatNumber(data.uniqueVisitors); document.getElementById('bounceRate').textContent = `${data.bounceRate}%`; document.getElementById('avgDuration').textContent = formatDuration(data.avgDuration); // Update top pages updateTopPages(data.topPages); // Update top referrers updateTopReferrers(data.topReferrers); } /** * Load hourly trend data */ async function loadHourlyTrend() { // Determine hours based on period let hours = 24; if (currentPeriod === '1h') hours = 1; else if (currentPeriod === '7d') hours = 168; else if (currentPeriod === '30d') hours = 720; const response = await fetch(`/api/analytics/trend?hours=${hours}`, { credentials: 'include' }); if (!response.ok) { throw new Error('Failed to fetch trend data'); } const result = await response.json(); updateTrendChart(result.data); } /** * Load live visitors */ async function loadLiveVisitors() { const response = await fetch('/api/analytics/live', { credentials: 'include' }); if (!response.ok) { throw new Error('Failed to fetch live visitors'); } const result = await response.json(); const data = result.data; // Update live visitor count document.getElementById('liveVisitors').textContent = data.count; // Update recent activity updateRecentActivity(data.recentPages); } /** * Update top pages table */ function updateTopPages(pages) { const tbody = document.getElementById('topPagesTable'); if (!pages || pages.length === 0) { tbody.innerHTML = 'No data available'; return; } tbody.innerHTML = pages.map(page => ` ${escapeHtml(page.path)} ${formatNumber(page.views)} ${formatNumber(page.uniqueVisitors)} `).join(''); } /** * Update top referrers table */ function updateTopReferrers(referrers) { const tbody = document.getElementById('topReferrersTable'); if (!referrers || referrers.length === 0) { tbody.innerHTML = 'No external referrers'; return; } tbody.innerHTML = referrers.map(ref => ` ${escapeHtml(ref.referrer)} ${formatNumber(ref.visits)} `).join(''); } /** * Update recent activity table */ function updateRecentActivity(pages) { const tbody = document.getElementById('recentActivityTable'); if (!pages || pages.length === 0) { tbody.innerHTML = 'No recent activity'; return; } tbody.innerHTML = pages.map(page => ` ${escapeHtml(page.path)} ${formatTimeAgo(page.timestamp)} `).join(''); } /** * Update trend chart */ function updateTrendChart(data) { const canvas = document.getElementById('trendChart'); const ctx = canvas.getContext('2d'); // Prepare data const labels = data.map(d => { const date = new Date(d.hour.year, d.hour.month - 1, d.hour.day, d.hour.hour); if (currentPeriod === '1h') { return date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }); } else if (currentPeriod === '24h') { return date.toLocaleTimeString('en-US', { hour: '2-digit' }); } else { return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: '2-digit' }); } }); const pageViews = data.map(d => d.views); const uniqueVisitors = data.map(d => d.uniqueVisitors); // Destroy previous chart if (trendChart) { trendChart.destroy(); } // Create new chart trendChart = new Chart(ctx, { type: 'line', data: { labels, datasets: [ { label: 'Page Views', data: pageViews, borderColor: '#3b82f6', backgroundColor: 'rgba(59, 130, 246, 0.1)', fill: true, tension: 0.4 }, { label: 'Unique Visitors', data: uniqueVisitors, borderColor: '#8b5cf6', backgroundColor: 'rgba(139, 92, 246, 0.1)', fill: true, tension: 0.4 } ] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { position: 'top', align: 'end' }, tooltip: { mode: 'index', intersect: false } }, scales: { y: { beginAtZero: true, ticks: { precision: 0 } } } } }); } /** * Update last updated timestamp */ function updateLastUpdated() { const now = new Date(); const timeString = now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', second: '2-digit' }); document.getElementById('lastUpdated').textContent = `Last updated: ${timeString}`; } /** * Format number with commas */ function formatNumber(num) { if (num === undefined || num === null) return '-'; return num.toLocaleString('en-US'); } /** * Format duration in seconds */ function formatDuration(seconds) { if (!seconds || seconds === 0) return '-'; const minutes = Math.floor(seconds / 60); const secs = seconds % 60; if (minutes === 0) { return `${secs}s`; } return `${minutes}m ${secs}s`; } /** * Format time ago */ function formatTimeAgo(timestamp) { const now = new Date(); const then = new Date(timestamp); const seconds = Math.floor((now - then) / 1000); if (seconds < 60) return `${seconds}s ago`; if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`; if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`; return then.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); } /** * Escape HTML */ function escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } /** * Show error message */ function showError(message) { console.error(message); // Could add toast notification here }