// 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 = `
${monthNames[month]} ${year}
${weekdays.map(day => `
${day}
`).join('')}
`; 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 += `
${cellDate.getDate()}
`; } html += '
'; 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 = '

Access denied or no data available.

'; document.getElementById('hourChart').innerHTML = ''; document.getElementById('blogPostsStats').innerHTML = ''; return; } const result = await response.json(); if (!result.success) { document.getElementById('statsGrid').innerHTML = '

Error loading data.

'; 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 = `
${data.total || 0}
Total Visits
${data.new || 0}
New Visitors
${data.returning || 0}
Returning Visitors
${totalShares}
Total Shares
${shares.mastodon || 0}
Mastodon Shares
${shares.bluesky || 0}
Bluesky Shares
${shares.copy || 0}
Link Copies
${rssSubscriptions}
RSS Subscribers
`; // 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 `
${visits > 0 ? visits : ''}
${hourLabel}
`; }).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 = ` ${recentVisitors.map(visitor => ` `).join('')}
Time IP Address Location ISP Page
${formatVisitorTime(visitor.time)} ${visitor.ip || 'N/A'} ${visitor.city || 'Unknown'}, ${visitor.country || 'Unknown'} ${visitor.countryCode ? `${visitor.countryCode}` : ''} ${visitor.isp || 'Unknown'} ${visitor.page || '/'}
`; } else { recentVisitorsContainer.innerHTML = '

No recent visitors found.

'; } // 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 `

${post.title}

Likes
${reactions.like || 0}
Loves
${reactions.love || 0}
Helpful
${reactions.helpful || 0}
Total Reactions
${post.totalReactions || 0}
Mastodon Shares
${shares.mastodon || 0}
Bluesky Shares
${shares.bluesky || 0}
Link Copies
${shares.copy || 0}
Total Shares
${post.totalShares || 0}
`; }).join(''); } else { blogPostsStats.innerHTML = '

No blog posts found.

'; } } catch (error) { console.error('Error loading analytics:', error); document.getElementById('statsGrid').innerHTML = '

Error loading analytics data.

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