Add categories chart and expandable Others row to categories table

- Add second doughnut chart showing playtime by category alongside games chart
- Charts display side-by-side on large screens, stacked on small screens (<700px)
- Group categories beyond top 10 into expandable "Others" row in table
- Add color boxes to category rows matching chart colors

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Miguel Astor
2026-02-25 18:42:59 -04:00
parent c0953edf3a
commit bc0a541034

View File

@@ -225,17 +225,41 @@ HTML_TEMPLATE = """<!DOCTYPE html>
margin-top: 4px;
}
/* Chart container */
/* Chart containers */
.charts-wrapper {
display: flex;
gap: 20px;
justify-content: center;
flex-wrap: wrap;
}
.chart-container {
position: relative;
max-width: 500px;
margin: 0 auto;
flex: 1;
min-width: 280px;
max-width: 450px;
padding: 10px;
background: #FFFFFF;
border: 1px solid;
border-color: var(--mac-border-dark) var(--mac-border-light) var(--mac-border-light) var(--mac-border-dark);
box-shadow: inset 1px 1px 0 var(--mac-border-dark);
}
.chart-title {
font-family: 'Charcoal', 'Chicago', Geneva, sans-serif;
font-size: 11px;
font-weight: bold;
text-align: center;
margin-bottom: 8px;
color: var(--mac-text);
}
@media (max-width: 700px) {
.charts-wrapper {
flex-direction: column;
align-items: center;
}
.chart-container {
max-width: 100%;
}
}
/* Tables styled like Mac OS 9 lists */
table {
@@ -499,8 +523,15 @@ HTML_TEMPLATE = """<!DOCTYPE html>
<div class="window-shade"></div>
</div>
<div class="window-content">
<div class="chart-container">
<canvas id="playtime-chart"></canvas>
<div class="charts-wrapper">
<div class="chart-container">
<div class="chart-title">Top Games</div>
<canvas id="playtime-chart"></canvas>
</div>
<div class="chart-container">
<div class="chart-title">By Category</div>
<canvas id="categories-chart"></canvas>
</div>
</div>
</div>
</div>
@@ -598,7 +629,9 @@ HTML_TEMPLATE = """<!DOCTYPE html>
});
let chart = null;
let categoriesChart = null;
const ctx = document.getElementById('playtime-chart').getContext('2d');
const ctxCategories = document.getElementById('categories-chart').getContext('2d');
function getSelectedServices() {
const checkboxes = filtersDiv.querySelectorAll('input[type="checkbox"]');
@@ -671,6 +704,9 @@ HTML_TEMPLATE = """<!DOCTYPE html>
if (chart) {
chart.destroy();
}
if (categoriesChart) {
categoriesChart.destroy();
}
if (chartData.length === 0) {
document.getElementById('games-table').innerHTML =
@@ -718,6 +754,61 @@ HTML_TEMPLATE = """<!DOCTYPE html>
}
});
// Categories chart data
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: colors.slice(0, categoriesChartData.length),
borderColor: '#FFFFFF',
borderWidth: 1
}]
},
options: {
responsive: true,
plugins: {
legend: {
position: 'bottom',
labels: {
color: '#000000',
font: {
family: "'Charcoal', 'Chicago', Geneva, sans-serif",
size: 10
},
padding: 10
}
},
tooltip: {
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) => {
@@ -772,17 +863,64 @@ HTML_TEMPLATE = """<!DOCTYPE html>
if (categoriesData.length === 0) {
catTbody.innerHTML = '<tr><td colspan="4" class="no-data">No categories found</td></tr>';
} else {
categoriesData.forEach((cat, index) => {
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 = `
<td>${index + 1}</td>
<td>${cat.name} <span class="service-badge">${cat.gameCount} games</span></td>
<td>
<span class="color-box" style="background: ${colors[index]}"></span>
${cat.name} <span class="service-badge">${cat.gameCount} games</span>
</td>
<td class="time">${formatTime(cat.playtime)}</td>
<td class="percent">${percent}%</td>
`;
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 = `
<td>${othersIndex + 1}</td>
<td>
<span class="color-box" style="background: ${colors[othersIndex]}"></span>
Others (${otherCategories.length} categories)
</td>
<td class="time">${formatTime(othersPlaytime)}</td>
<td class="percent">${othersPercent}%</td>
`;
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 = `
<td>${othersIndex + 1}.${otherIndex + 1}</td>
<td>
${otherCat.name} <span class="service-badge">${otherCat.gameCount} games</span>
</td>
<td class="time">${formatTime(otherCat.playtime)}</td>
<td class="percent">${otherPercent}%</td>
`;
catTbody.appendChild(detailRow);
detailRows.push(detailRow);
});
othersRow.addEventListener('click', () => {
othersRow.classList.toggle('expanded');
detailRows.forEach(dr => dr.classList.toggle('visible'));
});
}
}
}