first commit

This commit is contained in:
2026-01-25 11:33:37 -04:00
commit 6f42a58ee5
148 changed files with 32974 additions and 0 deletions
+469
View File
@@ -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);
}
}
});
}
})();
+75
View File
@@ -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>
+301
View File
@@ -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";
?>