first commit
This commit is contained in:
@@ -0,0 +1,469 @@
|
||||
// Analytics dashboard script
|
||||
(function() {
|
||||
// Helper function to format date as YYYY-MM-DD in local timezone
|
||||
function formatLocalDate(date) {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
// Set today's date as default (using local timezone, not UTC)
|
||||
const dateSelect = document.getElementById('dateSelect');
|
||||
const endDateSelect = document.getElementById('endDateSelect');
|
||||
const goButton = document.getElementById('goButton');
|
||||
const customCalendar = document.getElementById('customCalendar');
|
||||
const customCalendarEnd = document.getElementById('customCalendarEnd');
|
||||
const today = new Date();
|
||||
const todayStr = formatLocalDate(today); // Format: YYYY-MM-DD in local timezone
|
||||
let currentDate = new Date(today);
|
||||
let selectedDate = new Date(today);
|
||||
let selectedEndDate = new Date(today);
|
||||
|
||||
if (dateSelect) {
|
||||
// Set the value directly as a string to avoid timezone conversion issues
|
||||
dateSelect.value = todayStr;
|
||||
// Also set valueAsDate to ensure the input displays correctly
|
||||
const localDate = new Date(today.getFullYear(), today.getMonth(), today.getDate());
|
||||
dateSelect.valueAsDate = localDate;
|
||||
selectedDate = new Date(localDate);
|
||||
}
|
||||
|
||||
if (endDateSelect) {
|
||||
endDateSelect.value = todayStr;
|
||||
const localDate = new Date(today.getFullYear(), today.getMonth(), today.getDate());
|
||||
endDateSelect.valueAsDate = localDate;
|
||||
selectedEndDate = new Date(localDate);
|
||||
}
|
||||
|
||||
// Handle Go button click
|
||||
if (goButton) {
|
||||
goButton.addEventListener('click', function() {
|
||||
const startDate = dateSelect.value;
|
||||
const endDate = endDateSelect.value;
|
||||
|
||||
// Validate dates
|
||||
if (!startDate || !endDate) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure end date is not before start date
|
||||
if (new Date(endDate) < new Date(startDate)) {
|
||||
endDateSelect.value = startDate;
|
||||
selectedEndDate = new Date(startDate);
|
||||
}
|
||||
|
||||
// Load analytics with date range
|
||||
loadAnalytics(startDate, endDate);
|
||||
});
|
||||
}
|
||||
|
||||
// Handle end date changes - validate but don't auto-refresh
|
||||
if (endDateSelect) {
|
||||
endDateSelect.addEventListener('change', function(e) {
|
||||
const newDate = e.target.value;
|
||||
if (newDate) {
|
||||
selectedEndDate = new Date(newDate);
|
||||
// Ensure end date is not before start date
|
||||
if (selectedEndDate < selectedDate) {
|
||||
endDateSelect.value = dateSelect.value;
|
||||
selectedEndDate = new Date(selectedDate);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Custom calendar implementation
|
||||
function renderCalendar(date, preserveShowState = false, isEndDate = false) {
|
||||
const targetCalendar = isEndDate ? customCalendarEnd : customCalendar;
|
||||
const targetDateSelect = isEndDate ? endDateSelect : dateSelect;
|
||||
const targetSelectedDate = isEndDate ? selectedEndDate : selectedDate;
|
||||
const year = date.getFullYear();
|
||||
const month = date.getMonth();
|
||||
const firstDay = new Date(year, month, 1);
|
||||
const lastDay = new Date(year, month + 1, 0);
|
||||
const startDate = new Date(firstDay);
|
||||
startDate.setDate(startDate.getDate() - startDate.getDay());
|
||||
|
||||
const monthNames = ['January', 'February', 'March', 'April', 'May', 'June',
|
||||
'July', 'August', 'September', 'October', 'November', 'December'];
|
||||
const weekdays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
|
||||
// Preserve show state if calendar was visible
|
||||
const wasVisible = preserveShowState && targetCalendar.classList.contains('show');
|
||||
|
||||
let html = `
|
||||
<div class="calendar-header">
|
||||
<button class="calendar-nav-btn" data-action="prev">‹</button>
|
||||
<div class="calendar-month-year">${monthNames[month]} ${year}</div>
|
||||
<button class="calendar-nav-btn" data-action="next">›</button>
|
||||
</div>
|
||||
<div class="calendar-weekdays">
|
||||
${weekdays.map(day => `<div class="calendar-weekday">${day}</div>`).join('')}
|
||||
</div>
|
||||
<div class="calendar-days">
|
||||
`;
|
||||
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const selected = new Date(targetSelectedDate);
|
||||
selected.setHours(0, 0, 0, 0);
|
||||
|
||||
for (let i = 0; i < 42; i++) {
|
||||
const cellDate = new Date(startDate);
|
||||
cellDate.setDate(startDate.getDate() + i);
|
||||
const cellDateOnly = new Date(cellDate);
|
||||
cellDateOnly.setHours(0, 0, 0, 0);
|
||||
|
||||
const isOtherMonth = cellDate.getMonth() !== month;
|
||||
const isToday = cellDateOnly.getTime() === today.getTime();
|
||||
const isSelected = cellDateOnly.getTime() === selected.getTime();
|
||||
|
||||
let classes = 'calendar-day';
|
||||
if (isOtherMonth) classes += ' other-month';
|
||||
if (isToday) classes += ' today';
|
||||
if (isSelected) classes += ' selected';
|
||||
|
||||
html += `<div class="${classes}" data-date="${formatLocalDate(cellDate)}">${cellDate.getDate()}</div>`;
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
targetCalendar.innerHTML = html;
|
||||
|
||||
// Restore show state if it was visible
|
||||
if (wasVisible) {
|
||||
targetCalendar.classList.add('show');
|
||||
}
|
||||
|
||||
// Prevent calendar header clicks from closing the calendar
|
||||
targetCalendar.querySelectorAll('.calendar-header, .calendar-month-year').forEach(el => {
|
||||
el.addEventListener('click', function(e) {
|
||||
e.stopPropagation();
|
||||
});
|
||||
});
|
||||
|
||||
// Add click handlers for navigation buttons
|
||||
targetCalendar.querySelectorAll('.calendar-nav-btn[data-action="prev"]').forEach(btn => {
|
||||
btn.addEventListener('click', function(e) {
|
||||
e.stopPropagation(); // Prevent click from bubbling up
|
||||
currentDate.setMonth(currentDate.getMonth() - 1);
|
||||
renderCalendar(currentDate, true, isEndDate); // Preserve show state
|
||||
});
|
||||
});
|
||||
|
||||
targetCalendar.querySelectorAll('.calendar-nav-btn[data-action="next"]').forEach(btn => {
|
||||
btn.addEventListener('click', function(e) {
|
||||
e.stopPropagation(); // Prevent click from bubbling up
|
||||
currentDate.setMonth(currentDate.getMonth() + 1);
|
||||
renderCalendar(currentDate, true, isEndDate); // Preserve show state
|
||||
});
|
||||
});
|
||||
|
||||
// Add click handlers for calendar days
|
||||
targetCalendar.querySelectorAll('.calendar-day').forEach(day => {
|
||||
day.addEventListener('click', function(e) {
|
||||
e.stopPropagation(); // Prevent document click handler from firing
|
||||
const dateStr = this.getAttribute('data-date');
|
||||
if (dateStr) {
|
||||
if (isEndDate) {
|
||||
selectedEndDate = new Date(dateStr);
|
||||
endDateSelect.value = dateStr;
|
||||
endDateSelect.valueAsDate = selectedEndDate;
|
||||
targetCalendar.classList.remove('show');
|
||||
// Ensure end date is not before start date
|
||||
if (selectedEndDate < selectedDate) {
|
||||
endDateSelect.value = dateSelect.value;
|
||||
selectedEndDate = new Date(selectedDate);
|
||||
}
|
||||
} else {
|
||||
selectedDate = new Date(dateStr);
|
||||
dateSelect.value = dateStr;
|
||||
dateSelect.valueAsDate = selectedDate;
|
||||
targetCalendar.classList.remove('show');
|
||||
// Ensure start date is not after end date
|
||||
if (selectedDate > selectedEndDate) {
|
||||
endDateSelect.value = dateStr;
|
||||
selectedEndDate = new Date(selectedDate);
|
||||
}
|
||||
}
|
||||
// Don't auto-refresh - user must click Go button
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Toggle calendar on input click - Start Date
|
||||
if (dateSelect && customCalendar) {
|
||||
dateSelect.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
currentDate = selectedDate;
|
||||
renderCalendar(currentDate, false, false);
|
||||
customCalendar.classList.toggle('show');
|
||||
});
|
||||
|
||||
// Prevent focus from causing layout shifts
|
||||
dateSelect.addEventListener('focus', function(e) {
|
||||
e.preventDefault();
|
||||
this.blur();
|
||||
});
|
||||
|
||||
// Initialize calendar
|
||||
renderCalendar(currentDate, false, false);
|
||||
}
|
||||
|
||||
// Toggle calendar on input click - End Date
|
||||
if (endDateSelect && customCalendarEnd) {
|
||||
endDateSelect.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
currentDate = selectedEndDate;
|
||||
renderCalendar(currentDate, false, true);
|
||||
customCalendarEnd.classList.toggle('show');
|
||||
});
|
||||
|
||||
// Prevent focus from causing layout shifts
|
||||
endDateSelect.addEventListener('focus', function(e) {
|
||||
e.preventDefault();
|
||||
this.blur();
|
||||
});
|
||||
}
|
||||
|
||||
// Close calendars when clicking outside
|
||||
document.addEventListener('click', function(e) {
|
||||
if (dateSelect && !dateSelect.contains(e.target) && customCalendar && !customCalendar.contains(e.target)) {
|
||||
customCalendar.classList.remove('show');
|
||||
}
|
||||
if (endDateSelect && !endDateSelect.contains(e.target) && customCalendarEnd && !customCalendarEnd.contains(e.target)) {
|
||||
customCalendarEnd.classList.remove('show');
|
||||
}
|
||||
});
|
||||
|
||||
async function loadAnalytics(date, endDate = null) {
|
||||
const dateStr = date || formatLocalDate(new Date());
|
||||
// Use API endpoint to fetch data securely
|
||||
let filename = `/api/analytics.php?date=${dateStr}`;
|
||||
if (endDate) {
|
||||
filename += `&endDate=${endDate}`;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(filename);
|
||||
if (!response.ok) {
|
||||
document.getElementById('statsGrid').innerHTML = '<p class="error">Access denied or no data available.</p>';
|
||||
document.getElementById('hourChart').innerHTML = '';
|
||||
document.getElementById('blogPostsStats').innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
if (!result.success) {
|
||||
document.getElementById('statsGrid').innerHTML = '<p class="error">Error loading data.</p>';
|
||||
document.getElementById('hourChart').innerHTML = '';
|
||||
document.getElementById('blogPostsStats').innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const data = result.data;
|
||||
|
||||
// Update stats
|
||||
const shares = data.shares || {mastodon: 0, bluesky: 0, copy: 0};
|
||||
const totalShares = shares.mastodon + shares.bluesky + shares.copy;
|
||||
const rssSubscriptions = data.activeRssSubscribers || 0;
|
||||
|
||||
document.getElementById('statsGrid').innerHTML = `
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">${data.total || 0}</div>
|
||||
<div class="stat-label">Total Visits</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">${data.new || 0}</div>
|
||||
<div class="stat-label">New Visitors</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">${data.returning || 0}</div>
|
||||
<div class="stat-label">Returning Visitors</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">${totalShares}</div>
|
||||
<div class="stat-label">Total Shares</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">${shares.mastodon || 0}</div>
|
||||
<div class="stat-label">Mastodon Shares</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">${shares.bluesky || 0}</div>
|
||||
<div class="stat-label">Bluesky Shares</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">${shares.copy || 0}</div>
|
||||
<div class="stat-label">Link Copies</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">${rssSubscriptions}</div>
|
||||
<div class="stat-label">RSS Subscribers</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Update hour chart - vertical histogram
|
||||
const byHour = data.byHour || Array(24).fill(0);
|
||||
const maxVisits = Math.max(...byHour, 1);
|
||||
const hourChart = document.getElementById('hourChart');
|
||||
|
||||
// Use fixed height for consistent scaling (300px container - 40px for labels/padding = 260px)
|
||||
const chartHeight = 260;
|
||||
|
||||
hourChart.innerHTML = byHour.map((visits, hour) => {
|
||||
const percentage = maxVisits > 0 ? (visits / maxVisits) * 100 : 0;
|
||||
const hourLabel = hour.toString().padStart(2, '0');
|
||||
// Calculate actual pixel height based on percentage of available height
|
||||
const barHeightPx = visits > 0 ? Math.max((percentage / 100) * chartHeight, 8) : 0;
|
||||
return `
|
||||
<div class="hour-bar">
|
||||
<div class="hour-visual" data-height="${barHeightPx}" title="${hourLabel}:00 - ${visits} visits">
|
||||
${visits > 0 ? visits : ''}
|
||||
</div>
|
||||
<div class="hour-label">${hourLabel}</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
// Set heights using CSS custom properties (to avoid CSP blocking inline styles)
|
||||
hourChart.querySelectorAll('.hour-visual').forEach(bar => {
|
||||
const height = bar.getAttribute('data-height');
|
||||
if (height) {
|
||||
bar.style.setProperty('--bar-height', height + 'px');
|
||||
}
|
||||
});
|
||||
|
||||
// Update recent visitors panel
|
||||
const recentVisitors = data.recentVisitors || [];
|
||||
const recentVisitorsContainer = document.getElementById('recentVisitors');
|
||||
|
||||
// Helper function to format UTC time to visitor's local time
|
||||
function formatVisitorTime(timeStr) {
|
||||
if (!timeStr) return 'N/A';
|
||||
try {
|
||||
const date = new Date(timeStr);
|
||||
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false });
|
||||
} catch (e) {
|
||||
return timeStr;
|
||||
}
|
||||
}
|
||||
|
||||
if (recentVisitors.length > 0) {
|
||||
recentVisitorsContainer.innerHTML = `
|
||||
<table class="visitors-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Time</th>
|
||||
<th>IP Address</th>
|
||||
<th>Location</th>
|
||||
<th>ISP</th>
|
||||
<th>Page</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${recentVisitors.map(visitor => `
|
||||
<tr>
|
||||
<td class="visitor-time">${formatVisitorTime(visitor.time)}</td>
|
||||
<td class="visitor-ip">${visitor.ip || 'N/A'}</td>
|
||||
<td class="visitor-location">
|
||||
${visitor.city || 'Unknown'}, ${visitor.country || 'Unknown'}
|
||||
${visitor.countryCode ? `<span class="country-code">${visitor.countryCode}</span>` : ''}
|
||||
</td>
|
||||
<td class="visitor-isp">${visitor.isp || 'Unknown'}</td>
|
||||
<td class="visitor-page">${visitor.page || '/'}</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
`;
|
||||
} else {
|
||||
recentVisitorsContainer.innerHTML = '<p style="color: #62696D; padding: 20px; text-align: center;">No recent visitors found.</p>';
|
||||
}
|
||||
|
||||
// Update blog post stats (reactions are cumulative, not date-specific)
|
||||
const blogPosts = data.blogPosts || [];
|
||||
const blogPostsStats = document.getElementById('blogPostsStats');
|
||||
if (blogPosts.length > 0) {
|
||||
blogPostsStats.innerHTML = blogPosts.map(post => {
|
||||
const reactions = post.reactions || {like: 0, love: 0, helpful: 0};
|
||||
const shares = post.shares || {mastodon: 0, bluesky: 0, copy: 0};
|
||||
return `
|
||||
<div class="post-stat-card">
|
||||
<h3 class="post-stat-title">${post.title}</h3>
|
||||
<div class="post-stat-row">
|
||||
<div class="post-stat-item">
|
||||
<div class="post-stat-item-label">Likes</div>
|
||||
<div class="post-stat-item-value">${reactions.like || 0}</div>
|
||||
</div>
|
||||
<div class="post-stat-item">
|
||||
<div class="post-stat-item-label">Loves</div>
|
||||
<div class="post-stat-item-value">${reactions.love || 0}</div>
|
||||
</div>
|
||||
<div class="post-stat-item">
|
||||
<div class="post-stat-item-label">Helpful</div>
|
||||
<div class="post-stat-item-value">${reactions.helpful || 0}</div>
|
||||
</div>
|
||||
<div class="post-stat-item">
|
||||
<div class="post-stat-item-label">Total Reactions</div>
|
||||
<div class="post-stat-item-value">${post.totalReactions || 0}</div>
|
||||
</div>
|
||||
<div class="post-stat-item">
|
||||
<div class="post-stat-item-label">Mastodon Shares</div>
|
||||
<div class="post-stat-item-value">${shares.mastodon || 0}</div>
|
||||
</div>
|
||||
<div class="post-stat-item">
|
||||
<div class="post-stat-item-label">Bluesky Shares</div>
|
||||
<div class="post-stat-item-value">${shares.bluesky || 0}</div>
|
||||
</div>
|
||||
<div class="post-stat-item">
|
||||
<div class="post-stat-item-label">Link Copies</div>
|
||||
<div class="post-stat-item-value">${shares.copy || 0}</div>
|
||||
</div>
|
||||
<div class="post-stat-item">
|
||||
<div class="post-stat-item-label">Total Shares</div>
|
||||
<div class="post-stat-item-value">${post.totalShares || 0}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
} else {
|
||||
blogPostsStats.innerHTML = '<p style="color: #62696D;">No blog posts found.</p>';
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading analytics:', error);
|
||||
document.getElementById('statsGrid').innerHTML = '<p class="error">Error loading analytics data.</p>';
|
||||
}
|
||||
}
|
||||
|
||||
// Load analytics on page load with today's date range
|
||||
function initializeAnalytics() {
|
||||
loadAnalytics(todayStr, todayStr);
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initializeAnalytics);
|
||||
} else {
|
||||
initializeAnalytics();
|
||||
}
|
||||
|
||||
// Handle start date changes - validate but don't auto-refresh
|
||||
if (dateSelect) {
|
||||
dateSelect.addEventListener('change', function(e) {
|
||||
const newDate = e.target.value;
|
||||
if (newDate) {
|
||||
selectedDate = new Date(newDate);
|
||||
// Ensure start date is not after end date
|
||||
if (endDateSelect && selectedDate > selectedEndDate) {
|
||||
endDateSelect.value = newDate;
|
||||
selectedEndDate = new Date(selectedDate);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,75 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="stylesheet" href="../assets/css/style.css">
|
||||
<link rel="icon" type="image/x-icon" href="../favicon.ico">
|
||||
<script>
|
||||
// Apply theme immediately to prevent flash
|
||||
(function() {
|
||||
const theme = localStorage.getItem('theme') ||
|
||||
(window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
})();
|
||||
</script>
|
||||
<title>Analytics - Launch Pad</title>
|
||||
</head>
|
||||
<body>
|
||||
<button class="theme-toggle" id="themeToggle" aria-label="Toggle dark mode">
|
||||
<svg class="theme-icon theme-icon-moon" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path></svg>
|
||||
<svg class="theme-icon theme-icon-sun" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display: none;"><circle cx="12" cy="12" r="5"></circle><line x1="12" y1="1" x2="12" y2="3"></line><line x1="12" y1="21" x2="12" y2="23"></line><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line><line x1="1" y1="12" x2="3" y2="12"></line><line x1="21" y1="12" x2="23" y2="12"></line><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line></svg>
|
||||
</button>
|
||||
<div class="analytics-container">
|
||||
<h1 class="blog-page-title">Analytics Dashboard</h1>
|
||||
|
||||
<div class="date-selector-container">
|
||||
<div class="date-selectors-wrapper">
|
||||
<div class="date-selector-wrapper">
|
||||
<label for="dateSelect" class="date-label">Start Date</label>
|
||||
<input type="date" id="dateSelect" class="date-selector" value="">
|
||||
<div id="customCalendar" class="custom-calendar"></div>
|
||||
</div>
|
||||
<div class="date-selector-wrapper" id="endDateWrapper">
|
||||
<label for="endDateSelect" class="date-label">End Date</label>
|
||||
<input type="date" id="endDateSelect" class="date-selector" value="">
|
||||
<div id="customCalendarEnd" class="custom-calendar"></div>
|
||||
</div>
|
||||
<div class="date-go-button-wrapper">
|
||||
<button id="goButton" class="go-button">Go</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 class="analytics-section-title">Traffic Statistics</h2>
|
||||
|
||||
<div class="stats-grid" id="statsGrid">
|
||||
<!-- Stats will be loaded here -->
|
||||
</div>
|
||||
|
||||
<h2 class="analytics-section-title">Traffic by Hour <span class="timezone-note">(Server Time: AST/ADT)</span></h2>
|
||||
<div class="stat-card">
|
||||
<div class="hour-chart" id="hourChart">
|
||||
<!-- Hour chart will be loaded here -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 class="analytics-section-title">Recent Visitors <span class="timezone-note">(Your Local Time)</span></h2>
|
||||
<div class="stat-card">
|
||||
<div id="recentVisitors">
|
||||
<!-- Recent visitors will be loaded here -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 class="analytics-section-title">Blog Post Statistics</h2>
|
||||
<div class="blog-posts-stats">
|
||||
<div id="blogPostsStats">
|
||||
<!-- Blog post stats will be loaded here -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="analytics.js"></script>
|
||||
<script src="../assets/js/theme.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,301 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
/**
|
||||
* Analytics Verification Script
|
||||
*
|
||||
* This script helps verify the accuracy of analytics data by:
|
||||
* - Comparing raw visit counts with summary totals
|
||||
* - Identifying potential issues (bots, duplicates, etc.)
|
||||
* - Validating data integrity
|
||||
* - Showing discrepancies
|
||||
*/
|
||||
|
||||
// Timezone: where reality meets server configuration
|
||||
date_default_timezone_set('UTC'); // Adjust to your server's actual timezone
|
||||
|
||||
$dataDir = '/var/www/data/analytics';
|
||||
$date = isset($argv[1]) ? $argv[1] : date('Y-m-d');
|
||||
|
||||
echo "=== Analytics Verification for {$date} ===\n\n";
|
||||
|
||||
// Load summary
|
||||
$summaryFile = $dataDir . '/summary_' . $date . '.json';
|
||||
$summary = [];
|
||||
if (file_exists($summaryFile)) {
|
||||
$summary = json_decode(file_get_contents($summaryFile), true) ?: [];
|
||||
} else {
|
||||
echo "ERROR: Summary file not found: {$summaryFile}\n";
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// Load raw visits
|
||||
$visitsFile = $dataDir . '/visits_' . $date . '.json';
|
||||
$visits = [];
|
||||
if (file_exists($visitsFile)) {
|
||||
$visits = json_decode(file_get_contents($visitsFile), true) ?: [];
|
||||
} else {
|
||||
echo "ERROR: Visits file not found: {$visitsFile}\n";
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// 1. Count pageviews from raw data
|
||||
$pageviews = array_filter($visits, function($v) { return $v['type'] === 'pageview'; });
|
||||
$pageviewCount = count($pageviews);
|
||||
|
||||
// 2. Count new vs returning from raw data
|
||||
$newVisitors = [];
|
||||
$returningVisitors = [];
|
||||
foreach ($pageviews as $visit) {
|
||||
if ($visit['isNew']) {
|
||||
$newVisitors[$visit['visitorId']] = true;
|
||||
} else {
|
||||
$returningVisitors[$visit['visitorId']] = true;
|
||||
}
|
||||
}
|
||||
$newCount = count($newVisitors);
|
||||
$returningCount = count($returningVisitors);
|
||||
|
||||
// 3. Recalculate hourly distribution
|
||||
$byHour = array_fill(0, 24, 0);
|
||||
foreach ($pageviews as $visit) {
|
||||
if (isset($visit['timestamp'])) {
|
||||
$hour = (int)date('H', $visit['timestamp']);
|
||||
if ($hour >= 0 && $hour < 24) {
|
||||
$byHour[$hour]++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Count shares
|
||||
$shares = ['mastodon' => 0, 'bluesky' => 0, 'copy' => 0];
|
||||
foreach ($visits as $visit) {
|
||||
if ($visit['type'] === 'share' && isset($visit['platform'])) {
|
||||
$platform = $visit['platform'];
|
||||
if (isset($shares[$platform])) {
|
||||
$shares[$platform]++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Count RSS clicks
|
||||
$rssClicks = 0;
|
||||
foreach ($visits as $visit) {
|
||||
if ($visit['type'] === 'rss_click') {
|
||||
$rssClicks++;
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Identify potential issues
|
||||
$issues = [];
|
||||
|
||||
// Check for duplicate pageviews (same visitor, same page, within 5 seconds)
|
||||
$duplicates = [];
|
||||
foreach ($pageviews as $i => $visit1) {
|
||||
foreach ($pageviews as $j => $visit2) {
|
||||
if ($i < $j &&
|
||||
$visit1['visitorId'] === $visit2['visitorId'] &&
|
||||
$visit1['page'] === $visit2['page'] &&
|
||||
abs($visit1['timestamp'] - $visit2['timestamp']) < 5) {
|
||||
$duplicates[] = [
|
||||
'visitor' => $visit1['visitorId'],
|
||||
'page' => $visit1['page'],
|
||||
'time1' => date('H:i:s', $visit1['timestamp']),
|
||||
'time2' => date('H:i:s', $visit2['timestamp'])
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (count($duplicates) > 0) {
|
||||
$issues[] = "Found " . count($duplicates) . " potential duplicate pageviews (same visitor, same page, <5s apart)";
|
||||
}
|
||||
|
||||
// Check for suspicious user agents (common bots)
|
||||
$botPatterns = [
|
||||
'/bot/i', '/crawler/i', '/spider/i', '/scraper/i',
|
||||
'/google/i', '/bing/i', '/yahoo/i', '/duckduckbot/i',
|
||||
'/facebookexternalhit/i', '/twitterbot/i', '/linkedinbot/i'
|
||||
];
|
||||
$botCount = 0;
|
||||
$botVisitors = [];
|
||||
foreach ($visits as $visit) {
|
||||
$ua = $visit['userAgent'] ?? '';
|
||||
foreach ($botPatterns as $pattern) {
|
||||
if (preg_match($pattern, $ua)) {
|
||||
$botCount++;
|
||||
$botVisitors[$visit['visitorId']] = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($botCount > 0) {
|
||||
$issues[] = "Found {$botCount} visits from potential bots/crawlers (" . count($botVisitors) . " unique visitors)";
|
||||
}
|
||||
|
||||
// Check for rapid-fire visits (potential scripted access)
|
||||
$rapidVisits = [];
|
||||
$visitorTimestamps = [];
|
||||
foreach ($pageviews as $visit) {
|
||||
$vid = $visit['visitorId'];
|
||||
if (!isset($visitorTimestamps[$vid])) {
|
||||
$visitorTimestamps[$vid] = [];
|
||||
}
|
||||
$visitorTimestamps[$vid][] = $visit['timestamp'];
|
||||
}
|
||||
|
||||
foreach ($visitorTimestamps as $vid => $timestamps) {
|
||||
sort($timestamps);
|
||||
for ($i = 1; $i < count($timestamps); $i++) {
|
||||
$diff = $timestamps[$i] - $timestamps[$i-1];
|
||||
if ($diff < 2) { // Less than 2 seconds between pageviews
|
||||
$rapidVisits[$vid] = ($rapidVisits[$vid] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (count($rapidVisits) > 0) {
|
||||
$issues[] = "Found " . count($rapidVisits) . " visitors with rapid-fire pageviews (<2s apart)";
|
||||
}
|
||||
|
||||
// Check summary vs raw data discrepancies
|
||||
$discrepancies = [];
|
||||
|
||||
if ($summary['total'] != $pageviewCount) {
|
||||
$discrepancies[] = sprintf(
|
||||
"Total visits mismatch: Summary=%d, Raw count=%d (diff: %+d)",
|
||||
$summary['total'], $pageviewCount, $summary['total'] - $pageviewCount
|
||||
);
|
||||
}
|
||||
|
||||
if ($summary['new'] != $newCount) {
|
||||
$discrepancies[] = sprintf(
|
||||
"New visitors mismatch: Summary=%d, Raw count=%d (diff: %+d)",
|
||||
$summary['new'], $newCount, $summary['new'] - $newCount
|
||||
);
|
||||
}
|
||||
|
||||
if ($summary['returning'] != $returningCount) {
|
||||
$discrepancies[] = sprintf(
|
||||
"Returning visitors mismatch: Summary=%d, Raw count=%d (diff: %+d)",
|
||||
$summary['returning'], $returningCount, $summary['returning'] - $returningCount
|
||||
);
|
||||
}
|
||||
|
||||
// Compare hourly data
|
||||
$hourlyDiff = false;
|
||||
for ($h = 0; $h < 24; $h++) {
|
||||
if ($summary['byHour'][$h] != $byHour[$h]) {
|
||||
$hourlyDiff = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($hourlyDiff) {
|
||||
$discrepancies[] = "Hourly distribution differs between summary and raw data";
|
||||
}
|
||||
|
||||
// Compare shares
|
||||
foreach (['mastodon', 'bluesky', 'copy'] as $platform) {
|
||||
$summaryShares = $summary['shares'][$platform] ?? 0;
|
||||
$rawShares = $shares[$platform] ?? 0;
|
||||
if ($summaryShares != $rawShares) {
|
||||
$discrepancies[] = sprintf(
|
||||
"Shares ({$platform}) mismatch: Summary=%d, Raw count=%d (diff: %+d)",
|
||||
$summaryShares, $rawShares, $summaryShares - $rawShares
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Compare RSS
|
||||
$summaryRss = $summary['rss'] ?? 0;
|
||||
if ($summaryRss != $rssClicks) {
|
||||
$discrepancies[] = sprintf(
|
||||
"RSS clicks mismatch: Summary=%d, Raw count=%d (diff: %+d)",
|
||||
$summaryRss, $rssClicks, $summaryRss - $rssClicks
|
||||
);
|
||||
}
|
||||
|
||||
// Display results
|
||||
echo "SUMMARY DATA:\n";
|
||||
echo " Total visits: " . ($summary['total'] ?? 0) . "\n";
|
||||
echo " New visitors: " . ($summary['new'] ?? 0) . "\n";
|
||||
echo " Returning visitors: " . ($summary['returning'] ?? 0) . "\n";
|
||||
echo " RSS clicks: " . ($summary['rss'] ?? 0) . "\n";
|
||||
echo " Shares: Mastodon=" . ($summary['shares']['mastodon'] ?? 0) .
|
||||
", Bluesky=" . ($summary['shares']['bluesky'] ?? 0) .
|
||||
", Copy=" . ($summary['shares']['copy'] ?? 0) . "\n\n";
|
||||
|
||||
echo "RAW DATA COUNT:\n";
|
||||
echo " Total pageviews: {$pageviewCount}\n";
|
||||
echo " Unique new visitors: {$newCount}\n";
|
||||
echo " Unique returning visitors: {$returningCount}\n";
|
||||
echo " RSS clicks: {$rssClicks}\n";
|
||||
echo " Shares: Mastodon={$shares['mastodon']}, Bluesky={$shares['bluesky']}, Copy={$shares['copy']}\n";
|
||||
echo " Total visits (all types): " . count($visits) . "\n\n";
|
||||
|
||||
if (count($discrepancies) > 0) {
|
||||
echo "⚠️ DISCREPANCIES FOUND:\n";
|
||||
foreach ($discrepancies as $disc) {
|
||||
echo " - {$disc}\n";
|
||||
}
|
||||
echo "\n";
|
||||
} else {
|
||||
echo "✓ Summary and raw data match!\n\n";
|
||||
}
|
||||
|
||||
if (count($issues) > 0) {
|
||||
echo "⚠️ POTENTIAL ISSUES:\n";
|
||||
foreach ($issues as $issue) {
|
||||
echo " - {$issue}\n";
|
||||
}
|
||||
echo "\n";
|
||||
} else {
|
||||
echo "✓ No obvious issues detected.\n\n";
|
||||
}
|
||||
|
||||
// Show top visitors
|
||||
echo "TOP VISITORS (by pageview count):\n";
|
||||
$visitorCounts = [];
|
||||
foreach ($pageviews as $visit) {
|
||||
$vid = $visit['visitorId'];
|
||||
$visitorCounts[$vid] = ($visitorCounts[$vid] ?? 0) + 1;
|
||||
}
|
||||
arsort($visitorCounts);
|
||||
$topVisitors = array_slice($visitorCounts, 0, 10, true);
|
||||
foreach ($topVisitors as $vid => $count) {
|
||||
$firstVisit = null;
|
||||
foreach ($pageviews as $v) {
|
||||
if ($v['visitorId'] === $vid) {
|
||||
$firstVisit = $v;
|
||||
break;
|
||||
}
|
||||
}
|
||||
$ua = substr($firstVisit['userAgent'] ?? 'Unknown', 0, 50);
|
||||
echo sprintf(" %s: %d pageviews (UA: %s...)\n", substr($vid, 0, 30), $count, $ua);
|
||||
}
|
||||
|
||||
echo "\n";
|
||||
|
||||
// Show hourly breakdown
|
||||
echo "HOURLY BREAKDOWN (from raw data):\n";
|
||||
for ($h = 0; $h < 24; $h++) {
|
||||
$count = $byHour[$h];
|
||||
$bar = str_repeat('█', min(50, (int)($count / max(1, max($byHour)) * 50)));
|
||||
echo sprintf(" %02d:00 %5d %s\n", $h, $count, $bar);
|
||||
}
|
||||
|
||||
echo "\n";
|
||||
|
||||
// Accuracy notes
|
||||
echo "ACCURACY CONSIDERATIONS:\n";
|
||||
echo " ✓ Data is recalculated from raw timestamps (hourly stats are accurate)\n";
|
||||
echo " ⚠ Bot traffic is NOT filtered (may inflate numbers)\n";
|
||||
echo " ⚠ Ad blockers may prevent tracking (may deflate numbers)\n";
|
||||
echo " ⚠ Self-visits are NOT filtered\n";
|
||||
echo " ⚠ JavaScript-disabled browsers won't be tracked\n";
|
||||
echo " ⚠ Privacy tools may block localStorage (affects visitor ID)\n";
|
||||
echo " ⚠ New/Returning is calculated per-day, not across days\n";
|
||||
echo " ⚠ Multiple tabs/devices = multiple visitors\n";
|
||||
echo "\n";
|
||||
|
||||
?>
|
||||
Reference in New Issue
Block a user