WebsiteTemplate/analytics/analytics.js
2026-01-25 11:33:37 -04:00

470 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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);
}
}
});
}
})();