tractatus/public/js/admin/analytics.js
TheFlow 2298d36bed fix(submissions): restructure Economist package and fix article display
- Create Economist SubmissionTracking package correctly:
  * mainArticle = full blog post content
  * coverLetter = 216-word SIR— letter
  * Links to blog post via blogPostId
- Archive 'Letter to The Economist' from blog posts (it's the cover letter)
- Fix date display on article cards (use published_at)
- Target publication already displaying via blue badge

Database changes:
- Make blogPostId optional in SubmissionTracking model
- Economist package ID: 68fa85ae49d4900e7f2ecd83
- Le Monde package ID: 68fa2abd2e6acd5691932150

Next: Enhanced modal with tabs, validation, export

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-24 08:47:42 +13:00

361 lines
8.5 KiB
JavaScript

/**
* 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 = '<tr><td colspan="3" class="text-center text-gray-500">No data available</td></tr>';
return;
}
tbody.innerHTML = pages.map(page => `
<tr>
<td class="font-medium">${escapeHtml(page.path)}</td>
<td>${formatNumber(page.views)}</td>
<td>${formatNumber(page.uniqueVisitors)}</td>
</tr>
`).join('');
}
/**
* Update top referrers table
*/
function updateTopReferrers(referrers) {
const tbody = document.getElementById('topReferrersTable');
if (!referrers || referrers.length === 0) {
tbody.innerHTML = '<tr><td colspan="2" class="text-center text-gray-500">No external referrers</td></tr>';
return;
}
tbody.innerHTML = referrers.map(ref => `
<tr>
<td class="font-medium">${escapeHtml(ref.referrer)}</td>
<td>${formatNumber(ref.visits)}</td>
</tr>
`).join('');
}
/**
* Update recent activity table
*/
function updateRecentActivity(pages) {
const tbody = document.getElementById('recentActivityTable');
if (!pages || pages.length === 0) {
tbody.innerHTML = '<tr><td colspan="2" class="text-center text-gray-500">No recent activity</td></tr>';
return;
}
tbody.innerHTML = pages.map(page => `
<tr>
<td class="font-medium">${escapeHtml(page.path)}</td>
<td class="text-gray-500">${formatTimeAgo(page.timestamp)}</td>
</tr>
`).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
}