/*************************************************************************************************** * Copyright (C) 2026 by WallyHackenslacker wallyhackenslacker@noreply.git.hackenslacker.space * * * * Permission to use, copy, modify, and/or distribute this software for any purpose with or without * * fee is hereby granted. * * * * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS * * SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE * * AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES * * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, * * NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE * * OF THIS SOFTWARE. * ****************************************************************************************************/ // Theme management const themeToggle = document.getElementById('theme-toggle'); const themeIcon = document.getElementById('theme-icon'); const themes = ['auto', 'light', 'dark']; let currentThemeIndex = 0; function getSystemTheme() { return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; } function updateThemeIcon() { const theme = themes[currentThemeIndex]; if (theme === 'auto') { themeIcon.textContent = ''; themeIcon.className = 'icon icon-auto'; themeToggle.title = 'Theme: Auto (click to change)'; } else if (theme === 'light') { themeIcon.textContent = '\u2600\uFE0F'; themeIcon.className = 'icon'; themeToggle.title = 'Theme: Light (click to change)'; } else { themeIcon.textContent = '\uD83C\uDF19'; themeIcon.className = 'icon'; themeToggle.title = 'Theme: Dark (click to change)'; } } function applyTheme() { const theme = themes[currentThemeIndex]; if (theme === 'auto') { document.documentElement.removeAttribute('data-theme'); } else { document.documentElement.setAttribute('data-theme', theme); } updateThemeIcon(); localStorage.setItem('theme', theme); updateChartColors(); } function loadSavedTheme() { const saved = localStorage.getItem('theme'); if (saved) { currentThemeIndex = themes.indexOf(saved); if (currentThemeIndex === -1) currentThemeIndex = 0; } applyTheme(); } themeToggle.addEventListener('click', () => { currentThemeIndex = (currentThemeIndex + 1) % themes.length; applyTheme(); }); window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => { if (themes[currentThemeIndex] === 'auto') { updateThemeIcon(); updateChartColors(); } }); // Data and chart logic const allGames = __ALL_GAMES__; const topN = __TOP_N__; // Theme-specific configuration const themeConfig = __THEME_CONFIG__; function formatTime(hours) { const h = Math.floor(hours); const m = Math.round((hours - h) * 60); if (h === 0) return m + 'm'; if (m === 0) return h + 'h'; return h + 'h ' + m + 'm'; } function getServices() { const services = {}; allGames.forEach(g => { const s = g.service; if (!services[s]) services[s] = { count: 0, playtime: 0 }; services[s].count++; services[s].playtime += g.playtime; }); return Object.entries(services) .sort((a, b) => b[1].playtime - a[1].playtime) .map(([name, data]) => ({ name, ...data })); } const services = getServices(); const filtersDiv = document.getElementById('filters'); services.forEach(service => { const label = document.createElement('label'); label.className = 'filter-label'; label.innerHTML = ` ${service.name} (${service.count}) `; filtersDiv.appendChild(label); }); let chart = null; let categoriesChart = null; const ctx = document.getElementById('playtime-chart').getContext('2d'); const ctxCategories = document.getElementById('categories-chart').getContext('2d'); // Initialize theme after chart variables are declared loadSavedTheme(); function getSelectedServices() { const checkboxes = filtersDiv.querySelectorAll('input[type="checkbox"]'); return Array.from(checkboxes) .filter(cb => cb.checked) .map(cb => cb.value); } function getFilteredData(selectedServices) { const filtered = allGames .filter(g => selectedServices.includes(g.service)) .sort((a, b) => b.playtime - a.playtime); if (filtered.length === 0) { return { chartData: [], othersGames: [], categoriesData: [], totalPlaytime: 0, totalGames: 0 }; } const totalPlaytime = filtered.reduce((sum, g) => sum + g.playtime, 0); const totalGames = filtered.length; const topGames = filtered.slice(0, topN).map(g => ({ name: g.name, playtime: g.playtime, service: g.service, categories: g.categories || [] })); let othersGames = []; if (filtered.length > topN) { othersGames = filtered.slice(topN).map(g => ({ name: g.name, playtime: g.playtime, service: g.service, categories: g.categories || [] })); const othersPlaytime = othersGames.reduce((sum, g) => sum + g.playtime, 0); const othersCount = othersGames.length; topGames.push({ name: `Others (${othersCount} games)`, playtime: othersPlaytime, service: 'others' }); } const categoryMap = {}; filtered.forEach(g => { if (g.categories && g.categories.length > 0) { g.categories.forEach(cat => { if (cat === '.hidden' || cat === 'favorite') return; if (!categoryMap[cat]) { categoryMap[cat] = { name: cat, playtime: 0, gameCount: 0 }; } categoryMap[cat].playtime += g.playtime; categoryMap[cat].gameCount++; }); } }); const categoriesData = Object.values(categoryMap) .sort((a, b) => b.playtime - a.playtime); const runnerMap = {}; filtered.forEach(g => { const runner = g.runner || 'unknown'; if (!runnerMap[runner]) { runnerMap[runner] = { name: runner, playtime: 0, gameCount: 0 }; } runnerMap[runner].playtime += g.playtime; runnerMap[runner].gameCount++; }); const runnersData = Object.values(runnerMap) .sort((a, b) => b.playtime - a.playtime); return { chartData: topGames, othersGames, categoriesData, runnersData, totalPlaytime, totalGames }; } function getChartTextColor() { const theme = themes[currentThemeIndex]; if (theme === 'dark') return themeConfig.textColorDark; if (theme === 'light') return themeConfig.textColorLight; return getSystemTheme() === 'dark' ? themeConfig.textColorDark : themeConfig.textColorLight; } function getChartBorderColor() { const theme = themes[currentThemeIndex]; if (theme === 'dark') return themeConfig.borderColorDark; if (theme === 'light') return themeConfig.borderColorLight; return getSystemTheme() === 'dark' ? themeConfig.borderColorDark : themeConfig.borderColorLight; } function updateChartColors() { const textColor = getChartTextColor(); if (typeof chart !== 'undefined' && chart) { chart.options.plugins.legend.labels.color = textColor; chart.update(); } if (typeof categoriesChart !== 'undefined' && categoriesChart) { categoriesChart.options.plugins.legend.labels.color = textColor; categoriesChart.update(); } } function updateDisplay() { const selectedServices = getSelectedServices(); const { chartData, othersGames, categoriesData, runnersData, totalPlaytime, totalGames } = getFilteredData(selectedServices); document.getElementById('total-games').textContent = totalGames; document.getElementById('total-time').textContent = formatTime(totalPlaytime); if (chart) { chart.destroy(); } if (categoriesChart) { categoriesChart.destroy(); } if (chartData.length === 0) { document.getElementById('games-table').innerHTML = 'No games match the selected filters'; document.getElementById('categories-table').innerHTML = 'No categories found'; document.getElementById('runners-table').innerHTML = 'No runners found'; return; } const textColor = getChartTextColor(); const borderColor = getChartBorderColor(); chart = new Chart(ctx, { type: 'doughnut', data: { labels: chartData.map(g => g.name), datasets: [{ data: chartData.map(g => g.playtime), backgroundColor: themeConfig.colors.slice(0, chartData.length), borderColor: borderColor, borderWidth: themeConfig.borderWidth }] }, options: { responsive: true, plugins: { legend: { position: 'bottom', labels: { color: textColor, font: { family: themeConfig.fontFamily, size: 11, weight: themeConfig.fontWeight }, padding: 12, usePointStyle: true, pointStyle: themeConfig.pointStyle } }, tooltip: { backgroundColor: themeConfig.tooltipBg, titleColor: themeConfig.tooltipTitleColor, bodyColor: themeConfig.tooltipBodyColor, borderColor: themeConfig.tooltipBorderColor, borderWidth: themeConfig.tooltipBorderWidth, titleFont: { weight: 'bold', family: themeConfig.fontFamily }, bodyFont: { weight: 'normal', family: themeConfig.fontFamily }, cornerRadius: themeConfig.tooltipCornerRadius, padding: 12, callbacks: { title: function(context) { return themeConfig.uppercaseTooltip ? context[0].label.toUpperCase() : context[0].label; }, beforeBody: function(context) { const index = context[0].dataIndex; const service = chartData[index].service; if (service && service !== 'others') { return themeConfig.uppercaseTooltip ? service.toUpperCase() : service.charAt(0).toUpperCase() + service.slice(1); } return ''; }, label: function(context) { const value = context.raw; const percent = ((value / totalPlaytime) * 100).toFixed(1); return formatTime(value) + ' (' + percent + '%)'; } } } } } }); const topCategoriesChart = categoriesData.slice(0, topN); const otherCategoriesChart = categoriesData.slice(topN); const categoriesChartData = topCategoriesChart.map(c => ({ name: c.name, playtime: c.playtime })); if (otherCategoriesChart.length > 0) { const othersPlaytime = otherCategoriesChart.reduce((sum, c) => sum + c.playtime, 0); categoriesChartData.push({ name: `Others (${otherCategoriesChart.length} categories)`, playtime: othersPlaytime }); } if (categoriesChartData.length > 0) { categoriesChart = new Chart(ctxCategories, { type: 'doughnut', data: { labels: categoriesChartData.map(c => c.name), datasets: [{ data: categoriesChartData.map(c => c.playtime), backgroundColor: themeConfig.colors.slice(0, categoriesChartData.length), borderColor: borderColor, borderWidth: themeConfig.borderWidth }] }, options: { responsive: true, plugins: { legend: { position: 'bottom', labels: { color: textColor, font: { family: themeConfig.fontFamily, size: 11, weight: themeConfig.fontWeight }, padding: 12, usePointStyle: true, pointStyle: themeConfig.pointStyle } }, tooltip: { backgroundColor: themeConfig.tooltipBg, titleColor: themeConfig.tooltipTitleColor, bodyColor: themeConfig.tooltipBodyColor, borderColor: themeConfig.tooltipBorderColor, borderWidth: themeConfig.tooltipBorderWidth, cornerRadius: themeConfig.tooltipCornerRadius, padding: 12, titleFont: { family: themeConfig.fontFamily }, bodyFont: { family: themeConfig.fontFamily }, callbacks: { label: function(context) { const value = context.raw; const percent = ((value / totalPlaytime) * 100).toFixed(1); return ' ' + formatTime(value) + ' (' + percent + '%)'; } } } } } }); } const tbody = document.getElementById('games-table'); tbody.innerHTML = ''; chartData.forEach((game, index) => { const percent = ((game.playtime / totalPlaytime) * 100).toFixed(1); const isOthers = game.service === 'others'; const serviceBadge = !isOthers ? `${game.service}` : ''; const categoriesBadges = !isOthers && game.categories && game.categories.length > 0 ? game.categories .filter(cat => cat !== '.hidden' && cat !== 'favorite') .map(cat => `${cat}`) .join('') : ''; const row = document.createElement('tr'); if (isOthers) { row.className = 'others-row'; } row.innerHTML = ` ${index + 1} ${game.name}${serviceBadge}${categoriesBadges} ${formatTime(game.playtime)} ${percent}% `; tbody.appendChild(row); if (isOthers && othersGames.length > 0) { const detailRows = []; othersGames.forEach((otherGame, otherIndex) => { const otherPercent = ((otherGame.playtime / totalPlaytime) * 100).toFixed(1); const otherCategoriesBadges = otherGame.categories && otherGame.categories.length > 0 ? otherGame.categories .filter(cat => cat !== '.hidden' && cat !== 'favorite') .map(cat => `${cat}`) .join('') : ''; const detailRow = document.createElement('tr'); detailRow.className = 'others-detail'; detailRow.innerHTML = ` ${index + 1}.${otherIndex + 1} ${otherGame.name} ${otherGame.service}${otherCategoriesBadges} ${formatTime(otherGame.playtime)} ${otherPercent}% `; tbody.appendChild(detailRow); detailRows.push(detailRow); }); row.addEventListener('click', () => { row.classList.toggle('expanded'); detailRows.forEach(dr => dr.classList.toggle('visible')); }); } }); const catTbody = document.getElementById('categories-table'); catTbody.innerHTML = ''; if (categoriesData.length === 0) { catTbody.innerHTML = 'No categories found'; } else { const topCategories = categoriesData.slice(0, topN); const otherCategories = categoriesData.slice(topN); topCategories.forEach((cat, index) => { const percent = ((cat.playtime / totalPlaytime) * 100).toFixed(1); const row = document.createElement('tr'); row.innerHTML = ` ${index + 1} ${cat.name} ${cat.gameCount} games ${formatTime(cat.playtime)} ${percent}% `; catTbody.appendChild(row); }); if (otherCategories.length > 0) { const othersPlaytime = otherCategories.reduce((sum, c) => sum + c.playtime, 0); const othersPercent = ((othersPlaytime / totalPlaytime) * 100).toFixed(1); const othersIndex = topCategories.length; const othersRow = document.createElement('tr'); othersRow.className = 'others-row'; othersRow.innerHTML = ` ${othersIndex + 1} Others (${otherCategories.length} categories) ${formatTime(othersPlaytime)} ${othersPercent}% `; catTbody.appendChild(othersRow); const detailRows = []; otherCategories.forEach((otherCat, otherIndex) => { const otherPercent = ((otherCat.playtime / totalPlaytime) * 100).toFixed(1); const detailRow = document.createElement('tr'); detailRow.className = 'others-detail'; detailRow.innerHTML = ` ${othersIndex + 1}.${otherIndex + 1} ${otherCat.name} ${otherCat.gameCount} games ${formatTime(otherCat.playtime)} ${otherPercent}% `; catTbody.appendChild(detailRow); detailRows.push(detailRow); }); othersRow.addEventListener('click', () => { othersRow.classList.toggle('expanded'); detailRows.forEach(dr => dr.classList.toggle('visible')); }); } } const runnersTbody = document.getElementById('runners-table'); runnersTbody.innerHTML = ''; if (runnersData.length === 0) { runnersTbody.innerHTML = 'No runners found'; } else { const topRunners = runnersData.slice(0, topN); const otherRunners = runnersData.slice(topN); topRunners.forEach((runner, index) => { const percent = ((runner.playtime / totalPlaytime) * 100).toFixed(1); const row = document.createElement('tr'); row.innerHTML = ` ${index + 1} ${runner.name} ${runner.gameCount} games ${formatTime(runner.playtime)} ${percent}% `; runnersTbody.appendChild(row); }); if (otherRunners.length > 0) { const othersPlaytime = otherRunners.reduce((sum, r) => sum + r.playtime, 0); const othersPercent = ((othersPlaytime / totalPlaytime) * 100).toFixed(1); const othersIndex = topRunners.length; const othersRow = document.createElement('tr'); othersRow.className = 'others-row'; othersRow.innerHTML = ` ${othersIndex + 1} Others (${otherRunners.length} runners) ${formatTime(othersPlaytime)} ${othersPercent}% `; runnersTbody.appendChild(othersRow); const detailRows = []; otherRunners.forEach((otherRunner, otherIndex) => { const otherPercent = ((otherRunner.playtime / totalPlaytime) * 100).toFixed(1); const detailRow = document.createElement('tr'); detailRow.className = 'others-detail'; detailRow.innerHTML = ` ${othersIndex + 1}.${otherIndex + 1} ${otherRunner.name} ${otherRunner.gameCount} games ${formatTime(otherRunner.playtime)} ${otherPercent}% `; runnersTbody.appendChild(detailRow); detailRows.push(detailRow); }); othersRow.addEventListener('click', () => { othersRow.classList.toggle('expanded'); detailRows.forEach(dr => dr.classList.toggle('visible')); }); } } } filtersDiv.addEventListener('change', updateDisplay); updateDisplay(); // Tab switching document.querySelectorAll('.tab').forEach(tab => { tab.addEventListener('click', () => { const tabId = tab.dataset.tab; document.querySelectorAll('.tab').forEach(t => t.classList.remove('active')); tab.classList.add('active'); document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active')); document.getElementById('tab-' + tabId).classList.add('active'); }); }); // Scroll to top button const scrollTopBtn = document.getElementById('scroll-top'); function updateScrollTopVisibility() { if (window.scrollY > 100) { scrollTopBtn.classList.add('visible'); } else { scrollTopBtn.classList.remove('visible'); } } window.addEventListener('scroll', updateScrollTopVisibility); updateScrollTopVisibility(); scrollTopBtn.addEventListener('click', () => { window.scrollTo({ top: 0, behavior: 'smooth' }); });