// 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 = `
${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 = `
| Time |
IP Address |
Location |
ISP |
Page |
${recentVisitors.map(visitor => `
| ${formatVisitorTime(visitor.time)} |
${visitor.ip || 'N/A'} |
${visitor.city || 'Unknown'}, ${visitor.country || 'Unknown'}
${visitor.countryCode ? `${visitor.countryCode}` : ''}
|
${visitor.isp || 'Unknown'} |
${visitor.page || '/'} |
`).join('')}
`;
} 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);
}
}
});
}
})();