Add By Runner tab to summaries section in all templates

Extract runner field from Lutris database and display playtime
grouped by runner (wine, linux, steam, dosbox, etc.) in a new
third tab alongside Top Games and By Category.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Miguel Astor
2026-03-03 03:09:29 -04:00
parent aa9719cbfe
commit 31e8d152ae
5 changed files with 390 additions and 9 deletions

View File

@@ -49,7 +49,7 @@ def get_all_games(db_path: str) -> tuple[list[dict], int]:
total_library = cursor.fetchone()[0]
cursor.execute("""
SELECT id, name, playtime, COALESCE(service, 'local') as service
SELECT id, name, playtime, COALESCE(service, 'local') as service, COALESCE(runner, 'unknown') as runner
FROM games
WHERE playtime > 0
ORDER BY playtime DESC
@@ -75,6 +75,7 @@ def get_all_games(db_path: str) -> tuple[list[dict], int]:
"name": row[1],
"playtime": row[2],
"service": row[3],
"runner": row[4],
"categories": game_categories.get(row[0], [])
}
for row in games_rows

View File

@@ -683,6 +683,7 @@
<div class="tabs">
<div class="tab active" data-tab="games">Top Games</div>
<div class="tab" data-tab="categories">By Category</div>
<div class="tab" data-tab="runners">By Runner</div>
</div>
<div class="tab-content">
<div class="tab-panel active" id="tab-games">
@@ -715,6 +716,21 @@
</table>
</div>
</div>
<div class="tab-panel" id="tab-runners">
<div class="table-wrapper">
<table>
<thead>
<tr>
<th>#</th>
<th>Runner</th>
<th>Playtime</th>
<th style="text-align: right">%</th>
</tr>
</thead>
<tbody id="runners-table"></tbody>
</table>
</div>
</div>
</div>
</div>
</div>
@@ -893,7 +909,19 @@
const categoriesData = Object.values(categoryMap)
.sort((a, b) => b.playtime - a.playtime);
return { chartData: topGames, othersGames, categoriesData, totalPlaytime, totalGames };
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() {
@@ -924,7 +952,7 @@
function updateDisplay() {
const selectedServices = getSelectedServices();
const { chartData, othersGames, categoriesData, totalPlaytime, totalGames } = getFilteredData(selectedServices);
const { chartData, othersGames, categoriesData, runnersData, totalPlaytime, totalGames } = getFilteredData(selectedServices);
document.getElementById('total-games').textContent = totalGames;
document.getElementById('total-time').textContent = formatTime(totalPlaytime);
@@ -941,6 +969,8 @@
'<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>';
document.getElementById('runners-table').innerHTML =
'<tr><td colspan="4" class="no-data">No runners found</td></tr>';
return;
}
@@ -1199,6 +1229,71 @@
});
}
}
const runnersTbody = document.getElementById('runners-table');
runnersTbody.innerHTML = '';
if (runnersData.length === 0) {
runnersTbody.innerHTML = '<tr><td colspan="4" class="no-data">No runners found</td></tr>';
} 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 = `
<td>${index + 1}</td>
<td>
<span class="color-box" style="background: ${colors[index]}"></span>
${runner.name} <span class="service-badge">${runner.gameCount} games</span>
</td>
<td class="time">${formatTime(runner.playtime)}</td>
<td class="percent">${percent}%</td>
`;
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 = `
<td>${othersIndex + 1}</td>
<td>
<span class="color-box" style="background: ${colors[othersIndex]}"></span>
Others (${otherRunners.length} runners)
</td>
<td class="time">${formatTime(othersPlaytime)}</td>
<td class="percent">${othersPercent}%</td>
`;
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 = `
<td>${othersIndex + 1}.${otherIndex + 1}</td>
<td>
${otherRunner.name} <span class="service-badge">${otherRunner.gameCount} games</span>
</td>
<td class="time">${formatTime(otherRunner.playtime)}</td>
<td class="percent">${otherPercent}%</td>
`;
runnersTbody.appendChild(detailRow);
detailRows.push(detailRow);
});
othersRow.addEventListener('click', () => {
othersRow.classList.toggle('expanded');
detailRows.forEach(dr => dr.classList.toggle('visible'));
});
}
}
}
filtersDiv.addEventListener('change', updateDisplay);

View File

@@ -648,6 +648,7 @@
<div class="tabs">
<div class="tab active" data-tab="games">Top Games</div>
<div class="tab" data-tab="categories">By Category</div>
<div class="tab" data-tab="runners">By Runner</div>
</div>
<div class="tab-content">
<div class="tab-panel active" id="tab-games">
@@ -680,6 +681,21 @@
</table>
</div>
</div>
<div class="tab-panel" id="tab-runners">
<div class="table-wrapper">
<table>
<thead>
<tr>
<th>#</th>
<th>Runner</th>
<th>Playtime</th>
<th style="text-align: right">%</th>
</tr>
</thead>
<tbody id="runners-table"></tbody>
</table>
</div>
</div>
</div>
</div>
</div>
@@ -857,7 +873,19 @@
const categoriesData = Object.values(categoryMap)
.sort((a, b) => b.playtime - a.playtime);
return { chartData: topGames, othersGames, categoriesData, totalPlaytime, totalGames };
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() {
@@ -881,7 +909,7 @@
function updateDisplay() {
const selectedServices = getSelectedServices();
const { chartData, othersGames, categoriesData, totalPlaytime, totalGames } = getFilteredData(selectedServices);
const { chartData, othersGames, categoriesData, runnersData, totalPlaytime, totalGames } = getFilteredData(selectedServices);
document.getElementById('total-games').textContent = totalGames;
document.getElementById('total-time').textContent = formatTime(totalPlaytime);
@@ -898,6 +926,8 @@
'<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>';
document.getElementById('runners-table').innerHTML =
'<tr><td colspan="4" class="no-data">No runners found</td></tr>';
return;
}
@@ -1143,6 +1173,71 @@
});
}
}
const runnersTbody = document.getElementById('runners-table');
runnersTbody.innerHTML = '';
if (runnersData.length === 0) {
runnersTbody.innerHTML = '<tr><td colspan="4" class="no-data">No runners found</td></tr>';
} 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 = `
<td>${index + 1}</td>
<td>
<span class="color-box" style="background: ${colors[index]}"></span>
${runner.name} <span class="service-badge">${runner.gameCount} games</span>
</td>
<td class="time">${formatTime(runner.playtime)}</td>
<td class="percent">${percent}%</td>
`;
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 = `
<td>${othersIndex + 1}</td>
<td>
<span class="color-box" style="background: ${colors[othersIndex]}"></span>
Others (${otherRunners.length} runners)
</td>
<td class="time">${formatTime(othersPlaytime)}</td>
<td class="percent">${othersPercent}%</td>
`;
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 = `
<td>${othersIndex + 1}.${otherIndex + 1}</td>
<td>
${otherRunner.name} <span class="service-badge">${otherRunner.gameCount} games</span>
</td>
<td class="time">${formatTime(otherRunner.playtime)}</td>
<td class="percent">${otherPercent}%</td>
`;
runnersTbody.appendChild(detailRow);
detailRows.push(detailRow);
});
othersRow.addEventListener('click', () => {
othersRow.classList.toggle('expanded');
detailRows.forEach(dr => dr.classList.toggle('visible'));
});
}
}
}
filtersDiv.addEventListener('change', updateDisplay);

View File

@@ -688,6 +688,7 @@
<div class="tabs">
<div class="tab active" data-tab="games">Top Games</div>
<div class="tab" data-tab="categories">By Category</div>
<div class="tab" data-tab="runners">By Runner</div>
</div>
<div class="tab-content">
<div class="tab-panel active" id="tab-games">
@@ -720,6 +721,21 @@
</table>
</div>
</div>
<div class="tab-panel" id="tab-runners">
<div class="table-wrapper">
<table>
<thead>
<tr>
<th>#</th>
<th>Runner</th>
<th>Playtime</th>
<th style="text-align: right">%</th>
</tr>
</thead>
<tbody id="runners-table"></tbody>
</table>
</div>
</div>
</div>
</div>
</div>
@@ -897,7 +913,19 @@
const categoriesData = Object.values(categoryMap)
.sort((a, b) => b.playtime - a.playtime);
return { chartData: topGames, othersGames, categoriesData, totalPlaytime, totalGames };
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() {
@@ -921,7 +949,7 @@
function updateDisplay() {
const selectedServices = getSelectedServices();
const { chartData, othersGames, categoriesData, totalPlaytime, totalGames } = getFilteredData(selectedServices);
const { chartData, othersGames, categoriesData, runnersData, totalPlaytime, totalGames } = getFilteredData(selectedServices);
document.getElementById('total-games').textContent = totalGames;
document.getElementById('total-time').textContent = formatTime(totalPlaytime);
@@ -938,6 +966,8 @@
'<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>';
document.getElementById('runners-table').innerHTML =
'<tr><td colspan="4" class="no-data">No runners found</td></tr>';
return;
}
@@ -1183,6 +1213,71 @@
});
}
}
const runnersTbody = document.getElementById('runners-table');
runnersTbody.innerHTML = '';
if (runnersData.length === 0) {
runnersTbody.innerHTML = '<tr><td colspan="4" class="no-data">No runners found</td></tr>';
} 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 = `
<td>${index + 1}</td>
<td>
<span class="color-box" style="background: ${colors[index]}"></span>
${runner.name} <span class="service-badge">${runner.gameCount} games</span>
</td>
<td class="time">${formatTime(runner.playtime)}</td>
<td class="percent">${percent}%</td>
`;
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 = `
<td>${othersIndex + 1}</td>
<td>
<span class="color-box" style="background: ${colors[othersIndex]}"></span>
Others (${otherRunners.length} runners)
</td>
<td class="time">${formatTime(othersPlaytime)}</td>
<td class="percent">${othersPercent}%</td>
`;
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 = `
<td>${othersIndex + 1}.${otherIndex + 1}</td>
<td>
${otherRunner.name} <span class="service-badge">${otherRunner.gameCount} games</span>
</td>
<td class="time">${formatTime(otherRunner.playtime)}</td>
<td class="percent">${otherPercent}%</td>
`;
runnersTbody.appendChild(detailRow);
detailRows.push(detailRow);
});
othersRow.addEventListener('click', () => {
othersRow.classList.toggle('expanded');
detailRows.forEach(dr => dr.classList.toggle('visible'));
});
}
}
}
filtersDiv.addEventListener('change', updateDisplay);

View File

@@ -582,6 +582,7 @@
<div class="tabs">
<div class="tab active" data-tab="games">Top Games</div>
<div class="tab" data-tab="categories">By Category</div>
<div class="tab" data-tab="runners">By Runner</div>
</div>
<div class="tab-content">
<div class="tab-panel active" id="tab-games">
@@ -614,6 +615,21 @@
</table>
</div>
</div>
<div class="tab-panel" id="tab-runners">
<div class="table-wrapper">
<table>
<thead>
<tr>
<th>#</th>
<th>Runner</th>
<th>Playtime</th>
<th style="text-align: right">%</th>
</tr>
</thead>
<tbody id="runners-table"></tbody>
</table>
</div>
</div>
</div>
</div>
</div>
@@ -728,12 +744,24 @@
const categoriesData = Object.values(categoryMap)
.sort((a, b) => b.playtime - a.playtime);
return { chartData: topGames, othersGames, categoriesData, totalPlaytime, totalGames };
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 updateDisplay() {
const selectedServices = getSelectedServices();
const { chartData, othersGames, categoriesData, totalPlaytime, totalGames } = getFilteredData(selectedServices);
const { chartData, othersGames, categoriesData, runnersData, totalPlaytime, totalGames } = getFilteredData(selectedServices);
document.getElementById('total-games').textContent = totalGames;
document.getElementById('total-time').textContent = formatTime(totalPlaytime);
@@ -750,6 +778,8 @@
'<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>';
document.getElementById('runners-table').innerHTML =
'<tr><td colspan="4" class="no-data">No runners found</td></tr>';
return;
}
@@ -988,6 +1018,71 @@
});
}
}
const runnersTbody = document.getElementById('runners-table');
runnersTbody.innerHTML = '';
if (runnersData.length === 0) {
runnersTbody.innerHTML = '<tr><td colspan="4" class="no-data">No runners found</td></tr>';
} 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 = `
<td>${index + 1}</td>
<td>
<span class="color-box" style="background: ${colors[index]}"></span>
${runner.name} <span class="service-badge">${runner.gameCount} games</span>
</td>
<td class="time">${formatTime(runner.playtime)}</td>
<td class="percent">${percent}%</td>
`;
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 = `
<td>${othersIndex + 1}</td>
<td>
<span class="color-box" style="background: ${colors[othersIndex]}"></span>
Others (${otherRunners.length} runners)
</td>
<td class="time">${formatTime(othersPlaytime)}</td>
<td class="percent">${othersPercent}%</td>
`;
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 = `
<td>${othersIndex + 1}.${otherIndex + 1}</td>
<td>
${otherRunner.name} <span class="service-badge">${otherRunner.gameCount} games</span>
</td>
<td class="time">${formatTime(otherRunner.playtime)}</td>
<td class="percent">${otherPercent}%</td>
`;
runnersTbody.appendChild(detailRow);
detailRows.push(detailRow);
});
othersRow.addEventListener('click', () => {
othersRow.classList.toggle('expanded');
detailRows.forEach(dr => dr.classList.toggle('visible'));
});
}
}
}
filtersDiv.addEventListener('change', updateDisplay);