GameSentenceMiner 2.15.11__py3-none-any.whl → 2.15.12__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- GameSentenceMiner/web/static/css/kanji-grid.css +107 -0
- GameSentenceMiner/web/static/css/search.css +14 -0
- GameSentenceMiner/web/static/css/shared.css +932 -0
- GameSentenceMiner/web/static/css/stats.css +499 -0
- GameSentenceMiner/web/static/js/anki_stats.js +84 -0
- GameSentenceMiner/web/static/js/database.js +541 -0
- GameSentenceMiner/web/static/js/kanji-grid.js +203 -0
- GameSentenceMiner/web/static/js/search.js +273 -0
- GameSentenceMiner/web/static/js/shared.js +506 -0
- GameSentenceMiner/web/static/js/stats.js +1427 -0
- GameSentenceMiner/web/templates/components/navigation.html +16 -0
- GameSentenceMiner/web/templates/components/theme-styles.html +128 -0
- {gamesentenceminer-2.15.11.dist-info → gamesentenceminer-2.15.12.dist-info}/METADATA +1 -1
- {gamesentenceminer-2.15.11.dist-info → gamesentenceminer-2.15.12.dist-info}/RECORD +18 -6
- {gamesentenceminer-2.15.11.dist-info → gamesentenceminer-2.15.12.dist-info}/WHEEL +0 -0
- {gamesentenceminer-2.15.11.dist-info → gamesentenceminer-2.15.12.dist-info}/entry_points.txt +0 -0
- {gamesentenceminer-2.15.11.dist-info → gamesentenceminer-2.15.12.dist-info}/licenses/LICENSE +0 -0
- {gamesentenceminer-2.15.11.dist-info → gamesentenceminer-2.15.12.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,1427 @@
|
|
1
|
+
// Helper function to detect the current theme based on the app's theme system
|
2
|
+
function getCurrentTheme() {
|
3
|
+
const dataTheme = document.documentElement.getAttribute('data-theme');
|
4
|
+
if (dataTheme === 'dark' || dataTheme === 'light') {
|
5
|
+
return dataTheme;
|
6
|
+
}
|
7
|
+
|
8
|
+
// Fallback to system preference if no manual theme is set
|
9
|
+
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
10
|
+
return 'dark';
|
11
|
+
}
|
12
|
+
return 'light';
|
13
|
+
}
|
14
|
+
|
15
|
+
// Helper function to get theme-appropriate text color
|
16
|
+
function getThemeTextColor() {
|
17
|
+
return getCurrentTheme() === 'dark' ? '#fff' : '#222';
|
18
|
+
}
|
19
|
+
|
20
|
+
// Ensure Chart.js uses white font in dark mode and black in light mode for all chart text
|
21
|
+
if (window.Chart) {
|
22
|
+
function setChartFontColor() {
|
23
|
+
Chart.defaults.color = getThemeTextColor();
|
24
|
+
}
|
25
|
+
setChartFontColor();
|
26
|
+
|
27
|
+
// Listen for theme changes from both manual toggle and system preference
|
28
|
+
if (window.matchMedia) {
|
29
|
+
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', setChartFontColor);
|
30
|
+
}
|
31
|
+
|
32
|
+
// Listen for manual theme changes via MutationObserver on data-theme attribute
|
33
|
+
const observer = new MutationObserver((mutations) => {
|
34
|
+
mutations.forEach((mutation) => {
|
35
|
+
if (mutation.type === 'attributes' && mutation.attributeName === 'data-theme') {
|
36
|
+
setChartFontColor();
|
37
|
+
}
|
38
|
+
});
|
39
|
+
});
|
40
|
+
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
|
41
|
+
}
|
42
|
+
|
43
|
+
// Statistics Page JavaScript
|
44
|
+
// Dependencies: shared.js (provides utility functions like showElement, hideElement, escapeHtml)
|
45
|
+
|
46
|
+
document.addEventListener('DOMContentLoaded', function () {
|
47
|
+
// Helper function to create a chart to avoid repeating code
|
48
|
+
function createChart(canvasId, datasets, chartTitle) {
|
49
|
+
const ctx = document.getElementById(canvasId).getContext('2d');
|
50
|
+
new Chart(ctx, {
|
51
|
+
type: 'line',
|
52
|
+
data: {
|
53
|
+
labels: datasets.labels,
|
54
|
+
datasets: datasets.datasets
|
55
|
+
},
|
56
|
+
options: {
|
57
|
+
responsive: true,
|
58
|
+
plugins: {
|
59
|
+
legend: {
|
60
|
+
position: 'top',
|
61
|
+
labels: {
|
62
|
+
color: getThemeTextColor()
|
63
|
+
}
|
64
|
+
},
|
65
|
+
title: {
|
66
|
+
display: true,
|
67
|
+
text: chartTitle,
|
68
|
+
color: getThemeTextColor()
|
69
|
+
}
|
70
|
+
},
|
71
|
+
scales: {
|
72
|
+
y: {
|
73
|
+
beginAtZero: true,
|
74
|
+
title: {
|
75
|
+
display: true,
|
76
|
+
text: 'Cumulative Count',
|
77
|
+
color: getThemeTextColor()
|
78
|
+
},
|
79
|
+
ticks: {
|
80
|
+
color: getThemeTextColor()
|
81
|
+
}
|
82
|
+
},
|
83
|
+
x: {
|
84
|
+
title: {
|
85
|
+
display: true,
|
86
|
+
text: 'Date',
|
87
|
+
color: getThemeTextColor()
|
88
|
+
},
|
89
|
+
ticks: {
|
90
|
+
color: getThemeTextColor()
|
91
|
+
}
|
92
|
+
}
|
93
|
+
}
|
94
|
+
}
|
95
|
+
});
|
96
|
+
}
|
97
|
+
|
98
|
+
// Helper function to get week number of year (GitHub style - week starts on Sunday)
|
99
|
+
function getWeekOfYear(date) {
|
100
|
+
const yearStart = new Date(date.getFullYear(), 0, 1);
|
101
|
+
const dayOfYear = Math.floor((date - yearStart) / (24 * 60 * 60 * 1000)) + 1;
|
102
|
+
const dayOfWeek = yearStart.getDay(); // 0 = Sunday
|
103
|
+
|
104
|
+
// Calculate week number (1-indexed)
|
105
|
+
const weekNum = Math.ceil((dayOfYear + dayOfWeek) / 7);
|
106
|
+
return Math.min(53, weekNum); // Cap at 53 weeks
|
107
|
+
}
|
108
|
+
|
109
|
+
// Helper function to get day of week (0 = Sunday, 6 = Saturday)
|
110
|
+
function getDayOfWeek(date) {
|
111
|
+
return date.getDay();
|
112
|
+
}
|
113
|
+
|
114
|
+
// Helper function to get the first Sunday of the year (or before)
|
115
|
+
function getFirstSunday(year) {
|
116
|
+
const jan1 = new Date(year, 0, 1);
|
117
|
+
const dayOfWeek = jan1.getDay();
|
118
|
+
const firstSunday = new Date(year, 0, 1 - dayOfWeek);
|
119
|
+
return firstSunday;
|
120
|
+
}
|
121
|
+
|
122
|
+
// Function to calculate heatmap streaks and average daily time
|
123
|
+
function calculateHeatmapStreaks(grid, yearData, allLinesForYear = []) {
|
124
|
+
const dates = [];
|
125
|
+
|
126
|
+
// Collect all dates in chronological order
|
127
|
+
for (let week = 0; week < 53; week++) {
|
128
|
+
for (let day = 0; day < 7; day++) {
|
129
|
+
const date = grid[day][week];
|
130
|
+
if (date) {
|
131
|
+
const dateStr = date.toISOString().split('T')[0];
|
132
|
+
const activity = yearData[dateStr] || 0;
|
133
|
+
dates.push({ date: dateStr, activity: activity });
|
134
|
+
}
|
135
|
+
}
|
136
|
+
}
|
137
|
+
|
138
|
+
// Sort dates chronologically
|
139
|
+
dates.sort((a, b) => new Date(a.date) - new Date(b.date));
|
140
|
+
|
141
|
+
|
142
|
+
let longestStreak = 0;
|
143
|
+
let currentStreak = 0;
|
144
|
+
let tempStreak = 0;
|
145
|
+
|
146
|
+
// Calculate longest streak
|
147
|
+
for (let i = 0; i < dates.length; i++) {
|
148
|
+
if (dates[i].activity > 0) {
|
149
|
+
tempStreak++;
|
150
|
+
longestStreak = Math.max(longestStreak, tempStreak);
|
151
|
+
} else {
|
152
|
+
tempStreak = 0;
|
153
|
+
}
|
154
|
+
}
|
155
|
+
|
156
|
+
// Calculate current streak from today backwards
|
157
|
+
const today = new Date().toISOString().split('T')[0];
|
158
|
+
|
159
|
+
// Find today's index or the most recent date before today
|
160
|
+
let todayIndex = -1;
|
161
|
+
for (let i = dates.length - 1; i >= 0; i--) {
|
162
|
+
if (dates[i].date <= today) {
|
163
|
+
todayIndex = i;
|
164
|
+
break;
|
165
|
+
}
|
166
|
+
}
|
167
|
+
|
168
|
+
// Count backwards from today (or most recent date)
|
169
|
+
if (todayIndex >= 0) {
|
170
|
+
for (let i = todayIndex; i >= 0; i--) {
|
171
|
+
if (dates[i].activity > 0) {
|
172
|
+
currentStreak++;
|
173
|
+
} else {
|
174
|
+
break;
|
175
|
+
}
|
176
|
+
}
|
177
|
+
}
|
178
|
+
|
179
|
+
// Calculate average daily time for this year
|
180
|
+
let avgDailyTime = "-";
|
181
|
+
if (allLinesForYear && allLinesForYear.length > 0) {
|
182
|
+
// Group timestamps by day for this year
|
183
|
+
const dailyTimestamps = {};
|
184
|
+
for (const line of allLinesForYear) {
|
185
|
+
const dateStr = new Date(parseFloat(line.timestamp) * 1000).toISOString().split('T')[0];
|
186
|
+
if (!dailyTimestamps[dateStr]) {
|
187
|
+
dailyTimestamps[dateStr] = [];
|
188
|
+
}
|
189
|
+
dailyTimestamps[dateStr].push(parseFloat(line.timestamp));
|
190
|
+
}
|
191
|
+
|
192
|
+
// Calculate reading time for each day with activity
|
193
|
+
let totalHours = 0;
|
194
|
+
let activeDays = 0;
|
195
|
+
const afkTimerSeconds = 120; // Default AFK timer - should be fetched from settings
|
196
|
+
|
197
|
+
for (const [dateStr, timestamps] of Object.entries(dailyTimestamps)) {
|
198
|
+
if (timestamps.length >= 2) {
|
199
|
+
timestamps.sort((a, b) => a - b);
|
200
|
+
let dayReadingTime = 0;
|
201
|
+
|
202
|
+
for (let i = 1; i < timestamps.length; i++) {
|
203
|
+
const gap = timestamps[i] - timestamps[i-1];
|
204
|
+
dayReadingTime += Math.min(gap, afkTimerSeconds);
|
205
|
+
}
|
206
|
+
|
207
|
+
if (dayReadingTime > 0) {
|
208
|
+
totalHours += dayReadingTime / 3600;
|
209
|
+
activeDays++;
|
210
|
+
}
|
211
|
+
} else if (timestamps.length === 1) {
|
212
|
+
// Single timestamp - count as minimal activity (1 second)
|
213
|
+
totalHours += 1 / 3600;
|
214
|
+
activeDays++;
|
215
|
+
}
|
216
|
+
}
|
217
|
+
|
218
|
+
if (activeDays > 0) {
|
219
|
+
const avgHours = totalHours / activeDays;
|
220
|
+
if (avgHours < 1) {
|
221
|
+
const minutes = Math.round(avgHours * 60);
|
222
|
+
avgDailyTime = `${minutes}m`;
|
223
|
+
} else {
|
224
|
+
const hours = Math.floor(avgHours);
|
225
|
+
const minutes = Math.round((avgHours - hours) * 60);
|
226
|
+
avgDailyTime = minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`;
|
227
|
+
}
|
228
|
+
}
|
229
|
+
}
|
230
|
+
|
231
|
+
return { longestStreak, currentStreak, avgDailyTime };
|
232
|
+
}
|
233
|
+
|
234
|
+
// Function to create GitHub-style heatmap
|
235
|
+
function createHeatmap(heatmapData) {
|
236
|
+
const container = document.getElementById('heatmapContainer');
|
237
|
+
|
238
|
+
Object.keys(heatmapData).sort().forEach(year => {
|
239
|
+
const yearData = heatmapData[year];
|
240
|
+
const yearDiv = document.createElement('div');
|
241
|
+
yearDiv.className = 'heatmap-year';
|
242
|
+
|
243
|
+
const yearTitle = document.createElement('h3');
|
244
|
+
yearTitle.textContent = year;
|
245
|
+
yearDiv.appendChild(yearTitle);
|
246
|
+
|
247
|
+
// Find maximum activity value for this year to scale colors
|
248
|
+
const maxActivity = Math.max(...Object.values(yearData));
|
249
|
+
|
250
|
+
// Create main wrapper to center everything
|
251
|
+
const mainWrapper = document.createElement('div');
|
252
|
+
mainWrapper.className = 'heatmap-wrapper';
|
253
|
+
|
254
|
+
// Create container wrapper for labels and grid
|
255
|
+
const containerWrapper = document.createElement('div');
|
256
|
+
containerWrapper.className = 'heatmap-container-wrapper';
|
257
|
+
|
258
|
+
// Create day labels (S, M, T, W, T, F, S)
|
259
|
+
const dayLabels = document.createElement('div');
|
260
|
+
dayLabels.className = 'heatmap-day-labels';
|
261
|
+
const dayNames = ['S', '', 'M', '', 'W', '', 'F']; // Only show some labels for space
|
262
|
+
dayNames.forEach(dayName => {
|
263
|
+
const dayLabel = document.createElement('div');
|
264
|
+
dayLabel.className = 'heatmap-day-label';
|
265
|
+
dayLabel.textContent = dayName;
|
266
|
+
dayLabels.appendChild(dayLabel);
|
267
|
+
});
|
268
|
+
|
269
|
+
// Create grid container
|
270
|
+
const gridContainer = document.createElement('div');
|
271
|
+
|
272
|
+
// Create month labels
|
273
|
+
const monthLabels = document.createElement('div');
|
274
|
+
monthLabels.className = 'heatmap-month-labels';
|
275
|
+
|
276
|
+
// Create the main grid
|
277
|
+
const gridDiv = document.createElement('div');
|
278
|
+
gridDiv.className = 'heatmap-grid';
|
279
|
+
|
280
|
+
// Initialize 7x53 grid with empty cells
|
281
|
+
const grid = Array(7).fill(null).map(() => Array(53).fill(null));
|
282
|
+
|
283
|
+
// Get the first Sunday of the year (start of week 1)
|
284
|
+
const firstSunday = getFirstSunday(parseInt(year));
|
285
|
+
|
286
|
+
// Populate grid with dates for the entire year
|
287
|
+
for (let week = 0; week < 53; week++) {
|
288
|
+
for (let day = 0; day < 7; day++) {
|
289
|
+
const currentDate = new Date(firstSunday);
|
290
|
+
currentDate.setDate(firstSunday.getDate() + (week * 7) + day);
|
291
|
+
|
292
|
+
// Only include dates that belong to the current year
|
293
|
+
if (currentDate.getFullYear() === parseInt(year)) {
|
294
|
+
grid[day][week] = currentDate;
|
295
|
+
}
|
296
|
+
}
|
297
|
+
}
|
298
|
+
|
299
|
+
// Create month labels based on grid positions
|
300
|
+
const monthTracker = new Set();
|
301
|
+
for (let week = 0; week < 53; week++) {
|
302
|
+
const dateInWeek = grid[0][week] || grid[1][week] || grid[2][week] ||
|
303
|
+
grid[3][week] || grid[4][week] || grid[5][week] || grid[6][week];
|
304
|
+
|
305
|
+
if (dateInWeek) {
|
306
|
+
const month = dateInWeek.getMonth();
|
307
|
+
const monthName = dateInWeek.toLocaleDateString('en', { month: 'short' });
|
308
|
+
|
309
|
+
// Add month label if it's the first week of the month
|
310
|
+
if (!monthTracker.has(month) && dateInWeek.getDate() <= 7) {
|
311
|
+
const monthLabel = document.createElement('div');
|
312
|
+
monthLabel.className = 'heatmap-month-label';
|
313
|
+
monthLabel.style.gridColumn = `${week + 1}`;
|
314
|
+
monthLabel.textContent = monthName;
|
315
|
+
monthLabels.appendChild(monthLabel);
|
316
|
+
monthTracker.add(month);
|
317
|
+
}
|
318
|
+
}
|
319
|
+
}
|
320
|
+
|
321
|
+
// Create cells for the grid
|
322
|
+
for (let day = 0; day < 7; day++) {
|
323
|
+
for (let week = 0; week < 53; week++) {
|
324
|
+
const cell = document.createElement('div');
|
325
|
+
cell.className = 'heatmap-cell';
|
326
|
+
|
327
|
+
const date = grid[day][week];
|
328
|
+
if (date) {
|
329
|
+
const dateStr = date.toISOString().split('T')[0];
|
330
|
+
const activity = yearData[dateStr] || 0;
|
331
|
+
|
332
|
+
if (activity > 0 && maxActivity > 0) {
|
333
|
+
// Calculate percentage of maximum activity
|
334
|
+
const percentage = (activity / maxActivity) * 100;
|
335
|
+
|
336
|
+
// Assign discrete color levels based on percentage thresholds
|
337
|
+
let colorLevel;
|
338
|
+
if (percentage <= 25) {
|
339
|
+
colorLevel = 1; // Light green
|
340
|
+
} else if (percentage <= 50) {
|
341
|
+
colorLevel = 2; // Medium green
|
342
|
+
} else if (percentage <= 75) {
|
343
|
+
colorLevel = 3; // Dark green
|
344
|
+
} else {
|
345
|
+
colorLevel = 4; // Darkest green
|
346
|
+
}
|
347
|
+
|
348
|
+
// Define discrete colors for each level
|
349
|
+
const colors = {
|
350
|
+
1: '#c6e48b', // Light green (1-25%)
|
351
|
+
2: '#7bc96f', // Medium green (26-50%)
|
352
|
+
3: '#239a3b', // Dark green (51-75%)
|
353
|
+
4: '#196127' // Darkest green (76-100%)
|
354
|
+
};
|
355
|
+
|
356
|
+
cell.style.backgroundColor = colors[colorLevel];
|
357
|
+
}
|
358
|
+
|
359
|
+
cell.title = `${dateStr}: ${activity} characters`;
|
360
|
+
} else {
|
361
|
+
// Empty cell for dates outside the year
|
362
|
+
cell.style.backgroundColor = 'transparent';
|
363
|
+
cell.style.cursor = 'default';
|
364
|
+
}
|
365
|
+
|
366
|
+
gridDiv.appendChild(cell);
|
367
|
+
}
|
368
|
+
}
|
369
|
+
|
370
|
+
gridContainer.appendChild(monthLabels);
|
371
|
+
gridContainer.appendChild(gridDiv);
|
372
|
+
containerWrapper.appendChild(dayLabels);
|
373
|
+
containerWrapper.appendChild(gridContainer);
|
374
|
+
mainWrapper.appendChild(containerWrapper);
|
375
|
+
|
376
|
+
// Calculate and display streaks with average daily time
|
377
|
+
const yearLines = window.allLinesData ? window.allLinesData.filter(line => {
|
378
|
+
if (!line.timestamp) return false;
|
379
|
+
const lineYear = new Date(parseFloat(line.timestamp) * 1000).getFullYear();
|
380
|
+
return lineYear === parseInt(year);
|
381
|
+
}) : [];
|
382
|
+
|
383
|
+
const streaks = calculateHeatmapStreaks(grid, yearData, yearLines);
|
384
|
+
const streaksDiv = document.createElement('div');
|
385
|
+
streaksDiv.className = 'heatmap-streaks';
|
386
|
+
streaksDiv.innerHTML = `
|
387
|
+
<div class="heatmap-streak-item">
|
388
|
+
<div class="heatmap-streak-number">${streaks.longestStreak}</div>
|
389
|
+
<div class="heatmap-streak-label">Longest Streak</div>
|
390
|
+
</div>
|
391
|
+
<div class="heatmap-streak-item">
|
392
|
+
<div class="heatmap-streak-number">${streaks.currentStreak}</div>
|
393
|
+
<div class="heatmap-streak-label">Current Streak</div>
|
394
|
+
</div>
|
395
|
+
<div class="heatmap-streak-item">
|
396
|
+
<div class="heatmap-streak-number">${streaks.avgDailyTime}</div>
|
397
|
+
<div class="heatmap-streak-label">Avg Daily Time</div>
|
398
|
+
</div>
|
399
|
+
`;
|
400
|
+
mainWrapper.appendChild(streaksDiv);
|
401
|
+
yearDiv.appendChild(mainWrapper);
|
402
|
+
|
403
|
+
// Add legend with discrete colors
|
404
|
+
const legend = document.createElement('div');
|
405
|
+
legend.className = 'heatmap-legend';
|
406
|
+
legend.innerHTML = `
|
407
|
+
<span>Less</span>
|
408
|
+
<div class="heatmap-legend-item" style="background-color: #ebedf0;" title="No activity"></div>
|
409
|
+
<div class="heatmap-legend-item" style="background-color: #c6e48b;" title="1-25% of max activity"></div>
|
410
|
+
<div class="heatmap-legend-item" style="background-color: #7bc96f;" title="26-50% of max activity"></div>
|
411
|
+
<div class="heatmap-legend-item" style="background-color: #239a3b;" title="51-75% of max activity"></div>
|
412
|
+
<div class="heatmap-legend-item" style="background-color: #196127;" title="76-100% of max activity"></div>
|
413
|
+
<span>More</span>
|
414
|
+
`;
|
415
|
+
yearDiv.appendChild(legend);
|
416
|
+
|
417
|
+
container.appendChild(yearDiv);
|
418
|
+
});
|
419
|
+
}
|
420
|
+
|
421
|
+
// Function to generate distinct colors for games
|
422
|
+
function generateGameColors(gameCount) {
|
423
|
+
const colors = [];
|
424
|
+
|
425
|
+
// Predefined set of good colors for the first few games
|
426
|
+
const predefinedColors = [
|
427
|
+
'#3498db', '#e74c3c', '#2ecc71', '#f1c40f', '#9b59b6',
|
428
|
+
'#1abc9c', '#e67e22', '#34495e', '#16a085', '#27ae60',
|
429
|
+
'#2980b9', '#8e44ad', '#d35400', '#c0392b', '#7f8c8d'
|
430
|
+
];
|
431
|
+
|
432
|
+
// Use predefined colors first
|
433
|
+
for (let i = 0; i < Math.min(gameCount, predefinedColors.length); i++) {
|
434
|
+
colors.push(predefinedColors[i]);
|
435
|
+
}
|
436
|
+
|
437
|
+
// Generate additional colors using HSL if needed
|
438
|
+
if (gameCount > predefinedColors.length) {
|
439
|
+
const remaining = gameCount - predefinedColors.length;
|
440
|
+
for (let i = 0; i < remaining; i++) {
|
441
|
+
// Distribute hue evenly across the color wheel
|
442
|
+
const hue = (i * 360 / remaining) % 360;
|
443
|
+
// Use varied saturation and lightness for visual distinction
|
444
|
+
const saturation = 65 + (i % 3) * 10; // 65%, 75%, 85%
|
445
|
+
const lightness = 45 + (i % 2) * 10; // 45%, 55%
|
446
|
+
|
447
|
+
colors.push(`hsl(${hue.toFixed(0)}, ${saturation}%, ${lightness}%)`);
|
448
|
+
}
|
449
|
+
}
|
450
|
+
|
451
|
+
return colors;
|
452
|
+
}
|
453
|
+
|
454
|
+
// Reusable function to create game bar charts with interactive legend
|
455
|
+
function createGameBarChart(canvasId, chartData, chartTitle, yAxisLabel) {
|
456
|
+
const ctx = document.getElementById(canvasId).getContext('2d');
|
457
|
+
const colors = generateGameColors(chartData.labels.length);
|
458
|
+
|
459
|
+
// Track which bars are hidden for toggle functionality
|
460
|
+
const hiddenBars = new Array(chartData.labels.length).fill(false);
|
461
|
+
|
462
|
+
new Chart(ctx, {
|
463
|
+
type: 'bar',
|
464
|
+
data: {
|
465
|
+
labels: chartData.labels, // Each game as a separate label
|
466
|
+
datasets: [{
|
467
|
+
label: chartTitle,
|
468
|
+
data: chartData.totals,
|
469
|
+
backgroundColor: colors.map(color => color + '99'), // Semi-transparent
|
470
|
+
borderColor: colors,
|
471
|
+
borderWidth: 2
|
472
|
+
}]
|
473
|
+
},
|
474
|
+
options: {
|
475
|
+
responsive: true,
|
476
|
+
interaction: {
|
477
|
+
intersect: false,
|
478
|
+
mode: 'nearest'
|
479
|
+
},
|
480
|
+
plugins: {
|
481
|
+
legend: {
|
482
|
+
position: 'right',
|
483
|
+
labels: {
|
484
|
+
color: getThemeTextColor(),
|
485
|
+
generateLabels: function(chart) {
|
486
|
+
// Create custom legend items for each game
|
487
|
+
return chartData.labels.map((gameName, index) => ({
|
488
|
+
text: gameName,
|
489
|
+
fillStyle: colors[index],
|
490
|
+
strokeStyle: colors[index],
|
491
|
+
lineWidth: 2,
|
492
|
+
hidden: hiddenBars[index],
|
493
|
+
index: index,
|
494
|
+
fontColor: getThemeTextColor()
|
495
|
+
}));
|
496
|
+
}
|
497
|
+
},
|
498
|
+
onClick: function(e, legendItem) {
|
499
|
+
const index = legendItem.index;
|
500
|
+
const chart = this.chart;
|
501
|
+
const meta = chart.getDatasetMeta(0);
|
502
|
+
|
503
|
+
// Toggle visibility for this specific bar
|
504
|
+
hiddenBars[index] = !hiddenBars[index];
|
505
|
+
|
506
|
+
// Update the dataset to hide/show this bar
|
507
|
+
if (hiddenBars[index]) {
|
508
|
+
meta.data[index].hidden = true;
|
509
|
+
} else {
|
510
|
+
meta.data[index].hidden = false;
|
511
|
+
}
|
512
|
+
|
513
|
+
chart.update();
|
514
|
+
}
|
515
|
+
},
|
516
|
+
title: {
|
517
|
+
display: true,
|
518
|
+
text: chartTitle,
|
519
|
+
color: getThemeTextColor()
|
520
|
+
},
|
521
|
+
tooltip: {
|
522
|
+
callbacks: {
|
523
|
+
title: function(context) {
|
524
|
+
// Show the game name as the main title
|
525
|
+
return context[0].label;
|
526
|
+
},
|
527
|
+
label: function(context) {
|
528
|
+
// Show only this game's data
|
529
|
+
const value = context.parsed.y;
|
530
|
+
return `Characters: ${value.toLocaleString()}`;
|
531
|
+
}
|
532
|
+
},
|
533
|
+
displayColors: true,
|
534
|
+
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
535
|
+
titleColor: 'white',
|
536
|
+
bodyColor: 'white',
|
537
|
+
borderColor: 'rgba(255, 255, 255, 0.1)',
|
538
|
+
borderWidth: 1
|
539
|
+
}
|
540
|
+
},
|
541
|
+
scales: {
|
542
|
+
y: {
|
543
|
+
beginAtZero: true,
|
544
|
+
title: {
|
545
|
+
display: true,
|
546
|
+
text: yAxisLabel,
|
547
|
+
color: getThemeTextColor()
|
548
|
+
},
|
549
|
+
ticks: {
|
550
|
+
color: getThemeTextColor()
|
551
|
+
}
|
552
|
+
},
|
553
|
+
x: {
|
554
|
+
title: {
|
555
|
+
display: false // Remove unhelpful "Game Titles" label
|
556
|
+
},
|
557
|
+
ticks: {
|
558
|
+
color: getThemeTextColor()
|
559
|
+
}
|
560
|
+
}
|
561
|
+
}
|
562
|
+
}
|
563
|
+
});
|
564
|
+
}
|
565
|
+
|
566
|
+
// Specialized function for charts with custom formatting (time/speed)
|
567
|
+
function createGameBarChartWithCustomFormat(canvasId, chartData, chartTitle, yAxisLabel, formatFunction) {
|
568
|
+
const ctx = document.getElementById(canvasId).getContext('2d');
|
569
|
+
const colors = generateGameColors(chartData.labels.length);
|
570
|
+
|
571
|
+
// Track which bars are hidden for toggle functionality
|
572
|
+
const hiddenBars = new Array(chartData.labels.length).fill(false);
|
573
|
+
|
574
|
+
new Chart(ctx, {
|
575
|
+
type: 'bar',
|
576
|
+
data: {
|
577
|
+
labels: chartData.labels, // Each game as a separate label
|
578
|
+
datasets: [{
|
579
|
+
label: chartTitle,
|
580
|
+
data: chartData.totals,
|
581
|
+
backgroundColor: colors.map(color => color + '99'), // Semi-transparent
|
582
|
+
borderColor: colors,
|
583
|
+
borderWidth: 2
|
584
|
+
}]
|
585
|
+
},
|
586
|
+
options: {
|
587
|
+
responsive: true,
|
588
|
+
interaction: {
|
589
|
+
intersect: false,
|
590
|
+
mode: 'nearest'
|
591
|
+
},
|
592
|
+
plugins: {
|
593
|
+
legend: {
|
594
|
+
position: 'right',
|
595
|
+
labels: {
|
596
|
+
color: getThemeTextColor(),
|
597
|
+
generateLabels: function(chart) {
|
598
|
+
// Create custom legend items for each game
|
599
|
+
return chartData.labels.map((gameName, index) => ({
|
600
|
+
text: gameName,
|
601
|
+
fillStyle: colors[index],
|
602
|
+
strokeStyle: colors[index],
|
603
|
+
lineWidth: 2,
|
604
|
+
hidden: hiddenBars[index],
|
605
|
+
index: index,
|
606
|
+
fontColor: getThemeTextColor()
|
607
|
+
}));
|
608
|
+
}
|
609
|
+
},
|
610
|
+
onClick: function(e, legendItem) {
|
611
|
+
const index = legendItem.index;
|
612
|
+
const chart = this.chart;
|
613
|
+
const meta = chart.getDatasetMeta(0);
|
614
|
+
|
615
|
+
// Toggle visibility for this specific bar
|
616
|
+
hiddenBars[index] = !hiddenBars[index];
|
617
|
+
|
618
|
+
// Update the dataset to hide/show this bar
|
619
|
+
if (hiddenBars[index]) {
|
620
|
+
meta.data[index].hidden = true;
|
621
|
+
} else {
|
622
|
+
meta.data[index].hidden = false;
|
623
|
+
}
|
624
|
+
|
625
|
+
chart.update();
|
626
|
+
}
|
627
|
+
},
|
628
|
+
title: {
|
629
|
+
display: true,
|
630
|
+
text: chartTitle,
|
631
|
+
color: getThemeTextColor()
|
632
|
+
},
|
633
|
+
tooltip: {
|
634
|
+
callbacks: {
|
635
|
+
title: function(context) {
|
636
|
+
// Show the game name as the main title
|
637
|
+
return context[0].label;
|
638
|
+
},
|
639
|
+
label: function(context) {
|
640
|
+
// Use custom format function
|
641
|
+
const value = context.parsed.y;
|
642
|
+
return formatFunction(value);
|
643
|
+
}
|
644
|
+
},
|
645
|
+
displayColors: true,
|
646
|
+
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
647
|
+
titleColor: 'white',
|
648
|
+
bodyColor: 'white',
|
649
|
+
borderColor: 'rgba(255, 255, 255, 0.1)',
|
650
|
+
borderWidth: 1
|
651
|
+
}
|
652
|
+
},
|
653
|
+
scales: {
|
654
|
+
y: {
|
655
|
+
beginAtZero: true,
|
656
|
+
title: {
|
657
|
+
display: true,
|
658
|
+
text: yAxisLabel,
|
659
|
+
color: getThemeTextColor()
|
660
|
+
},
|
661
|
+
ticks: {
|
662
|
+
color: getThemeTextColor()
|
663
|
+
}
|
664
|
+
},
|
665
|
+
x: {
|
666
|
+
title: {
|
667
|
+
display: false // Remove unhelpful axis labels
|
668
|
+
},
|
669
|
+
ticks: {
|
670
|
+
color: getThemeTextColor()
|
671
|
+
}
|
672
|
+
}
|
673
|
+
}
|
674
|
+
}
|
675
|
+
});
|
676
|
+
}
|
677
|
+
|
678
|
+
// Helper functions for formatting
|
679
|
+
function formatTime(hours) {
|
680
|
+
if (hours < 1) {
|
681
|
+
const minutes = (hours * 60).toFixed(0);
|
682
|
+
return `Time: ${minutes} minutes`;
|
683
|
+
} else {
|
684
|
+
return `Time: ${hours.toFixed(2)} hours`;
|
685
|
+
}
|
686
|
+
}
|
687
|
+
|
688
|
+
function formatSpeed(charsPerHour) {
|
689
|
+
return `Speed: ${charsPerHour.toLocaleString()} chars/hour`;
|
690
|
+
}
|
691
|
+
|
692
|
+
// Initialize Kanji Grid Renderer (using shared component)
|
693
|
+
const kanjiGridRenderer = new KanjiGridRenderer({
|
694
|
+
containerSelector: '#kanjiGrid',
|
695
|
+
counterSelector: '#kanjiCount',
|
696
|
+
colorMode: 'backend',
|
697
|
+
emptyMessage: 'No kanji data available'
|
698
|
+
});
|
699
|
+
|
700
|
+
// Function to create kanji grid (now using shared renderer)
|
701
|
+
function createKanjiGrid(kanjiData) {
|
702
|
+
kanjiGridRenderer.render(kanjiData);
|
703
|
+
}
|
704
|
+
|
705
|
+
// Function to load stats data with optional year filter
|
706
|
+
function loadStatsData(filterYear = null) {
|
707
|
+
const url = filterYear && filterYear !== 'all' ? `/api/stats?year=${filterYear}` : '/api/stats';
|
708
|
+
|
709
|
+
return fetch(url)
|
710
|
+
.then(response => response.json())
|
711
|
+
.then(data => {
|
712
|
+
// Store all lines data globally for heatmap calculations
|
713
|
+
if (data.allLinesData && Array.isArray(data.allLinesData)) {
|
714
|
+
window.allLinesData = data.allLinesData;
|
715
|
+
} else {
|
716
|
+
// If not provided by API, we'll work without it
|
717
|
+
window.allLinesData = [];
|
718
|
+
}
|
719
|
+
|
720
|
+
if (!data.labels || data.labels.length === 0) {
|
721
|
+
console.log("No data to display.");
|
722
|
+
return data;
|
723
|
+
}
|
724
|
+
|
725
|
+
// Filter datasets for each chart
|
726
|
+
const linesData = {
|
727
|
+
labels: data.labels,
|
728
|
+
datasets: data.datasets.filter(d => d.label.includes('Lines Received'))
|
729
|
+
};
|
730
|
+
|
731
|
+
const charsData = {
|
732
|
+
labels: data.labels,
|
733
|
+
datasets: data.datasets.filter(d => d.label.includes('Characters Read'))
|
734
|
+
};
|
735
|
+
|
736
|
+
// Remove the 'hidden' property so they appear on their own charts
|
737
|
+
[...charsData.datasets].forEach(d => delete d.hidden);
|
738
|
+
|
739
|
+
// Create the charts (only on initial load)
|
740
|
+
if (!window.chartsInitialized) {
|
741
|
+
createChart('linesChart', linesData, 'Cumulative Lines Received');
|
742
|
+
createChart('charsChart', charsData, 'Cumulative Characters Read');
|
743
|
+
|
744
|
+
// Create reading chars quantity chart if data exists
|
745
|
+
if (data.totalCharsPerGame) {
|
746
|
+
createGameBarChart('readingCharsChart', data.totalCharsPerGame, 'Reading Chars Quantity', 'Characters Read');
|
747
|
+
}
|
748
|
+
|
749
|
+
// Create reading time quantity chart if data exists
|
750
|
+
if (data.readingTimePerGame) {
|
751
|
+
createGameBarChartWithCustomFormat('readingTimeChart', data.readingTimePerGame, 'Reading Time Quantity', 'Time (hours)', formatTime);
|
752
|
+
}
|
753
|
+
|
754
|
+
// Create reading speed per game chart if data exists
|
755
|
+
if (data.readingSpeedPerGame) {
|
756
|
+
createGameBarChartWithCustomFormat('readingSpeedPerGameChart', data.readingSpeedPerGame, 'Reading Speed Improvement', 'Speed (chars/hour)', formatSpeed);
|
757
|
+
}
|
758
|
+
|
759
|
+
// Create kanji grid if data exists
|
760
|
+
if (data.kanjiGridData) {
|
761
|
+
createKanjiGrid(data.kanjiGridData);
|
762
|
+
}
|
763
|
+
|
764
|
+
window.chartsInitialized = true;
|
765
|
+
}
|
766
|
+
|
767
|
+
// Always update heatmap
|
768
|
+
if (data.heatmapData) {
|
769
|
+
const container = document.getElementById('heatmapContainer');
|
770
|
+
container.innerHTML = '';
|
771
|
+
createHeatmap(data.heatmapData);
|
772
|
+
}
|
773
|
+
|
774
|
+
// Load dashboard data (only on initial load)
|
775
|
+
if (!window.dashboardInitialized) {
|
776
|
+
loadDashboardData(data);
|
777
|
+
window.dashboardInitialized = true;
|
778
|
+
}
|
779
|
+
|
780
|
+
return data;
|
781
|
+
})
|
782
|
+
.catch(error => {
|
783
|
+
console.error('Error fetching chart data:', error);
|
784
|
+
showDashboardError();
|
785
|
+
throw error;
|
786
|
+
});
|
787
|
+
}
|
788
|
+
|
789
|
+
// Initial load with saved year preference
|
790
|
+
const savedYear = localStorage.getItem('selectedHeatmapYear') || 'all';
|
791
|
+
loadStatsData(savedYear);
|
792
|
+
|
793
|
+
// Make functions globally available
|
794
|
+
window.createHeatmap = createHeatmap;
|
795
|
+
window.loadStatsData = loadStatsData;
|
796
|
+
|
797
|
+
// Dashboard functionality
|
798
|
+
function loadDashboardData(data = null) {
|
799
|
+
function updateTodayOverview(allLinesData) {
|
800
|
+
// Get today's date string (YYYY-MM-DD)
|
801
|
+
const today = new Date();
|
802
|
+
const todayStr = today.toISOString().split('T')[0];
|
803
|
+
document.getElementById('todayDate').textContent = todayStr;
|
804
|
+
|
805
|
+
// Filter lines for today
|
806
|
+
const todayLines = (allLinesData || []).filter(line => {
|
807
|
+
if (!line.timestamp) return false;
|
808
|
+
const lineDate = new Date(parseFloat(line.timestamp) * 1000).toISOString().split('T')[0];
|
809
|
+
return lineDate === todayStr;
|
810
|
+
});
|
811
|
+
|
812
|
+
// Calculate total characters read today (only valid numbers)
|
813
|
+
const totalChars = todayLines.reduce((sum, line) => {
|
814
|
+
const chars = Number(line.characters);
|
815
|
+
return sum + (isNaN(chars) ? 0 : chars);
|
816
|
+
}, 0);
|
817
|
+
|
818
|
+
// Calculate sessions (count gaps > session threshold as new sessions)
|
819
|
+
let sessions = 0;
|
820
|
+
const sessionGapDefault = 3600; // 1 hour in seconds
|
821
|
+
// Try to get session gap from settings modal if available
|
822
|
+
let sessionGap = sessionGapDefault;
|
823
|
+
const sessionGapInput = document.getElementById('sessionGap');
|
824
|
+
if (sessionGapInput && sessionGapInput.value) {
|
825
|
+
const parsed = parseInt(sessionGapInput.value, 10);
|
826
|
+
if (!isNaN(parsed) && parsed > 0) sessionGap = parsed;
|
827
|
+
}
|
828
|
+
if (todayLines.length > 0 && todayLines[0].session_id !== undefined) {
|
829
|
+
const sessionSet = new Set(todayLines.map(l => l.session_id));
|
830
|
+
sessions = sessionSet.size;
|
831
|
+
} else {
|
832
|
+
// Use timestamp gap logic
|
833
|
+
const timestamps = todayLines
|
834
|
+
.map(l => parseFloat(l.timestamp))
|
835
|
+
.filter(ts => !isNaN(ts))
|
836
|
+
.sort((a, b) => a - b);
|
837
|
+
if (timestamps.length > 0) {
|
838
|
+
sessions = 1;
|
839
|
+
for (let i = 1; i < timestamps.length; i++) {
|
840
|
+
if (timestamps[i] - timestamps[i - 1] > sessionGap) {
|
841
|
+
sessions += 1;
|
842
|
+
}
|
843
|
+
}
|
844
|
+
} else {
|
845
|
+
sessions = 0;
|
846
|
+
}
|
847
|
+
}
|
848
|
+
|
849
|
+
// Calculate total reading time (reuse AFK logic from calculateHeatmapStreaks)
|
850
|
+
let totalSeconds = 0;
|
851
|
+
const timestamps = todayLines
|
852
|
+
.map(l => parseFloat(l.timestamp))
|
853
|
+
.filter(ts => !isNaN(ts))
|
854
|
+
.sort((a, b) => a - b);
|
855
|
+
const afkTimerSeconds = 120;
|
856
|
+
if (timestamps.length >= 2) {
|
857
|
+
for (let i = 1; i < timestamps.length; i++) {
|
858
|
+
const gap = timestamps[i] - timestamps[i-1];
|
859
|
+
totalSeconds += Math.min(gap, afkTimerSeconds);
|
860
|
+
}
|
861
|
+
} else if (timestamps.length === 1) {
|
862
|
+
totalSeconds = 1;
|
863
|
+
}
|
864
|
+
let totalHours = totalSeconds / 3600;
|
865
|
+
|
866
|
+
// Calculate chars/hour
|
867
|
+
let charsPerHour = '-';
|
868
|
+
if (totalChars > 0) {
|
869
|
+
// Avoid division by zero, set minimum time to 1 minute if activity exists
|
870
|
+
if (totalHours <= 0) totalHours = 1/60;
|
871
|
+
charsPerHour = Math.round(totalChars / totalHours).toLocaleString();
|
872
|
+
}
|
873
|
+
|
874
|
+
// Format hours for display
|
875
|
+
let hoursDisplay = '-';
|
876
|
+
if (totalHours > 0) {
|
877
|
+
const h = Math.floor(totalHours);
|
878
|
+
const m = Math.round((totalHours - h) * 60);
|
879
|
+
hoursDisplay = h > 0 ? `${h}h${m > 0 ? ' ' + m + 'm' : ''}` : `${m}m`;
|
880
|
+
}
|
881
|
+
|
882
|
+
document.getElementById('todayTotalHours').textContent = hoursDisplay;
|
883
|
+
document.getElementById('todayTotalChars').textContent = totalChars.toLocaleString();
|
884
|
+
document.getElementById('todaySessions').textContent = sessions;
|
885
|
+
document.getElementById('todayCharsPerHour').textContent = charsPerHour;
|
886
|
+
}
|
887
|
+
|
888
|
+
if (data && data.currentGameStats && data.allGamesStats) {
|
889
|
+
// Use existing data if available
|
890
|
+
updateCurrentGameDashboard(data.currentGameStats);
|
891
|
+
updateAllGamesDashboard(data.allGamesStats);
|
892
|
+
if (data.allLinesData) updateTodayOverview(data.allLinesData);
|
893
|
+
hideDashboardLoading();
|
894
|
+
} else {
|
895
|
+
// Fetch fresh data
|
896
|
+
showDashboardLoading();
|
897
|
+
fetch('/api/stats')
|
898
|
+
.then(response => response.json())
|
899
|
+
.then(data => {
|
900
|
+
if (data.currentGameStats && data.allGamesStats) {
|
901
|
+
updateCurrentGameDashboard(data.currentGameStats);
|
902
|
+
updateAllGamesDashboard(data.allGamesStats);
|
903
|
+
if (data.allLinesData) updateTodayOverview(data.allLinesData);
|
904
|
+
} else {
|
905
|
+
showDashboardError();
|
906
|
+
}
|
907
|
+
hideDashboardLoading();
|
908
|
+
})
|
909
|
+
.catch(error => {
|
910
|
+
console.error('Error fetching dashboard data:', error);
|
911
|
+
showDashboardError();
|
912
|
+
hideDashboardLoading();
|
913
|
+
});
|
914
|
+
}
|
915
|
+
}
|
916
|
+
|
917
|
+
function updateCurrentGameDashboard(stats) {
|
918
|
+
if (!stats) {
|
919
|
+
showNoDashboardData('currentGameCard', 'No current game data available');
|
920
|
+
return;
|
921
|
+
}
|
922
|
+
|
923
|
+
// Update game name and subtitle
|
924
|
+
document.getElementById('currentGameName').textContent = stats.game_name;
|
925
|
+
|
926
|
+
// Update main statistics
|
927
|
+
document.getElementById('currentTotalChars').textContent = stats.total_characters_formatted;
|
928
|
+
document.getElementById('currentTotalTime').textContent = stats.total_time_formatted;
|
929
|
+
document.getElementById('currentReadingSpeed').textContent = stats.reading_speed_formatted;
|
930
|
+
document.getElementById('currentSessions').textContent = stats.sessions.toLocaleString();
|
931
|
+
|
932
|
+
// Update progress section
|
933
|
+
document.getElementById('currentMonthlyChars').textContent = stats.monthly_characters_formatted;
|
934
|
+
document.getElementById('currentFirstDate').textContent = stats.first_date;
|
935
|
+
document.getElementById('currentLastDate').textContent = stats.last_date;
|
936
|
+
|
937
|
+
// Update streak indicator
|
938
|
+
const streakElement = document.getElementById('currentGameStreak');
|
939
|
+
const streakValue = document.getElementById('currentStreakValue');
|
940
|
+
if (stats.current_streak > 0) {
|
941
|
+
streakValue.textContent = stats.current_streak;
|
942
|
+
streakElement.style.display = 'inline-flex';
|
943
|
+
} else {
|
944
|
+
streakElement.style.display = 'none';
|
945
|
+
}
|
946
|
+
|
947
|
+
// Show the card
|
948
|
+
document.getElementById('currentGameCard').style.display = 'block';
|
949
|
+
}
|
950
|
+
|
951
|
+
function updateAllGamesDashboard(stats) {
|
952
|
+
if (!stats) {
|
953
|
+
showNoDashboardData('allGamesCard', 'No games data available');
|
954
|
+
return;
|
955
|
+
}
|
956
|
+
|
957
|
+
// Update subtitle
|
958
|
+
const gamesText = stats.unique_games === 1 ? '1 game played' : `${stats.unique_games} games played`;
|
959
|
+
document.getElementById('totalGamesCount').textContent = gamesText;
|
960
|
+
|
961
|
+
// Update main statistics
|
962
|
+
document.getElementById('allTotalChars').textContent = stats.total_characters_formatted;
|
963
|
+
document.getElementById('allTotalTime').textContent = stats.total_time_formatted;
|
964
|
+
document.getElementById('allReadingSpeed').textContent = stats.reading_speed_formatted;
|
965
|
+
document.getElementById('allSessions').textContent = stats.sessions.toLocaleString();
|
966
|
+
|
967
|
+
// Update progress section
|
968
|
+
document.getElementById('allMonthlyChars').textContent = stats.monthly_characters_formatted;
|
969
|
+
document.getElementById('allUniqueGames').textContent = stats.unique_games.toLocaleString();
|
970
|
+
document.getElementById('allTotalSentences').textContent = stats.total_sentences.toLocaleString();
|
971
|
+
|
972
|
+
// Update streak indicator
|
973
|
+
const streakElement = document.getElementById('allGamesStreak');
|
974
|
+
const streakValue = document.getElementById('allStreakValue');
|
975
|
+
if (stats.current_streak > 0) {
|
976
|
+
streakValue.textContent = stats.current_streak;
|
977
|
+
streakElement.style.display = 'inline-flex';
|
978
|
+
} else {
|
979
|
+
streakElement.style.display = 'none';
|
980
|
+
}
|
981
|
+
|
982
|
+
|
983
|
+
// Show the card
|
984
|
+
document.getElementById('allGamesCard').style.display = 'block';
|
985
|
+
}
|
986
|
+
|
987
|
+
function showDashboardLoading() {
|
988
|
+
document.getElementById('dashboardLoading').style.display = 'flex';
|
989
|
+
document.getElementById('dashboardError').style.display = 'none';
|
990
|
+
document.getElementById('currentGameCard').style.display = 'none';
|
991
|
+
document.getElementById('allGamesCard').style.display = 'none';
|
992
|
+
}
|
993
|
+
|
994
|
+
function hideDashboardLoading() {
|
995
|
+
document.getElementById('dashboardLoading').style.display = 'none';
|
996
|
+
}
|
997
|
+
|
998
|
+
function showDashboardError() {
|
999
|
+
document.getElementById('dashboardError').style.display = 'block';
|
1000
|
+
document.getElementById('dashboardLoading').style.display = 'none';
|
1001
|
+
document.getElementById('currentGameCard').style.display = 'none';
|
1002
|
+
document.getElementById('allGamesCard').style.display = 'none';
|
1003
|
+
}
|
1004
|
+
|
1005
|
+
function showNoDashboardData(cardId, message) {
|
1006
|
+
const card = document.getElementById(cardId);
|
1007
|
+
const statsGrid = card.querySelector('.dashboard-stats-grid');
|
1008
|
+
const progressSection = card.querySelector('.dashboard-progress-section');
|
1009
|
+
|
1010
|
+
// Hide stats and progress sections
|
1011
|
+
statsGrid.style.display = 'none';
|
1012
|
+
progressSection.style.display = 'none';
|
1013
|
+
|
1014
|
+
// Add no data message
|
1015
|
+
let noDataMsg = card.querySelector('.no-data-message');
|
1016
|
+
if (!noDataMsg) {
|
1017
|
+
noDataMsg = document.createElement('div');
|
1018
|
+
noDataMsg.className = 'no-data-message';
|
1019
|
+
noDataMsg.style.cssText = 'text-align: center; padding: 40px 20px; color: var(--text-tertiary); font-style: italic;';
|
1020
|
+
card.appendChild(noDataMsg);
|
1021
|
+
}
|
1022
|
+
noDataMsg.textContent = message;
|
1023
|
+
|
1024
|
+
card.style.display = 'block';
|
1025
|
+
}
|
1026
|
+
|
1027
|
+
// Add click animations for dashboard stat items
|
1028
|
+
const statItems = document.querySelectorAll('.dashboard-stat-item');
|
1029
|
+
statItems.forEach(item => {
|
1030
|
+
item.addEventListener('click', function() {
|
1031
|
+
// Add click animation
|
1032
|
+
this.style.transform = 'scale(0.95)';
|
1033
|
+
setTimeout(() => {
|
1034
|
+
this.style.transform = '';
|
1035
|
+
}, 150);
|
1036
|
+
});
|
1037
|
+
});
|
1038
|
+
|
1039
|
+
// Add accessibility improvements
|
1040
|
+
statItems.forEach(item => {
|
1041
|
+
item.setAttribute('tabindex', '0');
|
1042
|
+
item.setAttribute('role', 'button');
|
1043
|
+
|
1044
|
+
item.addEventListener('keydown', function(e) {
|
1045
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
1046
|
+
e.preventDefault();
|
1047
|
+
this.click();
|
1048
|
+
}
|
1049
|
+
});
|
1050
|
+
});
|
1051
|
+
|
1052
|
+
// Global function to retry dashboard loading
|
1053
|
+
window.loadDashboardData = loadDashboardData;
|
1054
|
+
|
1055
|
+
// Delete Game Entry Functionality
|
1056
|
+
class GameDeletionManager {
|
1057
|
+
constructor() {
|
1058
|
+
this.games = [];
|
1059
|
+
this.selectedGames = new Set();
|
1060
|
+
this.isLoading = false;
|
1061
|
+
|
1062
|
+
this.initializeElements();
|
1063
|
+
this.attachEventListeners();
|
1064
|
+
this.loadGames();
|
1065
|
+
}
|
1066
|
+
|
1067
|
+
initializeElements() {
|
1068
|
+
// Control elements
|
1069
|
+
this.selectAllBtn = document.getElementById('selectAllBtn');
|
1070
|
+
this.selectNoneBtn = document.getElementById('selectNoneBtn');
|
1071
|
+
this.deleteSelectedBtn = document.getElementById('deleteSelectedBtn');
|
1072
|
+
this.headerCheckbox = document.getElementById('headerCheckbox');
|
1073
|
+
|
1074
|
+
// Table elements
|
1075
|
+
this.gamesTableBody = document.getElementById('gamesTableBody');
|
1076
|
+
this.loadingIndicator = document.getElementById('loadingIndicator');
|
1077
|
+
this.noGamesMessage = document.getElementById('noGamesMessage');
|
1078
|
+
this.errorMessage = document.getElementById('errorMessage');
|
1079
|
+
this.retryBtn = document.getElementById('retryBtn');
|
1080
|
+
|
1081
|
+
// Modal elements
|
1082
|
+
this.confirmationModal = document.getElementById('confirmationModal');
|
1083
|
+
this.progressModal = document.getElementById('progressModal');
|
1084
|
+
this.resultModal = document.getElementById('resultModal');
|
1085
|
+
|
1086
|
+
// Modal content elements
|
1087
|
+
this.selectedGamesList = document.getElementById('selectedGamesList');
|
1088
|
+
this.totalGamesCount = document.getElementById('totalGamesCount');
|
1089
|
+
this.totalSentencesCount = document.getElementById('totalSentencesCount');
|
1090
|
+
this.totalCharactersCount = document.getElementById('totalCharactersCount');
|
1091
|
+
this.progressText = document.getElementById('progressText');
|
1092
|
+
this.resultContent = document.getElementById('resultContent');
|
1093
|
+
this.resultTitle = document.getElementById('resultTitle');
|
1094
|
+
}
|
1095
|
+
|
1096
|
+
attachEventListeners() {
|
1097
|
+
// Control buttons
|
1098
|
+
if (this.selectAllBtn) this.selectAllBtn.addEventListener('click', () => this.selectAll());
|
1099
|
+
if (this.selectNoneBtn) this.selectNoneBtn.addEventListener('click', () => this.selectNone());
|
1100
|
+
if (this.deleteSelectedBtn) this.deleteSelectedBtn.addEventListener('click', () => this.showConfirmation());
|
1101
|
+
if (this.headerCheckbox) this.headerCheckbox.addEventListener('change', (e) => this.toggleAll(e.target.checked));
|
1102
|
+
|
1103
|
+
// Modal controls
|
1104
|
+
const closeModalBtn = document.getElementById('closeModal');
|
1105
|
+
if (closeModalBtn) closeModalBtn.addEventListener('click', () => this.hideModal('confirmationModal'));
|
1106
|
+
const cancelDeleteBtn = document.getElementById('cancelDeleteBtn');
|
1107
|
+
if (cancelDeleteBtn) cancelDeleteBtn.addEventListener('click', () => this.hideModal('confirmationModal'));
|
1108
|
+
const confirmDeleteBtn = document.getElementById('confirmDeleteBtn');
|
1109
|
+
if (confirmDeleteBtn) confirmDeleteBtn.addEventListener('click', () => this.performDeletion());
|
1110
|
+
const closeResultModalBtn = document.getElementById('closeResultModal');
|
1111
|
+
if (closeResultModalBtn) closeResultModalBtn.addEventListener('click', () => this.hideModal('resultModal'));
|
1112
|
+
const okBtn = document.getElementById('okBtn');
|
1113
|
+
if (okBtn) okBtn.addEventListener('click', () => this.hideModal('resultModal'));
|
1114
|
+
|
1115
|
+
// Retry button
|
1116
|
+
if (this.retryBtn) this.retryBtn.addEventListener('click', () => this.loadGames());
|
1117
|
+
|
1118
|
+
// Close modals when clicking outside
|
1119
|
+
[this.confirmationModal, this.progressModal, this.resultModal].forEach(modal => {
|
1120
|
+
modal.addEventListener('click', (e) => {
|
1121
|
+
if (e.target === modal) {
|
1122
|
+
this.hideModal(modal.id);
|
1123
|
+
}
|
1124
|
+
});
|
1125
|
+
});
|
1126
|
+
}
|
1127
|
+
|
1128
|
+
async loadGames() {
|
1129
|
+
this.showLoading(true);
|
1130
|
+
hideElement(this.errorMessage);
|
1131
|
+
hideElement(this.noGamesMessage);
|
1132
|
+
|
1133
|
+
try {
|
1134
|
+
const response = await fetch('/api/games-list');
|
1135
|
+
const data = await response.json();
|
1136
|
+
|
1137
|
+
if (!response.ok) {
|
1138
|
+
throw new Error(data.error || 'Failed to fetch games');
|
1139
|
+
}
|
1140
|
+
|
1141
|
+
this.games = data.games;
|
1142
|
+
this.selectedGames.clear();
|
1143
|
+
this.renderGamesTable();
|
1144
|
+
this.updateDeleteButton();
|
1145
|
+
|
1146
|
+
if (this.games.length === 0) {
|
1147
|
+
showElement(this.noGamesMessage);
|
1148
|
+
}
|
1149
|
+
|
1150
|
+
} catch (error) {
|
1151
|
+
console.error('Error loading games:', error);
|
1152
|
+
this.showError(error.message);
|
1153
|
+
} finally {
|
1154
|
+
this.showLoading(false);
|
1155
|
+
}
|
1156
|
+
}
|
1157
|
+
|
1158
|
+
renderGamesTable() {
|
1159
|
+
this.gamesTableBody.innerHTML = '';
|
1160
|
+
|
1161
|
+
this.games.forEach(game => {
|
1162
|
+
const row = document.createElement('tr');
|
1163
|
+
row.dataset.gameName = game.name;
|
1164
|
+
|
1165
|
+
row.innerHTML = `
|
1166
|
+
<td class="checkbox-cell">
|
1167
|
+
<input type="checkbox" class="game-checkbox" data-game="${game.name}">
|
1168
|
+
</td>
|
1169
|
+
<td><strong>${escapeHtml(game.name)}</strong></td>
|
1170
|
+
<td>${game.sentence_count.toLocaleString()}</td>
|
1171
|
+
<td>${game.total_characters.toLocaleString()}</td>
|
1172
|
+
<td>${game.date_range}</td>
|
1173
|
+
<td>${game.first_entry_date}</td>
|
1174
|
+
<td>${game.last_entry_date}</td>
|
1175
|
+
`;
|
1176
|
+
|
1177
|
+
// Add checkbox event listener
|
1178
|
+
const checkbox = row.querySelector('.game-checkbox');
|
1179
|
+
checkbox.addEventListener('change', (e) => {
|
1180
|
+
this.toggleGameSelection(game.name, e.target.checked);
|
1181
|
+
});
|
1182
|
+
|
1183
|
+
this.gamesTableBody.appendChild(row);
|
1184
|
+
});
|
1185
|
+
}
|
1186
|
+
|
1187
|
+
toggleGameSelection(gameName, isSelected) {
|
1188
|
+
if (isSelected) {
|
1189
|
+
this.selectedGames.add(gameName);
|
1190
|
+
} else {
|
1191
|
+
this.selectedGames.delete(gameName);
|
1192
|
+
}
|
1193
|
+
|
1194
|
+
this.updateRowSelection(gameName, isSelected);
|
1195
|
+
this.updateHeaderCheckbox();
|
1196
|
+
this.updateDeleteButton();
|
1197
|
+
}
|
1198
|
+
|
1199
|
+
updateRowSelection(gameName, isSelected) {
|
1200
|
+
const row = document.querySelector(`tr[data-game-name="${gameName}"]`);
|
1201
|
+
if (row) {
|
1202
|
+
if (isSelected) {
|
1203
|
+
row.classList.add('selected');
|
1204
|
+
} else {
|
1205
|
+
row.classList.remove('selected');
|
1206
|
+
}
|
1207
|
+
}
|
1208
|
+
}
|
1209
|
+
|
1210
|
+
selectAll() {
|
1211
|
+
this.games.forEach(game => {
|
1212
|
+
this.selectedGames.add(game.name);
|
1213
|
+
const checkbox = document.querySelector(`input[data-game="${game.name}"]`);
|
1214
|
+
if (checkbox) {
|
1215
|
+
checkbox.checked = true;
|
1216
|
+
this.updateRowSelection(game.name, true);
|
1217
|
+
}
|
1218
|
+
});
|
1219
|
+
this.updateHeaderCheckbox();
|
1220
|
+
this.updateDeleteButton();
|
1221
|
+
}
|
1222
|
+
|
1223
|
+
selectNone() {
|
1224
|
+
this.selectedGames.clear();
|
1225
|
+
document.querySelectorAll('.game-checkbox').forEach(checkbox => {
|
1226
|
+
checkbox.checked = false;
|
1227
|
+
});
|
1228
|
+
document.querySelectorAll('tr.selected').forEach(row => {
|
1229
|
+
row.classList.remove('selected');
|
1230
|
+
});
|
1231
|
+
this.updateHeaderCheckbox();
|
1232
|
+
this.updateDeleteButton();
|
1233
|
+
}
|
1234
|
+
|
1235
|
+
toggleAll(checked) {
|
1236
|
+
if (checked) {
|
1237
|
+
this.selectAll();
|
1238
|
+
} else {
|
1239
|
+
this.selectNone();
|
1240
|
+
}
|
1241
|
+
}
|
1242
|
+
|
1243
|
+
updateHeaderCheckbox() {
|
1244
|
+
const totalGames = this.games.length;
|
1245
|
+
const selectedCount = this.selectedGames.size;
|
1246
|
+
|
1247
|
+
if (selectedCount === 0) {
|
1248
|
+
this.headerCheckbox.checked = false;
|
1249
|
+
this.headerCheckbox.indeterminate = false;
|
1250
|
+
} else if (selectedCount === totalGames) {
|
1251
|
+
this.headerCheckbox.checked = true;
|
1252
|
+
this.headerCheckbox.indeterminate = false;
|
1253
|
+
} else {
|
1254
|
+
this.headerCheckbox.checked = false;
|
1255
|
+
this.headerCheckbox.indeterminate = true;
|
1256
|
+
}
|
1257
|
+
}
|
1258
|
+
|
1259
|
+
updateDeleteButton() {
|
1260
|
+
this.deleteSelectedBtn.disabled = this.selectedGames.size === 0;
|
1261
|
+
this.deleteSelectedBtn.textContent = this.selectedGames.size > 0
|
1262
|
+
? `Delete Selected Games (${this.selectedGames.size})`
|
1263
|
+
: 'Delete Selected Games';
|
1264
|
+
}
|
1265
|
+
|
1266
|
+
showConfirmation() {
|
1267
|
+
if (this.selectedGames.size === 0) return;
|
1268
|
+
|
1269
|
+
// Populate confirmation modal
|
1270
|
+
this.populateConfirmationModal();
|
1271
|
+
this.showModal('confirmationModal');
|
1272
|
+
}
|
1273
|
+
|
1274
|
+
populateConfirmationModal() {
|
1275
|
+
const selectedGameData = this.games.filter(game => this.selectedGames.has(game.name));
|
1276
|
+
|
1277
|
+
// Populate games list
|
1278
|
+
this.selectedGamesList.innerHTML = '';
|
1279
|
+
selectedGameData.forEach(game => {
|
1280
|
+
const gameItem = document.createElement('div');
|
1281
|
+
gameItem.className = 'game-item';
|
1282
|
+
gameItem.innerHTML = `
|
1283
|
+
<div>
|
1284
|
+
<div class="game-name">${escapeHtml(game.name)}</div>
|
1285
|
+
<div class="game-stats">${game.date_range}</div>
|
1286
|
+
</div>
|
1287
|
+
<div class="game-stats">
|
1288
|
+
${game.sentence_count} sentences, ${game.total_characters.toLocaleString()} chars
|
1289
|
+
</div>
|
1290
|
+
`;
|
1291
|
+
this.selectedGamesList.appendChild(gameItem);
|
1292
|
+
});
|
1293
|
+
|
1294
|
+
// Calculate totals
|
1295
|
+
const totalGames = selectedGameData.length;
|
1296
|
+
const totalSentences = selectedGameData.reduce((sum, game) => sum + game.sentence_count, 0);
|
1297
|
+
const totalCharacters = selectedGameData.reduce((sum, game) => sum + game.total_characters, 0);
|
1298
|
+
|
1299
|
+
this.totalGamesCount.textContent = totalGames;
|
1300
|
+
this.totalSentencesCount.textContent = totalSentences.toLocaleString();
|
1301
|
+
this.totalCharactersCount.textContent = totalCharacters.toLocaleString();
|
1302
|
+
}
|
1303
|
+
|
1304
|
+
async performDeletion() {
|
1305
|
+
this.hideModal('confirmationModal');
|
1306
|
+
this.showModal('progressModal');
|
1307
|
+
|
1308
|
+
// Show native confirmation as second stage
|
1309
|
+
const gameNames = Array.from(this.selectedGames);
|
1310
|
+
const confirmText = `Are you absolutely sure you want to delete ${gameNames.length} game(s)? This action cannot be undone.`;
|
1311
|
+
|
1312
|
+
if (!confirm(confirmText)) {
|
1313
|
+
this.hideModal('progressModal');
|
1314
|
+
return;
|
1315
|
+
}
|
1316
|
+
|
1317
|
+
try {
|
1318
|
+
this.progressText.textContent = `Deleting ${gameNames.length} games...`;
|
1319
|
+
|
1320
|
+
const response = await fetch('/api/delete-games', {
|
1321
|
+
method: 'POST',
|
1322
|
+
headers: {
|
1323
|
+
'Content-Type': 'application/json',
|
1324
|
+
},
|
1325
|
+
body: JSON.stringify({ game_names: gameNames })
|
1326
|
+
});
|
1327
|
+
|
1328
|
+
const result = await response.json();
|
1329
|
+
|
1330
|
+
this.hideModal('progressModal');
|
1331
|
+
this.showResult(result, response.status);
|
1332
|
+
|
1333
|
+
} catch (error) {
|
1334
|
+
console.error('Error deleting games:', error);
|
1335
|
+
this.hideModal('progressModal');
|
1336
|
+
this.showResult({ error: error.message }, 500);
|
1337
|
+
}
|
1338
|
+
}
|
1339
|
+
|
1340
|
+
showResult(result, status) {
|
1341
|
+
let title, content, isSuccess = false;
|
1342
|
+
|
1343
|
+
if (status === 200) {
|
1344
|
+
// Complete success
|
1345
|
+
title = 'Deletion Successful';
|
1346
|
+
isSuccess = true;
|
1347
|
+
content = `
|
1348
|
+
<div class="success-message">
|
1349
|
+
<p>✅ Successfully deleted ${result.successful_games.length} games!</p>
|
1350
|
+
<p><strong>Total sentences deleted:</strong> ${result.total_sentences_deleted.toLocaleString()}</p>
|
1351
|
+
</div>
|
1352
|
+
`;
|
1353
|
+
} else if (status === 207) {
|
1354
|
+
// Partial success
|
1355
|
+
title = 'Deletion Partially Successful';
|
1356
|
+
content = `
|
1357
|
+
<div class="warning-result">
|
1358
|
+
<p>⚠️ ${result.successful_games.length} games deleted successfully</p>
|
1359
|
+
<p>${result.failed_games.length} games failed to delete</p>
|
1360
|
+
<p><strong>Total sentences deleted:</strong> ${result.total_sentences_deleted.toLocaleString()}</p>
|
1361
|
+
</div>
|
1362
|
+
<div style="margin-top: 15px;">
|
1363
|
+
<strong>Failed games:</strong>
|
1364
|
+
<ul style="margin: 10px 0; padding-left: 20px;">
|
1365
|
+
${result.failed_games.map(game => `<li>${escapeHtml(game)}</li>`).join('')}
|
1366
|
+
</ul>
|
1367
|
+
</div>
|
1368
|
+
`;
|
1369
|
+
isSuccess = true; // Still refresh since some succeeded
|
1370
|
+
} else {
|
1371
|
+
// Complete failure
|
1372
|
+
title = 'Deletion Failed';
|
1373
|
+
content = `
|
1374
|
+
<div class="error-result">
|
1375
|
+
<p>❌ Failed to delete games</p>
|
1376
|
+
<p><strong>Error:</strong> ${escapeHtml(result.error || 'Unknown error occurred')}</p>
|
1377
|
+
</div>
|
1378
|
+
`;
|
1379
|
+
}
|
1380
|
+
|
1381
|
+
this.resultTitle.textContent = title;
|
1382
|
+
this.resultContent.innerHTML = content;
|
1383
|
+
this.showModal('resultModal');
|
1384
|
+
|
1385
|
+
// Auto-refresh if any deletions were successful
|
1386
|
+
if (isSuccess) {
|
1387
|
+
setTimeout(() => {
|
1388
|
+
this.hideModal('resultModal');
|
1389
|
+
window.location.reload();
|
1390
|
+
}, 3000);
|
1391
|
+
}
|
1392
|
+
}
|
1393
|
+
|
1394
|
+
showModal(modalId) {
|
1395
|
+
const modal = document.getElementById(modalId);
|
1396
|
+
modal.classList.add('show');
|
1397
|
+
modal.style.display = 'flex';
|
1398
|
+
}
|
1399
|
+
|
1400
|
+
hideModal(modalId) {
|
1401
|
+
const modal = document.getElementById(modalId);
|
1402
|
+
modal.classList.remove('show');
|
1403
|
+
modal.style.display = 'none';
|
1404
|
+
}
|
1405
|
+
|
1406
|
+
showLoading(show) {
|
1407
|
+
this.isLoading = show;
|
1408
|
+
if (show) {
|
1409
|
+
showElement(this.loadingIndicator);
|
1410
|
+
hideElement(this.gamesTableBody.parentElement);
|
1411
|
+
} else {
|
1412
|
+
hideElement(this.loadingIndicator);
|
1413
|
+
showElement(this.gamesTableBody.parentElement);
|
1414
|
+
}
|
1415
|
+
}
|
1416
|
+
|
1417
|
+
showError(message) {
|
1418
|
+
document.getElementById('errorText').textContent = message;
|
1419
|
+
showElement(this.errorMessage);
|
1420
|
+
}
|
1421
|
+
}
|
1422
|
+
|
1423
|
+
// Initialize the deletion manager
|
1424
|
+
if (document.getElementById('gamesTableBody')) {
|
1425
|
+
new GameDeletionManager();
|
1426
|
+
}
|
1427
|
+
});
|