From b6d7060e79d5b5f044734f1de16eaf590f59267b Mon Sep 17 00:00:00 2001 From: Wally Hackenslacker Date: Sat, 14 Mar 2026 03:44:20 -0400 Subject: [PATCH] Added descriptive comments to the script file. --- templates/script.js | 145 ++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 133 insertions(+), 12 deletions(-) diff --git a/templates/script.js b/templates/script.js index b1030a1..16b7579 100644 --- a/templates/script.js +++ b/templates/script.js @@ -1,4 +1,4 @@ -/*************************************************************************************************** +/**************************************************************************************************** * 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 * @@ -12,16 +12,36 @@ * OF THIS SOFTWARE. * ****************************************************************************************************/ +/* + * Report UI controller + * -------------------- + * This script powers all client-side behavior for the generated report page: + * 1) Theme handling (auto/light/dark) + persistence in localStorage + * 2) Data aggregation from embedded game data + * 3) Chart.js chart creation and refresh after filter/theme changes + * 4) Table rendering with expandable "Others" rows + * 5) Small UI helpers (tabs, scroll-to-top button) + * + * The generator injects placeholders such as __ALL_GAMES__, __TOP_N__, and __THEME_CONFIG__ + * before this script reaches the browser. + */ + // Theme management const themeToggle = document.getElementById('theme-toggle'); const themeIcon = document.getElementById('theme-icon'); const themes = ['auto', 'light', 'dark']; let currentThemeIndex = 0; +// Returns the current OS/browser theme preference. function getSystemTheme() { return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; } +/* + * Updates button icon/title to reflect currently selected mode. + * + * Note: "auto" does not hard-force a theme; it follows system preference. + */ function updateThemeIcon() { const theme = themes[currentThemeIndex]; if (theme === 'auto') { @@ -39,6 +59,15 @@ function updateThemeIcon() { } } +/* + * Applies the selected theme mode to the root element and re-syncs all dependent UI. + * + * Behavior: + * - auto: remove data-theme so CSS/media-query driven defaults can apply + * - light/dark: set data-theme explicitly for deterministic styling + * + * Also updates chart legend colors because those are configured in JS, not pure CSS. + */ function applyTheme() { const theme = themes[currentThemeIndex]; if (theme === 'auto') { @@ -51,6 +80,7 @@ function applyTheme() { updateChartColors(); } +// Restores previously saved theme mode; defaults to "auto" when absent/invalid. function loadSavedTheme() { const saved = localStorage.getItem('theme'); if (saved) { @@ -60,11 +90,13 @@ function loadSavedTheme() { applyTheme(); } +// Cycle theme modes in a fixed order: auto -> light -> dark -> auto. themeToggle.addEventListener('click', () => { currentThemeIndex = (currentThemeIndex + 1) % themes.length; applyTheme(); }); +// When system theme changes and mode is "auto", refresh theme-dependent UI pieces. window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => { if (themes[currentThemeIndex] === 'auto') { updateThemeIcon(); @@ -79,6 +111,7 @@ const topN = __TOP_N__; // Theme-specific configuration const themeConfig = __THEME_CONFIG__; +// Converts decimal hours to a compact human-friendly string (e.g. 1.5 -> "1h 30m"). function formatTime(hours) { const h = Math.floor(hours); const m = Math.round((hours - h) * 60); @@ -87,6 +120,11 @@ function formatTime(hours) { return h + 'h ' + m + 'm'; } +/* + * Aggregates game list by service/source for checkbox filter creation. + * + * Output is sorted by playtime descending so the most relevant services appear first. + */ function getServices() { const services = {}; allGames.forEach(g => { @@ -102,6 +140,8 @@ function getServices() { const services = getServices(); const filtersDiv = document.getElementById('filters'); + +// Build one checkbox per service so users can include/exclude data dynamically. services.forEach(service => { const label = document.createElement('label'); label.className = 'filter-label'; @@ -125,6 +165,7 @@ const ctxSources = document.getElementById('sources-chart').getContext('2d'); // Initialize theme after chart variables are declared loadSavedTheme(); +// Reads currently checked service filters from the filter panel. function getSelectedServices() { const checkboxes = filtersDiv.querySelectorAll('input[type="checkbox"]'); return Array.from(checkboxes) @@ -132,6 +173,19 @@ function getSelectedServices() { .map(cb => cb.value); } +/* + * Produces all derived datasets required by charts and tables for the current filters. + * + * Returns: + * - chartData: top games list used by the main playtime chart/table + * - othersGames: the overflow games collapsed into chartData's "Others" bucket + * - categoriesData/runnersData/sourcesData: grouped aggregations for secondary views + * - totalPlaytime/totalGames: summary statistics for header counters and percentages + * + * Example (topN = 5): + * - 18 filtered games => 5 explicit entries + 1 synthetic "Others (13 games)" entry + * - the 13 overflow games are still accessible in expandable table detail rows + */ function getFilteredData(selectedServices) { const filtered = allGames .filter(g => selectedServices.includes(g.service)) @@ -144,6 +198,7 @@ function getFilteredData(selectedServices) { const totalPlaytime = filtered.reduce((sum, g) => sum + g.playtime, 0); const totalGames = filtered.length; + // Keep only top N games for chart readability. const topGames = filtered.slice(0, topN).map(g => ({ name: g.name, playtime: g.playtime, @@ -153,6 +208,7 @@ function getFilteredData(selectedServices) { let othersGames = []; + // Collapse remaining entries into a single "Others" slice while preserving detail rows. if (filtered.length > topN) { othersGames = filtered.slice(topN).map(g => ({ name: g.name, @@ -164,11 +220,19 @@ function getFilteredData(selectedServices) { const othersCount = othersGames.length; topGames.push({ name: `Others (${othersCount} games)`, - playtime: othersPlaytime, - service: 'others' + playtime: othersPlaytime, + service: 'others' }); } + /* + * Category aggregation notes: + * - A game's full playtime contributes to each of its categories. + * - Internal/special categories are intentionally hidden from report views. + * + * Example: + * Game X (10h) in [RPG, Coop] increases RPG by 10h and Coop by 10h. + */ const categoryMap = {}; filtered.forEach(g => { if (g.categories && g.categories.length > 0) { @@ -185,6 +249,7 @@ function getFilteredData(selectedServices) { const categoriesData = Object.values(categoryMap) .sort((a, b) => b.playtime - a.playtime); + // Aggregate by runner (wine, native, dosbox, etc.). Missing values become "unknown". const runnerMap = {}; filtered.forEach(g => { const runner = g.runner || 'unknown'; @@ -197,6 +262,7 @@ function getFilteredData(selectedServices) { const runnersData = Object.values(runnerMap) .sort((a, b) => b.playtime - a.playtime); + // Aggregate by source/service to power the Sources chart. const sourceMap = {}; filtered.forEach(g => { const source = g.service || 'unknown'; @@ -212,6 +278,7 @@ function getFilteredData(selectedServices) { return { chartData: topGames, othersGames, categoriesData, runnersData, sourcesData, totalPlaytime, totalGames }; } +// Theme-aware chart text color (explicit mode or system-driven when in auto). function getChartTextColor() { const theme = themes[currentThemeIndex]; if (theme === 'dark') return themeConfig.textColorDark; @@ -219,6 +286,7 @@ function getChartTextColor() { return getSystemTheme() === 'dark' ? themeConfig.textColorDark : themeConfig.textColorLight; } +// Theme-aware border color for chart segment separators. function getChartBorderColor() { const theme = themes[currentThemeIndex]; if (theme === 'dark') return themeConfig.borderColorDark; @@ -226,6 +294,12 @@ function getChartBorderColor() { return getSystemTheme() === 'dark' ? themeConfig.borderColorDark : themeConfig.borderColorLight; } +/* + * Updates existing chart instances after theme changes. + * + * We only patch runtime options that are theme-sensitive here (legend text color), + * then call chart.update() so Chart.js redraws without rebuilding datasets. + */ function updateChartColors() { const textColor = getChartTextColor(); if (typeof chart !== 'undefined' && chart) { @@ -246,6 +320,19 @@ function updateChartColors() { } } +/* + * Main render pipeline. + * + * This function is triggered on: + * - initial page load + * - service filter changes + * + * Steps: + * 1) read active filters and compute all derived data + * 2) refresh summary counters + * 3) rebuild charts + * 4) rebuild data tables (including expandable Others rows) + */ function updateDisplay() { const selectedServices = getSelectedServices(); const { chartData, othersGames, categoriesData, runnersData, sourcesData, totalPlaytime, totalGames } = getFilteredData(selectedServices); @@ -253,6 +340,7 @@ function updateDisplay() { document.getElementById('total-games').textContent = totalGames; document.getElementById('total-time').textContent = formatTime(totalPlaytime); + // Destroy old chart instances before creating new ones to avoid stacked canvases/memory leaks. if (chart) { chart.destroy(); } @@ -279,16 +367,24 @@ function updateDisplay() { const textColor = getChartTextColor(); const borderColor = getChartBorderColor(); + /* + * Primary chart: top games by playtime (plus optional Others slice). + * + * Tooltip details: + * - title: game/slice name + * - beforeBody: service label (when available and not Others) + * - label: absolute time + percentage of currently filtered total + */ chart = new Chart(ctx, { type: 'doughnut', data: { labels: chartData.map(g => g.name), - datasets: [{ - data: chartData.map(g => g.playtime), + datasets: [{ + data: chartData.map(g => g.playtime), backgroundColor: themeConfig.colors.slice(0, chartData.length), borderColor: borderColor, borderWidth: themeConfig.borderWidth - }] + }] }, options: { responsive: true, @@ -340,6 +436,12 @@ function updateDisplay() { } }); + /* + * Categories chart uses the same topN + Others strategy as games. + * + * Example (topN = 8): + * 12 categories => 8 visible slices + "Others (4 categories)" slice. + */ const topCategoriesChart = categoriesData.slice(0, topN); const otherCategoriesChart = categoriesData.slice(topN); const categoriesChartData = topCategoriesChart.map(c => ({ @@ -350,7 +452,7 @@ function updateDisplay() { const othersPlaytime = otherCategoriesChart.reduce((sum, c) => sum + c.playtime, 0); categoriesChartData.push({ name: `Others (${otherCategoriesChart.length} categories)`, - playtime: othersPlaytime + playtime: othersPlaytime }); } @@ -359,12 +461,12 @@ function updateDisplay() { type: 'doughnut', data: { labels: categoriesChartData.map(c => c.name), - datasets: [{ - data: categoriesChartData.map(c => c.playtime), + datasets: [{ + data: categoriesChartData.map(c => c.playtime), backgroundColor: themeConfig.colors.slice(0, categoriesChartData.length), borderColor: borderColor, borderWidth: themeConfig.borderWidth - }] + }] }, options: { responsive: true, @@ -406,7 +508,7 @@ function updateDisplay() { }); } - // Runners Chart + // Runners chart: identical bucketing strategy applied to runner aggregation. const topRunnersChart = runnersData.slice(0, topN); const otherRunnersChart = runnersData.slice(topN); const runnersChartData = topRunnersChart.map(r => ({ @@ -473,7 +575,7 @@ function updateDisplay() { }); } - // Sources Chart + // Sources chart: grouped by service/source, also with topN + Others bucketing. const topSourcesChart = sourcesData.slice(0, topN); const otherSourcesChart = sourcesData.slice(topN); const sourcesChartData = topSourcesChart.map(s => ({ @@ -540,9 +642,20 @@ function updateDisplay() { }); } + /* + * Games table rendering + * --------------------- + * Mirrors the main chart ordering and values. + * + * "Others" behavior: + * - parent row shows combined totals + * - click toggles child detail rows for each hidden game + * - detail index format uses parent.child numbering (e.g. 6.1, 6.2) + */ const tbody = document.getElementById('games-table'); tbody.innerHTML = ''; chartData.forEach((game, index) => { + // Percentages are always based on currently filtered total playtime. const percent = ((game.playtime / totalPlaytime) * 100).toFixed(1); const isOthers = game.service === 'others'; const serviceBadge = !isOthers @@ -594,6 +707,7 @@ function updateDisplay() { detailRows.push(detailRow); }); + // Expand/collapse all detail rows tied to this "Others" summary row. row.addEventListener('click', () => { row.classList.toggle('expanded'); detailRows.forEach(dr => dr.classList.toggle('visible')); @@ -601,6 +715,7 @@ function updateDisplay() { } }); + // Categories table mirrors chart grouping and supports expandable "Others" rows. const catTbody = document.getElementById('categories-table'); catTbody.innerHTML = ''; if (categoriesData.length === 0) { @@ -666,6 +781,7 @@ function updateDisplay() { } } + // Runners table mirrors chart grouping and supports expandable "Others" rows. const runnersTbody = document.getElementById('runners-table'); runnersTbody.innerHTML = ''; if (runnersData.length === 0) { @@ -732,10 +848,13 @@ function updateDisplay() { } } +// Re-render whenever any service checkbox state changes. filtersDiv.addEventListener('change', updateDisplay); +// Initial render with all services selected by default. updateDisplay(); // Tab switching +// Activates selected tab and matching panel while deactivating the rest. document.querySelectorAll('.tab').forEach(tab => { tab.addEventListener('click', () => { const tabId = tab.dataset.tab; @@ -749,6 +868,7 @@ document.querySelectorAll('.tab').forEach(tab => { // Scroll to top button const scrollTopBtn = document.getElementById('scroll-top'); +// Show the floating button only after user has scrolled past a threshold. function updateScrollTopVisibility() { if (window.scrollY > 100) { scrollTopBtn.classList.add('visible'); @@ -760,6 +880,7 @@ function updateScrollTopVisibility() { window.addEventListener('scroll', updateScrollTopVisibility); updateScrollTopVisibility(); +// Smoothly jump to top for better UX on long reports. scrollTopBtn.addEventListener('click', () => { window.scrollTo({ top: 0,