Add By Runner and By Source charts to playtime reports

- Add runners and sources charts to modern and platinum templates
- Update responsive layout to use grid for multiple charts
- Update .gitignore to exclude pga.db
This commit is contained in:
Miguel Astor
2026-03-06 06:19:06 -04:00
parent afd11fba3a
commit c0c25e2719
7 changed files with 333 additions and 17 deletions

1
.gitignore vendored
View File

@@ -259,3 +259,4 @@ flycheck_*.el
# Built Visual Studio Code Extensions
*.vsix
pga.db

View File

@@ -286,14 +286,15 @@ body {
/* Charts */
.charts-wrapper {
display: flex;
gap: 20px;
display: grid;
grid-auto-columns: minmax(280px, 450px);
grid-template-columns: repeat(auto-fill, minmax(280px, 450px));
grid-gap: 24px;
justify-content: center;
flex-wrap: wrap;
}
.chart-container {
flex: 1;
min-width: 280px;
max-width: 450px;
padding: 16px;

View File

@@ -274,14 +274,14 @@ body {
/* Charts */
.charts-wrapper {
display: flex;
gap: 24px;
display: grid;
grid-auto-columns: minmax(280px, 450px);
grid-template-columns: repeat(auto-fill, minmax(280px, 450px));
grid-gap: 24px;
justify-content: center;
flex-wrap: wrap;
}
.chart-container {
flex: 1;
min-width: 280px;
max-width: 450px;
padding: 16px;

View File

@@ -79,6 +79,14 @@ OF THIS SOFTWARE.
<div class="chart-title">By Category</div>
<canvas id="categories-chart"></canvas>
</div>
<div class="chart-container">
<div class="chart-title">By Runner</div>
<canvas id="runners-chart"></canvas>
</div>
<div class="chart-container">
<div class="chart-title">By Source</div>
<canvas id="sources-chart"></canvas>
</div>
</div>
</div>
</div>

View File

@@ -294,14 +294,15 @@ body {
/* Charts */
.charts-wrapper {
display: flex;
gap: 24px;
display: grid;
grid-auto-columns: minmax(280px, 450px);
grid-template-columns: repeat(auto-fill, minmax(280px, 450px));
grid-gap: 24px;
justify-content: center;
flex-wrap: wrap;
}
.chart-container {
flex: 1;
min-width: 280px;
max-width: 450px;
padding: 20px;

View File

@@ -217,14 +217,15 @@ OF THIS SOFTWARE.
/* Chart containers */
.charts-wrapper {
display: flex;
gap: 20px;
display: grid;
grid-auto-columns: minmax(280px, 450px);
grid-template-columns: repeat(auto-fill, minmax(280px, 450px));
grid-gap: 20px;
justify-content: center;
flex-wrap: wrap;
}
.chart-container {
position: relative;
flex: 1;
min-width: 280px;
max-width: 450px;
padding: 10px;
@@ -580,6 +581,14 @@ OF THIS SOFTWARE.
<div class="chart-title">By Category</div>
<canvas id="categories-chart"></canvas>
</div>
<div class="chart-container">
<div class="chart-title">By Runner</div>
<canvas id="runners-chart"></canvas>
</div>
<div class="chart-container">
<div class="chart-title">By Source</div>
<canvas id="sources-chart"></canvas>
</div>
</div>
</div>
</div>
@@ -694,8 +703,12 @@ OF THIS SOFTWARE.
let chart = null;
let categoriesChart = null;
let runnersChart = null;
let sourcesChart = null;
const ctx = document.getElementById('playtime-chart').getContext('2d');
const ctxCategories = document.getElementById('categories-chart').getContext('2d');
const ctxRunners = document.getElementById('runners-chart').getContext('2d');
const ctxSources = document.getElementById('sources-chart').getContext('2d');
function getSelectedServices() {
const checkboxes = filtersDiv.querySelectorAll('input[type="checkbox"]');
@@ -769,12 +782,24 @@ OF THIS SOFTWARE.
const runnersData = Object.values(runnerMap)
.sort((a, b) => b.playtime - a.playtime);
return { chartData: topGames, othersGames, categoriesData, runnersData, totalPlaytime, totalGames };
const sourceMap = {};
filtered.forEach(g => {
const source = g.service || 'unknown';
if (!sourceMap[source]) {
sourceMap[source] = { name: source, playtime: 0, gameCount: 0 };
}
sourceMap[source].playtime += g.playtime;
sourceMap[source].gameCount++;
});
const sourcesData = Object.values(sourceMap)
.sort((a, b) => b.playtime - a.playtime);
return { chartData: topGames, othersGames, categoriesData, runnersData, sourcesData, totalPlaytime, totalGames };
}
function updateDisplay() {
const selectedServices = getSelectedServices();
const { chartData, othersGames, categoriesData, runnersData, totalPlaytime, totalGames } = getFilteredData(selectedServices);
const { chartData, othersGames, categoriesData, runnersData, sourcesData, totalPlaytime, totalGames } = getFilteredData(selectedServices);
document.getElementById('total-games').textContent = totalGames;
document.getElementById('total-time').textContent = formatTime(totalPlaytime);
@@ -785,6 +810,12 @@ OF THIS SOFTWARE.
if (categoriesChart) {
categoriesChart.destroy();
}
if (runnersChart) {
runnersChart.destroy();
}
if (sourcesChart) {
sourcesChart.destroy();
}
if (chartData.length === 0) {
document.getElementById('games-table').innerHTML =
@@ -906,6 +937,116 @@ OF THIS SOFTWARE.
});
}
// Runners Chart
const topRunnersChart = runnersData.slice(0, topN);
const otherRunnersChart = runnersData.slice(topN);
const runnersChartData = topRunnersChart.map(r => ({
name: r.name,
playtime: r.playtime
}));
if (otherRunnersChart.length > 0) {
const othersPlaytime = otherRunnersChart.reduce((sum, r) => sum + r.playtime, 0);
runnersChartData.push({
name: `Others (${otherRunnersChart.length} runners)`,
playtime: othersPlaytime
});
}
if (runnersChartData.length > 0) {
runnersChart = new Chart(ctxRunners, {
type: 'doughnut',
data: {
labels: runnersChartData.map(r => r.name),
datasets: [{
data: runnersChartData.map(r => r.playtime),
backgroundColor: colors.slice(0, runnersChartData.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 + '%)';
}
}
}
}
}
});
}
// Sources Chart
const topSourcesChart = sourcesData.slice(0, topN);
const otherSourcesChart = sourcesData.slice(topN);
const sourcesChartData = topSourcesChart.map(s => ({
name: s.name,
playtime: s.playtime
}));
if (otherSourcesChart.length > 0) {
const othersPlaytime = otherSourcesChart.reduce((sum, s) => sum + s.playtime, 0);
sourcesChartData.push({
name: `Others (${otherSourcesChart.length} sources)`,
playtime: othersPlaytime
});
}
if (sourcesChartData.length > 0) {
sourcesChart = new Chart(ctxSources, {
type: 'doughnut',
data: {
labels: sourcesChartData.map(s => s.name),
datasets: [{
data: sourcesChartData.map(s => s.playtime),
backgroundColor: colors.slice(0, sourcesChartData.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) => {

View File

@@ -115,8 +115,12 @@ services.forEach(service => {
let chart = null;
let categoriesChart = null;
let runnersChart = null;
let sourcesChart = null;
const ctx = document.getElementById('playtime-chart').getContext('2d');
const ctxCategories = document.getElementById('categories-chart').getContext('2d');
const ctxRunners = document.getElementById('runners-chart').getContext('2d');
const ctxSources = document.getElementById('sources-chart').getContext('2d');
// Initialize theme after chart variables are declared
loadSavedTheme();
@@ -193,7 +197,19 @@ function getFilteredData(selectedServices) {
const runnersData = Object.values(runnerMap)
.sort((a, b) => b.playtime - a.playtime);
return { chartData: topGames, othersGames, categoriesData, runnersData, totalPlaytime, totalGames };
const sourceMap = {};
filtered.forEach(g => {
const source = g.service || 'unknown';
if (!sourceMap[source]) {
sourceMap[source] = { name: source, playtime: 0, gameCount: 0 };
}
sourceMap[source].playtime += g.playtime;
sourceMap[source].gameCount++;
});
const sourcesData = Object.values(sourceMap)
.sort((a, b) => b.playtime - a.playtime);
return { chartData: topGames, othersGames, categoriesData, runnersData, sourcesData, totalPlaytime, totalGames };
}
function getChartTextColor() {
@@ -220,11 +236,19 @@ function updateChartColors() {
categoriesChart.options.plugins.legend.labels.color = textColor;
categoriesChart.update();
}
if (typeof runnersChart !== 'undefined' && runnersChart) {
runnersChart.options.plugins.legend.labels.color = textColor;
runnersChart.update();
}
if (typeof sourcesChart !== 'undefined' && sourcesChart) {
sourcesChart.options.plugins.legend.labels.color = textColor;
sourcesChart.update();
}
}
function updateDisplay() {
const selectedServices = getSelectedServices();
const { chartData, othersGames, categoriesData, runnersData, totalPlaytime, totalGames } = getFilteredData(selectedServices);
const { chartData, othersGames, categoriesData, runnersData, sourcesData, totalPlaytime, totalGames } = getFilteredData(selectedServices);
document.getElementById('total-games').textContent = totalGames;
document.getElementById('total-time').textContent = formatTime(totalPlaytime);
@@ -235,6 +259,12 @@ function updateDisplay() {
if (categoriesChart) {
categoriesChart.destroy();
}
if (runnersChart) {
runnersChart.destroy();
}
if (sourcesChart) {
sourcesChart.destroy();
}
if (chartData.length === 0) {
document.getElementById('games-table').innerHTML =
@@ -376,6 +406,140 @@ function updateDisplay() {
});
}
// Runners Chart
const topRunnersChart = runnersData.slice(0, topN);
const otherRunnersChart = runnersData.slice(topN);
const runnersChartData = topRunnersChart.map(r => ({
name: r.name,
playtime: r.playtime
}));
if (otherRunnersChart.length > 0) {
const othersPlaytime = otherRunnersChart.reduce((sum, r) => sum + r.playtime, 0);
runnersChartData.push({
name: `Others (${otherRunnersChart.length} runners)`,
playtime: othersPlaytime
});
}
if (runnersChartData.length > 0) {
runnersChart = new Chart(ctxRunners, {
type: 'doughnut',
data: {
labels: runnersChartData.map(r => r.name),
datasets: [{
data: runnersChartData.map(r => r.playtime),
backgroundColor: themeConfig.colors.slice(0, runnersChartData.length),
borderColor: borderColor,
borderWidth: themeConfig.borderWidth
}]
},
options: {
responsive: true,
plugins: {
legend: {
position: 'bottom',
labels: {
color: textColor,
font: {
family: themeConfig.fontFamily,
size: 11,
weight: themeConfig.fontWeight
},
padding: 12,
usePointStyle: true,
pointStyle: themeConfig.pointStyle
}
},
tooltip: {
backgroundColor: themeConfig.tooltipBg,
titleColor: themeConfig.tooltipTitleColor,
bodyColor: themeConfig.tooltipBodyColor,
borderColor: themeConfig.tooltipBorderColor,
borderWidth: themeConfig.tooltipBorderWidth,
cornerRadius: themeConfig.tooltipCornerRadius,
padding: 12,
titleFont: { family: themeConfig.fontFamily },
bodyFont: { family: themeConfig.fontFamily },
callbacks: {
label: function(context) {
const value = context.raw;
const percent = ((value / totalPlaytime) * 100).toFixed(1);
return ' ' + formatTime(value) + ' (' + percent + '%)';
}
}
}
}
}
});
}
// Sources Chart
const topSourcesChart = sourcesData.slice(0, topN);
const otherSourcesChart = sourcesData.slice(topN);
const sourcesChartData = topSourcesChart.map(s => ({
name: s.name,
playtime: s.playtime
}));
if (otherSourcesChart.length > 0) {
const othersPlaytime = otherSourcesChart.reduce((sum, s) => sum + s.playtime, 0);
sourcesChartData.push({
name: `Others (${otherSourcesChart.length} sources)`,
playtime: othersPlaytime
});
}
if (sourcesChartData.length > 0) {
sourcesChart = new Chart(ctxSources, {
type: 'doughnut',
data: {
labels: sourcesChartData.map(s => s.name),
datasets: [{
data: sourcesChartData.map(s => s.playtime),
backgroundColor: themeConfig.colors.slice(0, sourcesChartData.length),
borderColor: borderColor,
borderWidth: themeConfig.borderWidth
}]
},
options: {
responsive: true,
plugins: {
legend: {
position: 'bottom',
labels: {
color: textColor,
font: {
family: themeConfig.fontFamily,
size: 11,
weight: themeConfig.fontWeight
},
padding: 12,
usePointStyle: true,
pointStyle: themeConfig.pointStyle
}
},
tooltip: {
backgroundColor: themeConfig.tooltipBg,
titleColor: themeConfig.tooltipTitleColor,
bodyColor: themeConfig.tooltipBodyColor,
borderColor: themeConfig.tooltipBorderColor,
borderWidth: themeConfig.tooltipBorderWidth,
cornerRadius: themeConfig.tooltipCornerRadius,
padding: 12,
titleFont: { family: themeConfig.fontFamily },
bodyFont: { family: themeConfig.fontFamily },
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) => {