- 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>
361 lines
8.5 KiB
JavaScript
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
|
|
}
|