Files
Lutris-Playtime-Report-Gene…/templates/neumorphism.html
Miguel Astor 3a700c4f48 Add category badges to game tables in all templates
Display game categories as colored badges after service badges in both
top games and others sections. Categories filtered to exclude .hidden,
favorite, and Horny.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-26 17:07:28 -04:00

1225 lines
42 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Lutris Playtime Report</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<style>
:root {
--bg-base: #e0e5ec;
--bg-primary: #e0e5ec;
--bg-secondary: #e0e5ec;
--bg-tertiary: #d1d9e6;
--text-primary: #2d3436;
--text-secondary: #4a5568;
--text-muted: #718096;
--accent-color: #6366f1;
--accent-hover: #4f46e5;
--selection-bg: rgba(99, 102, 241, 0.15);
--shadow-light: rgba(255, 255, 255, 0.8);
--shadow-dark: rgba(163, 177, 198, 0.6);
--shadow-inset-light: rgba(255, 255, 255, 0.7);
--shadow-inset-dark: rgba(163, 177, 198, 0.5);
--card-radius: 20px;
--border-color: transparent;
}
[data-theme="dark"] {
--bg-base: #2d3436;
--bg-primary: #2d3436;
--bg-secondary: #2d3436;
--bg-tertiary: #353b3d;
--text-primary: #f0f0f5;
--text-secondary: #b0b8c0;
--text-muted: #8090a0;
--accent-color: #818cf8;
--accent-hover: #6366f1;
--selection-bg: rgba(129, 140, 248, 0.2);
--shadow-light: rgba(255, 255, 255, 0.05);
--shadow-dark: rgba(0, 0, 0, 0.4);
--shadow-inset-light: rgba(255, 255, 255, 0.03);
--shadow-inset-dark: rgba(0, 0, 0, 0.3);
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg-base: #2d3436;
--bg-primary: #2d3436;
--bg-secondary: #2d3436;
--bg-tertiary: #353b3d;
--text-primary: #f0f0f5;
--text-secondary: #b0b8c0;
--text-muted: #8090a0;
--accent-color: #818cf8;
--accent-hover: #6366f1;
--selection-bg: rgba(129, 140, 248, 0.2);
--shadow-light: rgba(255, 255, 255, 0.05);
--shadow-dark: rgba(0, 0, 0, 0.4);
--shadow-inset-light: rgba(255, 255, 255, 0.03);
--shadow-inset-dark: rgba(0, 0, 0, 0.3);
}
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
font-size: 14px;
max-width: 1200px;
margin: 0 auto;
padding: 20px;
background-color: var(--bg-base);
min-height: 100vh;
color: var(--text-primary);
line-height: 1.5;
}
/* Theme Toggle Button */
.theme-toggle {
position: fixed;
top: 20px;
right: 20px;
z-index: 1000;
width: 48px;
height: 48px;
border-radius: 50%;
border: none;
background: var(--bg-primary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
box-shadow:
6px 6px 12px var(--shadow-dark),
-6px -6px 12px var(--shadow-light);
transition: all 0.3s ease;
}
.theme-toggle:hover {
transform: scale(1.05);
}
.theme-toggle:active {
box-shadow:
inset 4px 4px 8px var(--shadow-inset-dark),
inset -4px -4px 8px var(--shadow-inset-light);
}
.theme-toggle .icon {
transition: transform 0.3s ease;
}
.theme-toggle .icon-auto {
position: relative;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
}
.theme-toggle .icon-auto::before,
.theme-toggle .icon-auto::after {
position: absolute;
font-size: 20px;
line-height: 1;
}
.theme-toggle .icon-auto::before {
content: '☀️';
clip-path: inset(0 50% 0 0);
}
.theme-toggle .icon-auto::after {
content: '🌙';
clip-path: inset(0 0 0 50%);
}
/* Neumorphic Card Style */
.neu-card {
background: var(--bg-primary);
border-radius: var(--card-radius);
box-shadow:
8px 8px 16px var(--shadow-dark),
-8px -8px 16px var(--shadow-light);
margin-bottom: 24px;
overflow: hidden;
transition: all 0.3s ease;
}
.card-header {
padding: 16px 20px;
background: var(--bg-tertiary);
border-radius: var(--card-radius) var(--card-radius) 0 0;
}
.card-title {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
}
.card-content {
padding: 20px;
}
/* Filters */
.filters {
display: flex;
justify-content: center;
gap: 16px;
flex-wrap: wrap;
}
.filter-label {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
user-select: none;
padding: 10px 18px;
border-radius: 30px;
background: var(--bg-secondary);
box-shadow:
4px 4px 8px var(--shadow-dark),
-4px -4px 8px var(--shadow-light);
transition: all 0.2s ease;
}
.filter-label:hover {
transform: translateY(-2px);
}
.filter-label:active {
box-shadow:
inset 3px 3px 6px var(--shadow-inset-dark),
inset -3px -3px 6px var(--shadow-inset-light);
}
.filter-label input[type="checkbox"] {
appearance: none;
-webkit-appearance: none;
width: 20px;
height: 20px;
border-radius: 6px;
background: var(--bg-secondary);
cursor: pointer;
position: relative;
transition: all 0.2s ease;
box-shadow:
inset 2px 2px 4px var(--shadow-inset-dark),
inset -2px -2px 4px var(--shadow-inset-light);
}
.filter-label input[type="checkbox"]:checked {
background: var(--accent-color);
box-shadow:
inset 2px 2px 4px rgba(0, 0, 0, 0.2),
inset -2px -2px 4px rgba(255, 255, 255, 0.1);
}
.filter-label input[type="checkbox"]:checked::after {
content: '';
position: absolute;
left: 6px;
top: 3px;
width: 5px;
height: 9px;
border: solid white;
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
.filter-label .service-name {
text-transform: capitalize;
font-weight: 500;
color: var(--text-primary);
}
.filter-label .service-count {
color: var(--text-muted);
font-size: 12px;
}
/* Stats */
.stats {
display: flex;
justify-content: center;
gap: 24px;
flex-wrap: wrap;
}
.stat {
text-align: center;
padding: 24px 36px;
background: var(--bg-secondary);
border-radius: 16px;
min-width: 140px;
box-shadow:
6px 6px 12px var(--shadow-dark),
-6px -6px 12px var(--shadow-light);
}
.stat-value {
font-size: 28px;
font-weight: 700;
color: var(--accent-color);
font-variant-numeric: tabular-nums;
}
.stat-label {
font-size: 12px;
color: var(--text-muted);
margin-top: 4px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* Charts */
.charts-wrapper {
display: flex;
gap: 24px;
justify-content: center;
flex-wrap: wrap;
}
.chart-container {
flex: 1;
min-width: 280px;
max-width: 450px;
padding: 20px;
background: var(--bg-secondary);
border-radius: 16px;
box-shadow:
inset 4px 4px 8px var(--shadow-inset-dark),
inset -4px -4px 8px var(--shadow-inset-light);
}
.chart-title {
font-size: 14px;
font-weight: 600;
text-align: center;
margin-bottom: 12px;
color: var(--text-primary);
}
@media (max-width: 700px) {
.charts-wrapper {
flex-direction: column;
align-items: center;
}
.chart-container {
max-width: 100%;
width: 100%;
}
}
/* Tabs */
.tabs {
display: flex;
gap: 8px;
padding: 0 20px;
margin-bottom: -1px;
position: relative;
z-index: 1;
}
.tab {
padding: 12px 24px;
cursor: pointer;
color: var(--text-secondary);
font-weight: 500;
border-radius: 16px 16px 0 0;
background: var(--bg-tertiary);
transition: all 0.2s ease;
box-shadow:
4px -4px 8px var(--shadow-dark),
-4px -4px 8px var(--shadow-light);
}
.tab:hover {
color: var(--text-primary);
}
.tab.active {
color: var(--accent-color);
background: var(--bg-secondary);
box-shadow:
inset 2px 2px 4px var(--shadow-inset-dark),
inset -2px -2px 4px var(--shadow-inset-light);
}
.tab-content {
background: var(--bg-secondary);
border-radius: 0 16px 16px 16px;
padding: 16px;
box-shadow:
inset 4px 4px 8px var(--shadow-inset-dark),
inset -4px -4px 8px var(--shadow-inset-light);
}
.tab-panel {
display: none;
}
.tab-panel.active {
display: block;
}
/* Tables */
.table-wrapper {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
th {
padding: 12px 16px;
text-align: left;
font-weight: 600;
color: var(--text-secondary);
border-bottom: 2px solid var(--bg-tertiary);
text-transform: uppercase;
font-size: 11px;
letter-spacing: 0.5px;
}
td {
padding: 12px 16px;
border-bottom: 1px solid var(--bg-tertiary);
color: var(--text-primary);
}
tr:hover td {
background: var(--selection-bg);
}
tr:last-child td {
border-bottom: none;
}
.time {
font-variant-numeric: tabular-nums;
font-weight: 500;
}
.percent {
font-variant-numeric: tabular-nums;
text-align: right;
color: var(--text-muted);
}
.color-box {
display: inline-block;
width: 12px;
height: 12px;
margin-right: 8px;
vertical-align: middle;
border-radius: 4px;
box-shadow:
2px 2px 4px var(--shadow-dark),
-2px -2px 4px var(--shadow-light);
}
.service-badge {
display: inline-block;
font-size: 10px;
padding: 3px 10px;
background: var(--bg-tertiary);
border-radius: 12px;
color: var(--text-muted);
margin-left: 8px;
text-transform: capitalize;
font-weight: 500;
box-shadow:
inset 1px 1px 2px var(--shadow-inset-dark),
inset -1px -1px 2px var(--shadow-inset-light);
}
.category-badge {
display: inline-block;
font-size: 10px;
padding: 3px 10px;
background: var(--bg-tertiary);
border-radius: 12px;
color: var(--accent-color);
margin-left: 4px;
text-transform: capitalize;
font-weight: 500;
box-shadow:
inset 1px 1px 2px var(--shadow-inset-dark),
inset -1px -1px 2px var(--shadow-inset-light);
}
.no-data {
text-align: center;
padding: 40px;
color: var(--text-muted);
}
/* Others row expansion */
.others-row {
cursor: pointer;
}
.others-row td:first-child::before {
content: '';
display: inline-block;
width: 0;
height: 0;
border-left: 6px solid var(--text-muted);
border-top: 4px solid transparent;
border-bottom: 4px solid transparent;
margin-right: 8px;
transition: transform 0.2s ease;
}
.others-row.expanded td:first-child::before {
transform: rotate(90deg);
}
.others-detail {
display: none;
background: var(--bg-tertiary);
}
.others-detail.visible {
display: table-row;
}
.others-detail td {
padding-left: 32px;
}
/* Scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--bg-tertiary);
border-radius: 4px;
}
::-webkit-scrollbar-thumb {
background: var(--text-muted);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--text-secondary);
}
/* Responsive */
@media (max-width: 600px) {
body {
padding: 12px;
}
.stats {
gap: 12px;
}
.stat {
padding: 16px 20px;
min-width: 100px;
}
.stat-value {
font-size: 22px;
}
.filter-label {
padding: 8px 14px;
font-size: 13px;
}
.tabs {
padding: 0 12px;
}
.tab {
padding: 10px 16px;
font-size: 13px;
}
.theme-toggle {
width: 40px;
height: 40px;
font-size: 16px;
}
}
/* Scroll to Top Button */
.scroll-top {
position: fixed;
bottom: 20px;
right: 20px;
z-index: 1000;
width: 48px;
height: 48px;
border-radius: 50%;
border: none;
background: var(--bg-primary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
box-shadow:
6px 6px 12px var(--shadow-dark),
-6px -6px 12px var(--shadow-light);
opacity: 0;
visibility: hidden;
transition: all 0.3s ease;
}
.scroll-top.visible {
opacity: 1;
visibility: visible;
}
.scroll-top:hover {
transform: scale(1.05);
}
.scroll-top:active {
box-shadow:
inset 4px 4px 8px var(--shadow-inset-dark),
inset -4px -4px 8px var(--shadow-inset-light);
}
.scroll-top-arrow {
width: 0;
height: 0;
border-left: 8px solid transparent;
border-right: 8px solid transparent;
border-bottom: 10px solid var(--text-primary);
}
@media (max-width: 600px) {
.scroll-top {
width: 40px;
height: 40px;
}
.scroll-top-arrow {
border-left-width: 6px;
border-right-width: 6px;
border-bottom-width: 8px;
}
}
</style>
</head>
<body>
<!-- Scroll to Top Button -->
<button class="scroll-top" id="scroll-top" title="Scroll to top">
<div class="scroll-top-arrow"></div>
</button>
<!-- Theme Toggle Button -->
<button class="theme-toggle" id="theme-toggle" title="Toggle theme">
<span class="icon" id="theme-icon"></span>
</button>
<div class="neu-card">
<div class="card-header">
<h1 class="card-title">Lutris Playtime Report</h1>
</div>
<div class="card-content">
<div class="filters" id="filters"></div>
</div>
</div>
<div class="neu-card">
<div class="card-header">
<h2 class="card-title">Statistics</h2>
</div>
<div class="card-content">
<div class="stats">
<div class="stat">
<div class="stat-value" id="total-library">__TOTAL_LIBRARY__</div>
<div class="stat-label">Games in Library</div>
</div>
<div class="stat">
<div class="stat-value" id="total-games">0</div>
<div class="stat-label">Games Played</div>
</div>
<div class="stat">
<div class="stat-value" id="total-time">0h</div>
<div class="stat-label">Total Playtime</div>
</div>
</div>
</div>
</div>
<div class="neu-card">
<div class="card-header">
<h2 class="card-title">Playtime Distribution</h2>
</div>
<div class="card-content">
<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>
<div class="neu-card">
<div class="card-header">
<h2 class="card-title">Summaries</h2>
</div>
<div class="card-content" style="padding: 0; padding-top: 16px;">
<div class="tabs">
<div class="tab active" data-tab="games">Top Games</div>
<div class="tab" data-tab="categories">By Category</div>
</div>
<div class="tab-content">
<div class="tab-panel active" id="tab-games">
<div class="table-wrapper">
<table>
<thead>
<tr>
<th>#</th>
<th>Game</th>
<th>Playtime</th>
<th style="text-align: right">%</th>
</tr>
</thead>
<tbody id="games-table"></tbody>
</table>
</div>
</div>
<div class="tab-panel" id="tab-categories">
<div class="table-wrapper">
<table>
<thead>
<tr>
<th>#</th>
<th>Category</th>
<th>Playtime</th>
<th style="text-align: right">%</th>
</tr>
</thead>
<tbody id="categories-table"></tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<script>
// 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 = '☀️';
themeIcon.className = 'icon';
themeToggle.title = 'Theme: Light (click to change)';
} else {
themeIcon.textContent = '🌙';
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__;
const colors = [
'#6366f1', '#8b5cf6', '#ec4899', '#f43f5e', '#f97316',
'#eab308', '#22c55e', '#14b8a6', '#06b6d4', '#3b82f6',
'#64748b'
];
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 = `
<input type="checkbox" value="${service.name}" checked>
<span class="service-name">${service.name}</span>
<span class="service-count">(${service.count})</span>
`;
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' || cat === 'Horny') 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);
return { chartData: topGames, othersGames, categoriesData, totalPlaytime, totalGames };
}
function getChartTextColor() {
const theme = themes[currentThemeIndex];
if (theme === 'dark') return '#f0f0f5';
if (theme === 'light') return '#2d3436';
return getSystemTheme() === 'dark' ? '#f0f0f5' : '#2d3436';
}
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, 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 =
'<tr><td colspan="4" class="no-data">No games match the selected filters</td></tr>';
document.getElementById('categories-table').innerHTML =
'<tr><td colspan="4" class="no-data">No categories found</td></tr>';
return;
}
const textColor = getChartTextColor();
chart = new Chart(ctx, {
type: 'doughnut',
data: {
labels: chartData.map(g => g.name),
datasets: [{
data: chartData.map(g => g.playtime),
backgroundColor: colors.slice(0, chartData.length),
borderColor: 'rgba(255, 255, 255, 0.3)',
borderWidth: 3
}]
},
options: {
responsive: true,
plugins: {
legend: {
position: 'bottom',
labels: {
color: textColor,
font: {
family: "'Inter', sans-serif",
size: 11
},
padding: 12,
usePointStyle: true,
pointStyle: 'circle'
}
},
tooltip: {
backgroundColor: 'rgba(0, 0, 0, 0.8)',
titleFont: { weight: 'bold' },
bodyFont: { weight: 'normal' },
cornerRadius: 8,
padding: 12,
callbacks: {
title: function(context) {
return context[0].label;
},
beforeBody: function(context) {
const index = context[0].dataIndex;
const service = chartData[index].service;
if (service && service !== 'others') {
return 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: colors.slice(0, categoriesChartData.length),
borderColor: 'rgba(255, 255, 255, 0.3)',
borderWidth: 3
}]
},
options: {
responsive: true,
plugins: {
legend: {
position: 'bottom',
labels: {
color: textColor,
font: {
family: "'Inter', sans-serif",
size: 11
},
padding: 12,
usePointStyle: true,
pointStyle: 'circle'
}
},
tooltip: {
backgroundColor: 'rgba(0, 0, 0, 0.8)',
cornerRadius: 8,
padding: 12,
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
? `<span class="service-badge">${game.service}</span>`
: '';
const categoriesBadges = !isOthers && game.categories && game.categories.length > 0
? game.categories
.filter(cat => cat !== '.hidden' && cat !== 'favorite' && cat !== 'Horny')
.map(cat => `<span class="category-badge">${cat}</span>`)
.join('')
: '';
const row = document.createElement('tr');
if (isOthers) {
row.className = 'others-row';
}
row.innerHTML = `
<td>${index + 1}</td>
<td>
<span class="color-box" style="background: ${colors[index]}"></span>
${game.name}${serviceBadge}${categoriesBadges}
</td>
<td class="time">${formatTime(game.playtime)}</td>
<td class="percent">${percent}%</td>
`;
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' && cat !== 'Horny')
.map(cat => `<span class="category-badge">${cat}</span>`)
.join('')
: '';
const detailRow = document.createElement('tr');
detailRow.className = 'others-detail';
detailRow.innerHTML = `
<td>${index + 1}.${otherIndex + 1}</td>
<td>
${otherGame.name}
<span class="service-badge">${otherGame.service}</span>${otherCategoriesBadges}
</td>
<td class="time">${formatTime(otherGame.playtime)}</td>
<td class="percent">${otherPercent}%</td>
`;
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 = '<tr><td colspan="4" class="no-data">No categories found</td></tr>';
} 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 = `
<td>${index + 1}</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'));
});
}
}
}
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'
});
});
</script>
</body>
</html>