Compare commits

...

15 Commits

Author SHA1 Message Date
Miguel Astor
aa9719cbfe Update CLAUDE.md documentation
- Document all four available templates (platinum, brutalism, glassmorphism, neumorphism)
- Fix template path to use templates/ folder
- Update theme toggle description (auto/light/dark with persistent preference)
- Add responsive design mention
- Fix filtered categories list

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-26 17:29:28 -04:00
Miguel Astor
f4e5c33c87 Improve card header visibility in brutalism template dark mode
- Keep card-header background yellow in both light and dark modes using
  --accent-tertiary variable (#ffff00 light, #ffff33 dark)
- Set card-title text to black in dark mode for optimal contrast against
  yellow background
- Remove dark mode overrides that changed header background to gray

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-26 17:19:52 -04:00
Miguel Astor
db575f2bb7 Include Horny category in category chart and table
Remove Horny from category filter to display it in the categories chart
and By Category table. Now only .hidden and favorite remain filtered.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-26 17:12:52 -04:00
Miguel Astor
3394d66151 Include Horny category in game category badges
Remove Horny from the filter list to display it in category badges.
Only .hidden and favorite categories remain filtered.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-26 17:09:01 -04:00
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
Miguel Astor
f850e4d69c Move templates and assets to templates/ folder
- Move all HTML templates to templates/
- Move Platinum assets to templates/Platinum/
- Update default paths in generate_report.py

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-26 03:21:25 -04:00
Miguel Astor
49c7a2bba8 Add brutalism template with bold industrial style
Features high-contrast colors, thick borders, hard shadows,
monospace typography, and no rounded corners. Uses custom
background placeholder that shows theme color when no image
is specified.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-26 03:18:13 -04:00
Miguel Astor
6e7f3e5e16 Add scroll-to-top button to all templates
Floating button in bottom-right corner that appears when scrolling
down and smoothly scrolls to top when clicked. Each template has
matching visual style.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-26 03:09:44 -04:00
Miguel Astor
9e49262118 Add neumorphism template with soft UI style
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-26 03:02:58 -04:00
Miguel Astor
ec9c1c5205 Add glassmorphism template with modern glass UI style
New template featuring:
- Frosted glass effect with backdrop-filter blur
- Light/dark theme with automatic system detection
- Theme toggle button (sun/moon/auto) in top right corner
- Responsive design for mobile devices
- Same data structure as platinum template

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-26 02:02:48 -04:00
Miguel Astor
d5c5703523 Update CLAUDE.md to document external template file
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-26 01:39:32 -04:00
Miguel Astor
878eaf0c4e Extract HTML template to external file with --template option
Move embedded HTML template from generate_report.py to platinum.html for
easier editing with proper syntax highlighting. Add --template argument
to allow selecting different template files.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-26 01:37:42 -04:00
Miguel Astor
3cc572a45b Add game source to tooltip in games chart
Show service name (Steam, Local, Gog, etc.) below the bold game title
when hovering over chart segments.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-25 18:53:41 -04:00
Miguel Astor
bc0a541034 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>
2026-02-25 18:42:59 -04:00
Miguel Astor
c0953edf3a Added additional fake buttons to the window titles. 2026-02-10 22:40:24 -04:00
246 changed files with 4721 additions and 761 deletions

View File

@@ -15,15 +15,24 @@ python generate_report.py
Generate report with custom options:
```bash
python generate_report.py --db pga.db --output report.html --top 10 --background background.png
python generate_report.py --db pga.db --output report.html --top 10 --background background.png --template templates/platinum.html
```
## Architecture
**Single-file generator (`generate_report.py`):**
**Report generator (`generate_report.py`):**
- Reads Lutris SQLite database (`pga.db`) containing games, categories, and playtime data
- Embeds all data (games JSON, background image as base64) directly into a self-contained HTML file
- HTML template with Chart.js doughnut chart and dynamic JavaScript filtering is embedded as a string constant (`HTML_TEMPLATE`)
- Loads HTML template from `templates/` folder (default: `templates/platinum.html`)
**HTML templates (`templates/`):**
- **platinum.html**: Mac OS 9 Platinum visual style
- **brutalism.html**: Bold industrial brutalist design with hard shadows
- **glassmorphism.html**: Modern frosted glass effect
- **neumorphism.html**: Soft 3D neumorphic style
- All templates use Chart.js doughnut charts and dynamic JavaScript filtering
- Placeholder tokens like `__ALL_GAMES__`, `__BACKGROUND_IMAGE__` are replaced at generation time
- Support light/dark mode with theme toggle button
**Database schema (`schema.py`):**
- Reference file documenting Lutris database structure
@@ -33,11 +42,12 @@ python generate_report.py --db pga.db --output report.html --top 10 --background
- Fully static, can be hosted on any web server
- Client-side filtering by service (Steam, GOG, itch.io, local)
- Expandable "Others" row in games table
- Light/dark mode support via CSS `prefers-color-scheme`
- Light/dark/auto theme toggle button with persistent preference
- Responsive design for mobile and desktop
## Key Data Relationships
- Games have a `service` field (steam, gog, itchio, humblebundle, or NULL for local)
- Games link to categories via `games_categories` join table
- Categories like `.hidden`, `favorite`, `Horny` are filtered out in the report
- Categories like `.hidden` and `favorite` are filtered out in the report display
- `playtime` is cumulative hours (REAL), not per-session data

View File

@@ -21,759 +21,14 @@ import json
import sqlite3
from pathlib import Path
HTML_TEMPLATE = """<!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>
@font-face {
font-family: 'Charcoal';
src: url('__FONT_CHARCOAL__') format('truetype');
}
@font-face {
font-family: 'Monaco';
src: url('__FONT_MONACO__') format('truetype');
}
:root {
--mac-bg: #DDDDDD;
--mac-window-bg: #DDDDDD;
--mac-border-dark: #888888;
--mac-border-light: #FFFFFF;
--mac-text: #000000;
--mac-text-muted: #555555;
--mac-selection: #316AC5;
--mac-selection-text: #FFFFFF;
}
* {
box-sizing: border-box;
}
body {
font-family: 'Charcoal', 'Chicago', Geneva, sans-serif;
font-size: 12px;
max-width: 1200px;
margin: 0 auto;
padding: 20px;
background-color: var(--mac-bg);
background-image: url('__BACKGROUND_IMAGE__');
background-repeat: repeat;
min-height: 100vh;
color: var(--mac-text);
}
h1 {
font-family: 'Charcoal', 'Chicago', Geneva, sans-serif;
text-align: center;
font-size: 14px;
font-weight: bold;
margin: 0;
color: var(--mac-text);
}
h2 {
font-family: 'Charcoal', 'Chicago', Geneva, sans-serif;
font-size: 12px;
font-weight: bold;
margin: 0 0 10px 0;
color: var(--mac-text);
}
# Directory where this script is located (for finding template.html)
SCRIPT_DIR = Path(__file__).parent
/* Mac OS 9 Window Style */
.window {
background: var(--mac-window-bg);
border-style: solid;
border-color: #000000;
border-width: 1px 2px 2px 1px;
box-shadow:
inset -1px -1px 0 var(--mac-border-dark),
inset 1px 1px 0 var(--mac-border-light);
margin-bottom: 20px;
}
.window-titlebar {
background: #BBBBBB;
background-image: url('__TITLEBAR_BG__');
background-repeat: repeat-x;
height: 22px;
display: flex;
align-items: center;
padding: 0 5px;
border-bottom: 1px solid var(--mac-border-dark);
position: relative;
}
.window-titlebar::before {
content: '';
position: absolute;
left: 25px;
right: 25px;
top: 4px;
bottom: 4px;
background-image: url('__TITLE_STRIPES__');
background-repeat: repeat-x;
background-position: center;
}
.window-close {
width: 13px;
height: 13px;
background-image: url('__CLOSE_BTN__');
background-size: contain;
background-repeat: no-repeat;
flex-shrink: 0;
position: relative;
z-index: 1;
}
.window-title {
flex: 1;
text-align: center;
font-family: 'Charcoal', 'Chicago', Geneva, sans-serif;
font-size: 12px;
font-weight: bold;
color: var(--mac-text);
position: relative;
z-index: 1;
background: #BBBBBB;
padding: 0 8px;
margin: 0 auto;
max-width: fit-content;
}
.window-content {
padding: 15px;
}
/* Filters styled as Mac checkboxes */
.filters {
display: flex;
justify-content: center;
gap: 15px;
flex-wrap: wrap;
}
.filter-label {
display: flex;
align-items: center;
gap: 4px;
cursor: pointer;
user-select: none;
font-family: 'Charcoal', 'Chicago', Geneva, sans-serif;
font-size: 12px;
}
.filter-label input[type="checkbox"] {
appearance: none;
-webkit-appearance: none;
width: 12px;
height: 12px;
background-image: url('__CHECK_OFF__');
background-size: contain;
background-repeat: no-repeat;
cursor: pointer;
margin: 0;
}
.filter-label input[type="checkbox"]:checked {
background-image: url('__CHECK_ON__');
}
.filter-label .service-name {
text-transform: capitalize;
}
.filter-label .service-count {
color: var(--mac-text-muted);
}
/* Stats in embossed boxes */
.stats {
display: flex;
justify-content: center;
gap: 30px;
flex-wrap: wrap;
}
.stat {
text-align: center;
padding: 10px 20px;
background: var(--mac-window-bg);
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), inset -1px -1px 0 var(--mac-border-light);
}
.stat-value {
font-family: 'Monaco', 'Courier New', monospace;
font-size: 18px;
font-weight: bold;
color: var(--mac-text);
}
.stat-label {
font-family: 'Charcoal', 'Chicago', Geneva, sans-serif;
font-size: 10px;
color: var(--mac-text-muted);
margin-top: 4px;
}
/* Chart container */
.chart-container {
position: relative;
max-width: 500px;
margin: 0 auto;
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);
}
/* Tables styled like Mac OS 9 lists */
table {
width: 100%;
border-collapse: collapse;
font-family: 'Charcoal', 'Chicago', Geneva, sans-serif;
font-size: 11px;
background: #FFFFFF;
border: 1px solid var(--mac-border-dark);
}
th {
background: linear-gradient(to bottom, #EEEEEE 0%, #CCCCCC 100%);
padding: 4px 8px;
text-align: left;
border-bottom: 1px solid var(--mac-border-dark);
border-right: 1px solid var(--mac-border-light);
font-weight: bold;
color: var(--mac-text);
}
th:last-child {
border-right: none;
}
td {
padding: 3px 8px;
border-bottom: 1px solid #CCCCCC;
color: var(--mac-text);
}
tr:hover td {
background: var(--mac-selection);
color: var(--mac-selection-text);
}
tr:hover .service-badge {
background: rgba(255,255,255,0.3);
color: var(--mac-selection-text);
}
.time {
font-family: 'Monaco', 'Courier New', monospace;
font-size: 10px;
}
.percent {
font-family: 'Monaco', 'Courier New', monospace;
font-size: 10px;
text-align: right;
}
.color-box {
display: inline-block;
width: 10px;
height: 10px;
margin-right: 6px;
vertical-align: middle;
border: 1px solid var(--mac-border-dark);
}
.service-badge {
display: inline-block;
font-size: 9px;
padding: 1px 4px;
background: #EEEEEE;
border: 1px solid #CCCCCC;
color: var(--mac-text-muted);
margin-left: 6px;
text-transform: capitalize;
}
.no-data {
text-align: center;
padding: 20px;
color: var(--mac-text-muted);
font-style: italic;
}
/* Others row expansion */
.others-row {
cursor: pointer;
}
.others-row td:first-child::before {
content: '';
display: inline-block;
width: 0;
height: 0;
border-left: 5px solid var(--mac-text);
border-top: 3px solid transparent;
border-bottom: 3px solid transparent;
margin-right: 5px;
transition: transform 0.15s;
}
.others-row:hover td:first-child::before {
border-left-color: var(--mac-selection-text);
}
.others-row.expanded td:first-child::before {
transform: rotate(90deg);
}
.others-detail {
display: none;
background: #F5F5F5;
}
.others-detail.visible {
display: table-row;
}
.others-detail td {
padding-left: 25px;
}
/* Mac OS 9 Tabs */
.tabs {
display: flex;
gap: 0;
margin-bottom: 0;
position: relative;
z-index: 1;
padding-left: 10px;
}
.tab {
font-family: 'Charcoal', 'Chicago', Geneva, sans-serif;
font-size: 11px;
padding: 3px 15px 2px 15px;
cursor: pointer;
color: var(--mac-text);
position: relative;
border: none;
background: transparent;
margin-right: -1px;
border-style: solid;
border-width: 10px 11px 2px 11px;
border-image: url('__TAB_INACTIVE__') 10 11 2 11 fill stretch;
}
.tab:hover {
opacity: 0.9;
}
.tab.active {
border-image: url('__TAB_ACTIVE__') 10 11 2 11 fill stretch;
font-weight: bold;
z-index: 2;
margin-bottom: -1px;
padding-bottom: 3px;
}
.tab-content {
background: #f3f3f3;
border: 1px solid #000000;
border-top: 1px solid #888888;
padding: 10px;
box-shadow: inset 1px 1px 0 #FFFFFF, inset -1px -1px 0 #888888;
}
.tab-panel {
display: none;
}
.tab-panel.active {
display: block;
}
.table-wrapper {
overflow-y: visible;
}
/* Mac OS 9 Scrollbars */
::-webkit-scrollbar {
width: 16px;
height: 16px;
}
::-webkit-scrollbar-track {
background-image: url('__SCROLLBAR_TROUGH_V__');
background-repeat: repeat-y;
background-color: #CCCCCC;
border-left: 1px solid #888888;
}
::-webkit-scrollbar-thumb {
background-image: url('__SCROLLBAR_THUMB_V__');
background-repeat: repeat-y;
background-size: contain;
border: 1px solid;
border-color: #FFFFFF #888888 #888888 #FFFFFF;
background-color: #DDDDDD;
min-height: 20px;
}
::-webkit-scrollbar-thumb:hover {
background-color: #EEEEEE;
}
::-webkit-scrollbar-thumb:active {
background-color: #CCCCCC;
}
::-webkit-scrollbar-button:vertical:start:decrement {
background-image: url('__SCROLLBAR_UP__');
background-size: contain;
background-repeat: no-repeat;
background-position: center;
background-color: #DDDDDD;
border: 1px solid;
border-color: #FFFFFF #888888 #888888 #FFFFFF;
height: 16px;
}
::-webkit-scrollbar-button:vertical:end:increment {
background-image: url('__SCROLLBAR_DOWN__');
background-size: contain;
background-repeat: no-repeat;
background-position: center;
background-color: #DDDDDD;
border: 1px solid;
border-color: #FFFFFF #888888 #888888 #FFFFFF;
height: 16px;
}
::-webkit-scrollbar-button:vertical:start:decrement:hover,
::-webkit-scrollbar-button:vertical:end:increment:hover {
background-color: #EEEEEE;
}
::-webkit-scrollbar-button:vertical:start:decrement:active,
::-webkit-scrollbar-button:vertical:end:increment:active {
background-color: #BBBBBB;
border-color: #888888 #FFFFFF #FFFFFF #888888;
}
::-webkit-scrollbar-corner {
background-color: #DDDDDD;
}
/* Firefox scrollbar */
* {
scrollbar-width: auto;
scrollbar-color: #DDDDDD #CCCCCC;
}
</style>
</head>
<body>
<div class="window">
<div class="window-titlebar">
<div class="window-close"></div>
<div class="window-title">Lutris Playtime Report</div>
</div>
<div class="window-content">
<div class="filters" id="filters"></div>
</div>
</div>
<div class="window">
<div class="window-titlebar">
<div class="window-close"></div>
<div class="window-title">Statistics</div>
</div>
<div class="window-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="window">
<div class="window-titlebar">
<div class="window-close"></div>
<div class="window-title">Playtime Distribution</div>
</div>
<div class="window-content">
<div class="chart-container">
<canvas id="playtime-chart"></canvas>
</div>
</div>
</div>
<div class="window">
<div class="window-titlebar">
<div class="window-close"></div>
<div class="window-title">Summaries</div>
</div>
<div class="window-content">
<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>
const allGames = __ALL_GAMES__;
const topN = __TOP_N__;
// Mac OS 9 inspired colors
const colors = [
'#336699', '#993366', '#669933', '#CC6633', '#663399',
'#339966', '#996633', '#336666', '#993333', '#666699',
'#888888'
];
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;
const ctx = document.getElementById('playtime-chart').getContext('2d');
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
}));
let othersGames = [];
if (filtered.length > topN) {
othersGames = filtered.slice(topN).map(g => ({
name: g.name,
playtime: g.playtime,
service: g.service
}));
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 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 (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;
}
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: '#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) => {
const percent = ((game.playtime / totalPlaytime) * 100).toFixed(1);
const isOthers = game.service === 'others';
const serviceBadge = !isOthers
? `<span class="service-badge">${game.service}</span>`
: '';
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}
</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 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>
</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 {
categoriesData.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 class="time">${formatTime(cat.playtime)}</td>
<td class="percent">${percent}%</td>
`;
catTbody.appendChild(row);
});
}
}
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');
});
});
</script>
</body>
</html>
"""
def load_template(template_file: str) -> str:
"""Load the HTML template from the specified file."""
template_path = SCRIPT_DIR / template_file
return template_path.read_text(encoding="utf-8")
def load_asset_as_base64(path: Path, mime_type: str) -> str:
@@ -827,7 +82,7 @@ def get_all_games(db_path: str) -> tuple[list[dict], int]:
return games, total_library
def generate_report(db_path: str, output_path: str, top_n: int, assets_dir: str, bg_image_path: str = None) -> None:
def generate_report(db_path: str, output_path: str, top_n: int, assets_dir: str, template_file: str, bg_image_path: str = None) -> None:
"""Generate the HTML report."""
all_games, total_library = get_all_games(db_path)
@@ -843,8 +98,10 @@ def generate_report(db_path: str, output_path: str, top_n: int, assets_dir: str,
# Load background image (custom or default stripes)
if bg_image_path and Path(bg_image_path).exists():
background_image = load_asset_as_base64(Path(bg_image_path), "image/png")
background_image_custom = f"url('{background_image}')"
else:
background_image = load_asset_as_base64(assets_path / "Others" / "stripes.png", "image/png")
background_image_custom = "none" # For templates that prefer no default background
# Load fonts
font_charcoal = load_asset_as_base64(assets_path / "Charcoal.ttf", "font/truetype")
@@ -854,6 +111,8 @@ def generate_report(db_path: str, output_path: str, top_n: int, assets_dir: str,
titlebar_bg = load_asset_as_base64(assets_path / "Windows" / "title-1-active.png", "image/png")
title_stripes = load_asset_as_base64(assets_path / "Windows" / "title-1-active.png", "image/png")
close_btn = load_asset_as_base64(assets_path / "Windows" / "close-active.png", "image/png")
hide_btn = load_asset_as_base64(assets_path / "Windows" / "maximize-active.png", "image/png")
shade_btn = load_asset_as_base64(assets_path / "Windows" / "shade-active.png", "image/png")
check_off = load_asset_as_base64(assets_path / "Check-Radio" / "check-normal.png", "image/png")
check_on = load_asset_as_base64(assets_path / "Check-Radio" / "check-active.png", "image/png")
@@ -867,15 +126,19 @@ def generate_report(db_path: str, output_path: str, top_n: int, assets_dir: str,
tab_active = load_asset_as_base64(assets_path / "Tabs" / "tab-top-active.png", "image/png")
tab_inactive = load_asset_as_base64(assets_path / "Tabs" / "tab-top.png", "image/png")
html = HTML_TEMPLATE.replace("__ALL_GAMES__", json.dumps(all_games))
html = load_template(template_file)
html = html.replace("__ALL_GAMES__", json.dumps(all_games))
html = html.replace("__TOP_N__", str(top_n))
html = html.replace("__TOTAL_LIBRARY__", str(total_library))
html = html.replace("__FONT_CHARCOAL__", font_charcoal)
html = html.replace("__FONT_MONACO__", font_monaco)
html = html.replace("__BACKGROUND_IMAGE__", background_image)
html = html.replace("__BACKGROUND_IMAGE_CUSTOM__", background_image_custom)
html = html.replace("__TITLEBAR_BG__", titlebar_bg)
html = html.replace("__TITLE_STRIPES__", title_stripes)
html = html.replace("__CLOSE_BTN__", close_btn)
html = html.replace("__HIDE_BTN__", hide_btn)
html = html.replace("__SHADE_BTN__", shade_btn)
html = html.replace("__CHECK_OFF__", check_off)
html = html.replace("__CHECK_ON__", check_on)
html = html.replace("__SCROLLBAR_TROUGH_V__", scrollbar_trough_v)
@@ -914,14 +177,19 @@ def main():
)
parser.add_argument(
"--assets",
default="Platinum",
help="Path to Platinum assets directory (default: Platinum)"
default="templates/Platinum",
help="Path to Platinum assets directory (default: templates/Platinum)"
)
parser.add_argument(
"--background",
default=None,
help="Path to background image for tiling (default: Platinum stripes pattern)"
)
parser.add_argument(
"--template",
default="templates/platinum.html",
help="HTML template file to use (default: templates/platinum.html)"
)
args = parser.parse_args()
@@ -933,7 +201,12 @@ def main():
print(f"Error: Assets directory not found: {args.assets}")
return 1
generate_report(args.db, args.output, args.top, args.assets, args.background)
template_path = SCRIPT_DIR / args.template
if not template_path.exists():
print(f"Error: Template file not found: {template_path}")
return 1
generate_report(args.db, args.output, args.top, args.assets, args.template, args.background)
return 0

View File

Before

Width:  |  Height:  |  Size: 172 B

After

Width:  |  Height:  |  Size: 172 B

View File

Before

Width:  |  Height:  |  Size: 122 B

After

Width:  |  Height:  |  Size: 122 B

View File

Before

Width:  |  Height:  |  Size: 172 B

After

Width:  |  Height:  |  Size: 172 B

View File

Before

Width:  |  Height:  |  Size: 172 B

After

Width:  |  Height:  |  Size: 172 B

View File

Before

Width:  |  Height:  |  Size: 119 B

After

Width:  |  Height:  |  Size: 119 B

View File

Before

Width:  |  Height:  |  Size: 171 B

After

Width:  |  Height:  |  Size: 171 B

View File

Before

Width:  |  Height:  |  Size: 171 B

After

Width:  |  Height:  |  Size: 171 B

View File

Before

Width:  |  Height:  |  Size: 182 B

After

Width:  |  Height:  |  Size: 182 B

View File

Before

Width:  |  Height:  |  Size: 179 B

After

Width:  |  Height:  |  Size: 179 B

View File

Before

Width:  |  Height:  |  Size: 127 B

After

Width:  |  Height:  |  Size: 127 B

View File

Before

Width:  |  Height:  |  Size: 127 B

After

Width:  |  Height:  |  Size: 127 B

View File

Before

Width:  |  Height:  |  Size: 127 B

After

Width:  |  Height:  |  Size: 127 B

View File

Before

Width:  |  Height:  |  Size: 127 B

After

Width:  |  Height:  |  Size: 127 B

View File

Before

Width:  |  Height:  |  Size: 123 B

After

Width:  |  Height:  |  Size: 123 B

View File

Before

Width:  |  Height:  |  Size: 121 B

After

Width:  |  Height:  |  Size: 121 B

View File

Before

Width:  |  Height:  |  Size: 121 B

After

Width:  |  Height:  |  Size: 121 B

View File

Before

Width:  |  Height:  |  Size: 201 B

After

Width:  |  Height:  |  Size: 201 B

View File

Before

Width:  |  Height:  |  Size: 202 B

After

Width:  |  Height:  |  Size: 202 B

View File

Before

Width:  |  Height:  |  Size: 140 B

After

Width:  |  Height:  |  Size: 140 B

View File

Before

Width:  |  Height:  |  Size: 299 B

After

Width:  |  Height:  |  Size: 299 B

View File

Before

Width:  |  Height:  |  Size: 314 B

After

Width:  |  Height:  |  Size: 314 B

View File

Before

Width:  |  Height:  |  Size: 299 B

After

Width:  |  Height:  |  Size: 299 B

View File

Before

Width:  |  Height:  |  Size: 308 B

After

Width:  |  Height:  |  Size: 308 B

View File

Before

Width:  |  Height:  |  Size: 314 B

After

Width:  |  Height:  |  Size: 314 B

View File

Before

Width:  |  Height:  |  Size: 311 B

After

Width:  |  Height:  |  Size: 311 B

View File

Before

Width:  |  Height:  |  Size: 299 B

After

Width:  |  Height:  |  Size: 299 B

View File

Before

Width:  |  Height:  |  Size: 299 B

After

Width:  |  Height:  |  Size: 299 B

View File

Before

Width:  |  Height:  |  Size: 338 B

After

Width:  |  Height:  |  Size: 338 B

View File

Before

Width:  |  Height:  |  Size: 289 B

After

Width:  |  Height:  |  Size: 289 B

View File

Before

Width:  |  Height:  |  Size: 243 B

After

Width:  |  Height:  |  Size: 243 B

View File

Before

Width:  |  Height:  |  Size: 237 B

After

Width:  |  Height:  |  Size: 237 B

View File

Before

Width:  |  Height:  |  Size: 242 B

After

Width:  |  Height:  |  Size: 242 B

View File

Before

Width:  |  Height:  |  Size: 290 B

After

Width:  |  Height:  |  Size: 290 B

View File

Before

Width:  |  Height:  |  Size: 365 B

After

Width:  |  Height:  |  Size: 365 B

View File

Before

Width:  |  Height:  |  Size: 365 B

After

Width:  |  Height:  |  Size: 365 B

View File

Before

Width:  |  Height:  |  Size: 365 B

After

Width:  |  Height:  |  Size: 365 B

View File

Before

Width:  |  Height:  |  Size: 365 B

After

Width:  |  Height:  |  Size: 365 B

View File

Before

Width:  |  Height:  |  Size: 378 B

After

Width:  |  Height:  |  Size: 378 B

View File

Before

Width:  |  Height:  |  Size: 365 B

After

Width:  |  Height:  |  Size: 365 B

View File

Before

Width:  |  Height:  |  Size: 172 B

After

Width:  |  Height:  |  Size: 172 B

View File

Before

Width:  |  Height:  |  Size: 172 B

After

Width:  |  Height:  |  Size: 172 B

View File

Before

Width:  |  Height:  |  Size: 172 B

After

Width:  |  Height:  |  Size: 172 B

View File

Before

Width:  |  Height:  |  Size: 299 B

After

Width:  |  Height:  |  Size: 299 B

View File

Before

Width:  |  Height:  |  Size: 287 B

After

Width:  |  Height:  |  Size: 287 B

View File

Before

Width:  |  Height:  |  Size: 314 B

After

Width:  |  Height:  |  Size: 314 B

View File

Before

Width:  |  Height:  |  Size: 314 B

After

Width:  |  Height:  |  Size: 314 B

View File

Before

Width:  |  Height:  |  Size: 299 B

After

Width:  |  Height:  |  Size: 299 B

View File

Before

Width:  |  Height:  |  Size: 287 B

After

Width:  |  Height:  |  Size: 287 B

View File

Before

Width:  |  Height:  |  Size: 314 B

After

Width:  |  Height:  |  Size: 314 B

View File

Before

Width:  |  Height:  |  Size: 314 B

After

Width:  |  Height:  |  Size: 314 B

View File

Before

Width:  |  Height:  |  Size: 290 B

After

Width:  |  Height:  |  Size: 290 B

View File

Before

Width:  |  Height:  |  Size: 292 B

After

Width:  |  Height:  |  Size: 292 B

View File

Before

Width:  |  Height:  |  Size: 297 B

After

Width:  |  Height:  |  Size: 297 B

View File

Before

Width:  |  Height:  |  Size: 297 B

After

Width:  |  Height:  |  Size: 297 B

View File

Before

Width:  |  Height:  |  Size: 216 B

After

Width:  |  Height:  |  Size: 216 B

View File

Before

Width:  |  Height:  |  Size: 202 B

After

Width:  |  Height:  |  Size: 202 B

View File

Before

Width:  |  Height:  |  Size: 202 B

After

Width:  |  Height:  |  Size: 202 B

View File

Before

Width:  |  Height:  |  Size: 185 B

After

Width:  |  Height:  |  Size: 185 B

View File

Before

Width:  |  Height:  |  Size: 167 B

After

Width:  |  Height:  |  Size: 167 B

View File

Before

Width:  |  Height:  |  Size: 170 B

After

Width:  |  Height:  |  Size: 170 B

View File

Before

Width:  |  Height:  |  Size: 204 B

After

Width:  |  Height:  |  Size: 204 B

View File

Before

Width:  |  Height:  |  Size: 181 B

After

Width:  |  Height:  |  Size: 181 B

View File

Before

Width:  |  Height:  |  Size: 154 B

After

Width:  |  Height:  |  Size: 154 B

View File

Before

Width:  |  Height:  |  Size: 164 B

After

Width:  |  Height:  |  Size: 164 B

View File

Before

Width:  |  Height:  |  Size: 238 B

After

Width:  |  Height:  |  Size: 238 B

View File

Before

Width:  |  Height:  |  Size: 229 B

After

Width:  |  Height:  |  Size: 229 B

View File

Before

Width:  |  Height:  |  Size: 232 B

After

Width:  |  Height:  |  Size: 232 B

View File

Before

Width:  |  Height:  |  Size: 213 B

After

Width:  |  Height:  |  Size: 213 B

View File

Before

Width:  |  Height:  |  Size: 209 B

After

Width:  |  Height:  |  Size: 209 B

View File

Before

Width:  |  Height:  |  Size: 207 B

After

Width:  |  Height:  |  Size: 207 B

View File

Before

Width:  |  Height:  |  Size: 219 B

After

Width:  |  Height:  |  Size: 219 B

View File

Before

Width:  |  Height:  |  Size: 233 B

After

Width:  |  Height:  |  Size: 233 B

View File

Before

Width:  |  Height:  |  Size: 229 B

After

Width:  |  Height:  |  Size: 229 B

View File

Before

Width:  |  Height:  |  Size: 232 B

After

Width:  |  Height:  |  Size: 232 B

View File

Before

Width:  |  Height:  |  Size: 157 B

After

Width:  |  Height:  |  Size: 157 B

View File

Before

Width:  |  Height:  |  Size: 188 B

After

Width:  |  Height:  |  Size: 188 B

View File

Before

Width:  |  Height:  |  Size: 167 B

After

Width:  |  Height:  |  Size: 167 B

View File

Before

Width:  |  Height:  |  Size: 221 B

After

Width:  |  Height:  |  Size: 221 B

View File

Before

Width:  |  Height:  |  Size: 201 B

After

Width:  |  Height:  |  Size: 201 B

View File

Before

Width:  |  Height:  |  Size: 198 B

After

Width:  |  Height:  |  Size: 198 B

View File

Before

Width:  |  Height:  |  Size: 224 B

After

Width:  |  Height:  |  Size: 224 B

View File

Before

Width:  |  Height:  |  Size: 168 B

After

Width:  |  Height:  |  Size: 168 B

View File

Before

Width:  |  Height:  |  Size: 185 B

After

Width:  |  Height:  |  Size: 185 B

View File

Before

Width:  |  Height:  |  Size: 195 B

After

Width:  |  Height:  |  Size: 195 B

View File

Before

Width:  |  Height:  |  Size: 199 B

After

Width:  |  Height:  |  Size: 199 B

View File

Before

Width:  |  Height:  |  Size: 197 B

After

Width:  |  Height:  |  Size: 197 B

View File

Before

Width:  |  Height:  |  Size: 146 B

After

Width:  |  Height:  |  Size: 146 B

View File

Before

Width:  |  Height:  |  Size: 186 B

After

Width:  |  Height:  |  Size: 186 B

View File

Before

Width:  |  Height:  |  Size: 8.7 KiB

After

Width:  |  Height:  |  Size: 8.7 KiB

View File

Before

Width:  |  Height:  |  Size: 181 B

After

Width:  |  Height:  |  Size: 181 B

View File

Before

Width:  |  Height:  |  Size: 184 B

After

Width:  |  Height:  |  Size: 184 B

View File

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

Before

Width:  |  Height:  |  Size: 178 B

After

Width:  |  Height:  |  Size: 178 B

View File

Before

Width:  |  Height:  |  Size: 210 B

After

Width:  |  Height:  |  Size: 210 B

Some files were not shown because too many files have changed in this diff Show More