470 lines
21 KiB
JavaScript
470 lines
21 KiB
JavaScript
// 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);
|
||
}
|
||
}
|
||
});
|
||
}
|
||
})();
|