GameSentenceMiner 2.17.7__py3-none-any.whl → 2.18.0__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.
Files changed (51) hide show
  1. GameSentenceMiner/ai/ai_prompting.py +6 -6
  2. GameSentenceMiner/anki.py +236 -152
  3. GameSentenceMiner/gametext.py +7 -4
  4. GameSentenceMiner/gsm.py +49 -10
  5. GameSentenceMiner/locales/en_us.json +7 -3
  6. GameSentenceMiner/locales/ja_jp.json +8 -4
  7. GameSentenceMiner/locales/zh_cn.json +8 -4
  8. GameSentenceMiner/obs.py +238 -59
  9. GameSentenceMiner/ocr/owocr_helper.py +1 -1
  10. GameSentenceMiner/tools/ss_selector.py +7 -8
  11. GameSentenceMiner/ui/__init__.py +0 -0
  12. GameSentenceMiner/ui/anki_confirmation.py +187 -0
  13. GameSentenceMiner/{config_gui.py → ui/config_gui.py} +100 -35
  14. GameSentenceMiner/ui/screenshot_selector.py +215 -0
  15. GameSentenceMiner/util/configuration.py +124 -22
  16. GameSentenceMiner/util/db.py +22 -13
  17. GameSentenceMiner/util/downloader/download_tools.py +2 -2
  18. GameSentenceMiner/util/ffmpeg.py +24 -30
  19. GameSentenceMiner/util/get_overlay_coords.py +34 -34
  20. GameSentenceMiner/util/gsm_utils.py +31 -1
  21. GameSentenceMiner/util/text_log.py +11 -9
  22. GameSentenceMiner/vad.py +31 -12
  23. GameSentenceMiner/web/database_api.py +742 -123
  24. GameSentenceMiner/web/static/css/dashboard-shared.css +241 -0
  25. GameSentenceMiner/web/static/css/kanji-grid.css +94 -2
  26. GameSentenceMiner/web/static/css/overview.css +850 -0
  27. GameSentenceMiner/web/static/css/popups-shared.css +126 -0
  28. GameSentenceMiner/web/static/css/shared.css +97 -0
  29. GameSentenceMiner/web/static/css/stats.css +192 -597
  30. GameSentenceMiner/web/static/js/anki_stats.js +6 -4
  31. GameSentenceMiner/web/static/js/database.js +209 -5
  32. GameSentenceMiner/web/static/js/goals.js +610 -0
  33. GameSentenceMiner/web/static/js/kanji-grid.js +267 -4
  34. GameSentenceMiner/web/static/js/overview.js +1176 -0
  35. GameSentenceMiner/web/static/js/shared.js +25 -0
  36. GameSentenceMiner/web/static/js/stats.js +154 -1459
  37. GameSentenceMiner/web/stats.py +2 -2
  38. GameSentenceMiner/web/templates/anki_stats.html +5 -0
  39. GameSentenceMiner/web/templates/components/navigation.html +3 -1
  40. GameSentenceMiner/web/templates/database.html +73 -1
  41. GameSentenceMiner/web/templates/goals.html +376 -0
  42. GameSentenceMiner/web/templates/index.html +13 -11
  43. GameSentenceMiner/web/templates/overview.html +416 -0
  44. GameSentenceMiner/web/templates/stats.html +46 -251
  45. GameSentenceMiner/web/texthooking_page.py +18 -0
  46. {gamesentenceminer-2.17.7.dist-info → gamesentenceminer-2.18.0.dist-info}/METADATA +5 -1
  47. {gamesentenceminer-2.17.7.dist-info → gamesentenceminer-2.18.0.dist-info}/RECORD +51 -41
  48. {gamesentenceminer-2.17.7.dist-info → gamesentenceminer-2.18.0.dist-info}/WHEEL +0 -0
  49. {gamesentenceminer-2.17.7.dist-info → gamesentenceminer-2.18.0.dist-info}/entry_points.txt +0 -0
  50. {gamesentenceminer-2.17.7.dist-info → gamesentenceminer-2.18.0.dist-info}/licenses/LICENSE +0 -0
  51. {gamesentenceminer-2.17.7.dist-info → gamesentenceminer-2.18.0.dist-info}/top_level.txt +0 -0
@@ -50,14 +50,17 @@ document.addEventListener('DOMContentLoaded', function () {
50
50
 
51
51
  // Helper function to create a chart to avoid repeating code
52
52
  function createChart(canvasId, datasets, chartTitle) {
53
- const ctx = document.getElementById(canvasId).getContext('2d');
53
+ const ctx = document.getElementById(canvasId);
54
+ if (!ctx) return null; // Add null check
55
+
56
+ const context = ctx.getContext('2d');
54
57
 
55
58
  // Destroy existing chart on this canvas if it exists
56
59
  if (window.myCharts[canvasId]) {
57
60
  window.myCharts[canvasId].destroy();
58
61
  }
59
62
 
60
- window.myCharts[canvasId] = new Chart(ctx, {
63
+ window.myCharts[canvasId] = new Chart(context, {
61
64
  type: 'line',
62
65
  data: {
63
66
  labels: datasets.labels,
@@ -106,339 +109,6 @@ document.addEventListener('DOMContentLoaded', function () {
106
109
  return window.myCharts[canvasId];
107
110
  }
108
111
 
109
- // Helper function to get week number of year (GitHub style - week starts on Sunday)
110
- function getWeekOfYear(date) {
111
- const yearStart = new Date(date.getFullYear(), 0, 1);
112
- const dayOfYear = Math.floor((date - yearStart) / (24 * 60 * 60 * 1000)) + 1;
113
- const dayOfWeek = yearStart.getDay(); // 0 = Sunday
114
-
115
- // Calculate week number (1-indexed)
116
- const weekNum = Math.ceil((dayOfYear + dayOfWeek) / 7);
117
- return Math.min(53, weekNum); // Cap at 53 weeks
118
- }
119
-
120
- // Helper function to get day of week (0 = Sunday, 6 = Saturday)
121
- function getDayOfWeek(date) {
122
- return date.getDay();
123
- }
124
-
125
- // Helper function to get the first Sunday of the year (or before)
126
- function getFirstSunday(year) {
127
- const jan1 = new Date(year, 0, 1);
128
- const dayOfWeek = jan1.getDay();
129
- const firstSunday = new Date(year, 0, 1 - dayOfWeek);
130
- return firstSunday;
131
- }
132
-
133
- // Function to calculate heatmap streaks and average daily time
134
- function calculateHeatmapStreaks(grid, yearData, allLinesForYear = []) {
135
- const dates = [];
136
-
137
- // Collect all dates in chronological order
138
- for (let week = 0; week < 53; week++) {
139
- for (let day = 0; day < 7; day++) {
140
- const date = grid[day][week];
141
- if (date) {
142
- const dateStr = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
143
- const activity = yearData[dateStr] || 0;
144
- dates.push({ date: dateStr, activity: activity });
145
- }
146
- }
147
- }
148
-
149
- // Sort dates chronologically
150
- dates.sort((a, b) => new Date(a.date) - new Date(b.date));
151
-
152
-
153
- let longestStreak = 0;
154
- let currentStreak = 0;
155
- let tempStreak = 0;
156
-
157
- // Calculate longest streak
158
- for (let i = 0; i < dates.length; i++) {
159
- if (dates[i].activity > 0) {
160
- tempStreak++;
161
- longestStreak = Math.max(longestStreak, tempStreak);
162
- } else {
163
- tempStreak = 0;
164
- }
165
- }
166
-
167
- // Calculate current streak from today backwards, using streak requirement hours from config
168
- const date = new Date();
169
- const today = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
170
- const streakRequirement = window.statsConfig ? window.statsConfig.streakRequirementHours : 1.0;
171
-
172
- // Find today's index or the most recent date before today
173
- let todayIndex = -1;
174
- for (let i = dates.length - 1; i >= 0; i--) {
175
- if (dates[i].date <= today) {
176
- todayIndex = i;
177
- break;
178
- }
179
- }
180
-
181
- // Count backwards from today (or most recent date)
182
- if (todayIndex >= 0) {
183
- for (let i = todayIndex; i >= 0; i--) {
184
- if (dates[i].activity >= streakRequirement) {
185
- currentStreak++;
186
- } else {
187
- break;
188
- }
189
- }
190
- }
191
-
192
- // Calculate average daily time for this year
193
- let avgDailyTime = "-";
194
- if (allLinesForYear && allLinesForYear.length > 0) {
195
- // Group timestamps by day for this year
196
- const dailyTimestamps = {};
197
- for (const line of allLinesForYear) {
198
- const ts = parseFloat(line.timestamp);
199
- if (isNaN(ts)) continue;
200
- const dateObj = new Date(ts * 1000);
201
- const dateStr = `${dateObj.getFullYear()}-${String(dateObj.getMonth() + 1).padStart(2, '0')}-${String(dateObj.getDate()).padStart(2, '0')}`;
202
- if (!dailyTimestamps[dateStr]) {
203
- dailyTimestamps[dateStr] = [];
204
- }
205
- dailyTimestamps[dateStr].push(parseFloat(line.timestamp));
206
- }
207
-
208
- // Calculate reading time for each day with activity
209
- let totalHours = 0;
210
- let activeDays = 0;
211
- let afkTimerSeconds = window.statsConfig ? window.statsConfig.afkTimerSeconds : 120;
212
- // Try to get AFK timer from settings modal if available and valid
213
- const afkTimerInput = document.getElementById('afkTimer');
214
- if (afkTimerInput && afkTimerInput.value) {
215
- const parsed = parseInt(afkTimerInput.value, 10);
216
- if (!isNaN(parsed) && parsed > 0) afkTimerSeconds = parsed;
217
- }
218
-
219
- for (const [dateStr, timestamps] of Object.entries(dailyTimestamps)) {
220
- if (timestamps.length >= 2) {
221
- timestamps.sort((a, b) => a - b);
222
- let dayReadingTime = 0;
223
-
224
- for (let i = 1; i < timestamps.length; i++) {
225
- const gap = timestamps[i] - timestamps[i-1];
226
- dayReadingTime += Math.min(gap, afkTimerSeconds);
227
- }
228
-
229
- if (dayReadingTime > 0) {
230
- totalHours += dayReadingTime / 3600;
231
- activeDays++;
232
- }
233
- } else if (timestamps.length === 1) {
234
- // Single timestamp - count as minimal activity (1 second)
235
- totalHours += 1 / 3600;
236
- activeDays++;
237
- }
238
- }
239
-
240
- if (activeDays > 0) {
241
- const avgHours = totalHours / activeDays;
242
- if (avgHours < 1) {
243
- const minutes = Math.round(avgHours * 60);
244
- avgDailyTime = `${minutes}m`;
245
- } else {
246
- const hours = Math.floor(avgHours);
247
- const minutes = Math.round((avgHours - hours) * 60);
248
- avgDailyTime = minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`;
249
- }
250
- }
251
- }
252
-
253
- return { longestStreak, currentStreak, avgDailyTime };
254
- }
255
-
256
- // Function to create GitHub-style heatmap
257
- function createHeatmap(heatmapData) {
258
- const container = document.getElementById('heatmapContainer');
259
-
260
- Object.keys(heatmapData).sort().forEach(year => {
261
- const yearData = heatmapData[year];
262
- const yearDiv = document.createElement('div');
263
- yearDiv.className = 'heatmap-year';
264
-
265
- const yearTitle = document.createElement('h3');
266
- yearTitle.textContent = year;
267
- yearDiv.appendChild(yearTitle);
268
-
269
- // Find maximum activity value for this year to scale colors
270
- const maxActivity = Math.max(...Object.values(yearData));
271
-
272
- // Create main wrapper to center everything
273
- const mainWrapper = document.createElement('div');
274
- mainWrapper.className = 'heatmap-wrapper';
275
-
276
- // Create container wrapper for labels and grid
277
- const containerWrapper = document.createElement('div');
278
- containerWrapper.className = 'heatmap-container-wrapper';
279
-
280
- // Create day labels (S, M, T, W, T, F, S)
281
- const dayLabels = document.createElement('div');
282
- dayLabels.className = 'heatmap-day-labels';
283
- const dayNames = ['S', '', 'M', '', 'W', '', 'F']; // Only show some labels for space
284
- dayNames.forEach(dayName => {
285
- const dayLabel = document.createElement('div');
286
- dayLabel.className = 'heatmap-day-label';
287
- dayLabel.textContent = dayName;
288
- dayLabels.appendChild(dayLabel);
289
- });
290
-
291
- // Create grid container
292
- const gridContainer = document.createElement('div');
293
-
294
- // Create month labels
295
- const monthLabels = document.createElement('div');
296
- monthLabels.className = 'heatmap-month-labels';
297
-
298
- // Create the main grid
299
- const gridDiv = document.createElement('div');
300
- gridDiv.className = 'heatmap-grid';
301
-
302
- // Initialize 7x53 grid with empty cells
303
- const grid = Array(7).fill(null).map(() => Array(53).fill(null));
304
-
305
- // Get the first Sunday of the year (start of week 1)
306
- const firstSunday = getFirstSunday(parseInt(year));
307
-
308
- // Populate grid with dates for the entire year
309
- for (let week = 0; week < 53; week++) {
310
- for (let day = 0; day < 7; day++) {
311
- const currentDate = new Date(firstSunday);
312
- currentDate.setDate(firstSunday.getDate() + (week * 7) + day);
313
-
314
- // Only include dates that belong to the current year
315
- if (currentDate.getFullYear() === parseInt(year)) {
316
- grid[day][week] = currentDate;
317
- }
318
- }
319
- }
320
-
321
- // Create month labels based on grid positions
322
- const monthTracker = new Set();
323
- for (let week = 0; week < 53; week++) {
324
- const dateInWeek = grid[0][week] || grid[1][week] || grid[2][week] ||
325
- grid[3][week] || grid[4][week] || grid[5][week] || grid[6][week];
326
-
327
- if (dateInWeek) {
328
- const month = dateInWeek.getMonth();
329
- const monthName = dateInWeek.toLocaleDateString('en', { month: 'short' });
330
-
331
- // Add month label if it's the first week of the month
332
- if (!monthTracker.has(month) && dateInWeek.getDate() <= 7) {
333
- const monthLabel = document.createElement('div');
334
- monthLabel.className = 'heatmap-month-label';
335
- monthLabel.style.gridColumn = `${week + 1}`;
336
- monthLabel.textContent = monthName;
337
- monthLabels.appendChild(monthLabel);
338
- monthTracker.add(month);
339
- }
340
- }
341
- }
342
-
343
- // Create cells for the grid
344
- for (let day = 0; day < 7; day++) {
345
- for (let week = 0; week < 53; week++) {
346
- const cell = document.createElement('div');
347
- cell.className = 'heatmap-cell';
348
-
349
- const date = grid[day][week];
350
- if (date) {
351
- const dateStr = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
352
- const activity = yearData[dateStr] || 0;
353
-
354
- if (activity > 0 && maxActivity > 0) {
355
- // Calculate percentage of maximum activity
356
- const percentage = (activity / maxActivity) * 100;
357
-
358
- // Assign discrete color levels based on percentage thresholds
359
- let colorLevel;
360
- if (percentage <= 25) {
361
- colorLevel = 1; // Light green
362
- } else if (percentage <= 50) {
363
- colorLevel = 2; // Medium green
364
- } else if (percentage <= 75) {
365
- colorLevel = 3; // Dark green
366
- } else {
367
- colorLevel = 4; // Darkest green
368
- }
369
-
370
- // Define discrete colors for each level
371
- const colors = {
372
- 1: '#c6e48b', // Light green (1-25%)
373
- 2: '#7bc96f', // Medium green (26-50%)
374
- 3: '#239a3b', // Dark green (51-75%)
375
- 4: '#196127' // Darkest green (76-100%)
376
- };
377
-
378
- cell.style.backgroundColor = colors[colorLevel];
379
- }
380
-
381
- cell.title = `${dateStr}: ${activity} characters`;
382
- } else {
383
- // Empty cell for dates outside the year
384
- cell.style.backgroundColor = 'transparent';
385
- cell.style.cursor = 'default';
386
- }
387
-
388
- gridDiv.appendChild(cell);
389
- }
390
- }
391
-
392
- gridContainer.appendChild(monthLabels);
393
- gridContainer.appendChild(gridDiv);
394
- containerWrapper.appendChild(dayLabels);
395
- containerWrapper.appendChild(gridContainer);
396
- mainWrapper.appendChild(containerWrapper);
397
-
398
- // Calculate and display streaks with average daily time
399
- const yearLines = window.allLinesData ? window.allLinesData.filter(line => {
400
- if (!line.timestamp) return false;
401
- const lineYear = new Date(parseFloat(line.timestamp) * 1000).getFullYear();
402
- return lineYear === parseInt(year);
403
- }) : [];
404
-
405
- const streaks = calculateHeatmapStreaks(grid, yearData, yearLines);
406
- const streaksDiv = document.createElement('div');
407
- streaksDiv.className = 'heatmap-streaks';
408
- streaksDiv.innerHTML = `
409
- <div class="heatmap-streak-item">
410
- <div class="heatmap-streak-number">${streaks.longestStreak}</div>
411
- <div class="heatmap-streak-label">Longest Streak</div>
412
- </div>
413
- <div class="heatmap-streak-item">
414
- <div class="heatmap-streak-number">${streaks.currentStreak}</div>
415
- <div class="heatmap-streak-label">Current Streak</div>
416
- </div>
417
- <div class="heatmap-streak-item">
418
- <div class="heatmap-streak-number">${streaks.avgDailyTime}</div>
419
- <div class="heatmap-streak-label">Avg Daily Time</div>
420
- </div>
421
- `;
422
- mainWrapper.appendChild(streaksDiv);
423
- yearDiv.appendChild(mainWrapper);
424
-
425
- // Add legend with discrete colors
426
- const legend = document.createElement('div');
427
- legend.className = 'heatmap-legend';
428
- legend.innerHTML = `
429
- <span>Less</span>
430
- <div class="heatmap-legend-item" style="background-color: #ebedf0;" title="No activity"></div>
431
- <div class="heatmap-legend-item" style="background-color: #c6e48b;" title="1-25% of max activity"></div>
432
- <div class="heatmap-legend-item" style="background-color: #7bc96f;" title="26-50% of max activity"></div>
433
- <div class="heatmap-legend-item" style="background-color: #239a3b;" title="51-75% of max activity"></div>
434
- <div class="heatmap-legend-item" style="background-color: #196127;" title="76-100% of max activity"></div>
435
- <span>More</span>
436
- `;
437
- yearDiv.appendChild(legend);
438
-
439
- container.appendChild(yearDiv);
440
- });
441
- }
442
112
 
443
113
  // Function to generate distinct colors for games
444
114
  function generateGameColors(gameCount) {
@@ -496,8 +166,11 @@ document.addEventListener('DOMContentLoaded', function () {
496
166
  }
497
167
 
498
168
  // Reusable function to create game bar charts with interactive legend
499
- function createGameBarChart(canvasId, chartData, chartTitle, yAxisLabel) {
500
- const ctx = document.getElementById(canvasId).getContext('2d');
169
+ function createGameBarChart(canvasId, chartData, chartTitle, yAxisLabel, showTrendline = false) {
170
+ const canvas = document.getElementById(canvasId);
171
+ if (!canvas) return null; // Add null check
172
+
173
+ const ctx = canvas.getContext('2d');
501
174
  const colors = generateGameColors(chartData.labels.length);
502
175
 
503
176
  // Track which bars are hidden for toggle functionality
@@ -513,22 +186,49 @@ document.addEventListener('DOMContentLoaded', function () {
513
186
  return getFilteredChartData(originalData, hiddenBars, colors);
514
187
  }
515
188
 
189
+ // Calculate trendline if requested
190
+ let trendlineData = null;
191
+ if (showTrendline && chartData.totals.length > 1) {
192
+ const trendline = calculateTrendline(chartData.totals);
193
+ trendlineData = trendline.points;
194
+ }
195
+
516
196
  // Destroy existing chart on this canvas if it exists
517
197
  if (window.myCharts[canvasId]) {
518
198
  window.myCharts[canvasId].destroy();
519
199
  }
520
200
 
201
+ // Build datasets array
202
+ const datasets = [{
203
+ label: chartTitle,
204
+ data: chartData.totals,
205
+ backgroundColor: colors.map(color => color + '99'), // Semi-transparent
206
+ borderColor: colors,
207
+ borderWidth: 2,
208
+ order: 2
209
+ }];
210
+
211
+ // Add trendline dataset if available
212
+ if (trendlineData) {
213
+ datasets.push({
214
+ label: 'Trendline',
215
+ data: trendlineData,
216
+ type: 'line',
217
+ borderColor: '#ff6384',
218
+ borderWidth: 3,
219
+ borderDash: [5, 5],
220
+ fill: false,
221
+ pointRadius: 0,
222
+ pointHoverRadius: 0,
223
+ order: 1
224
+ });
225
+ }
226
+
521
227
  window.myCharts[canvasId] = new Chart(ctx, {
522
228
  type: 'bar',
523
229
  data: {
524
230
  labels: chartData.labels, // Each game as a separate label
525
- datasets: [{
526
- label: chartTitle,
527
- data: chartData.totals,
528
- backgroundColor: colors.map(color => color + '99'), // Semi-transparent
529
- borderColor: colors,
530
- borderWidth: 2
531
- }]
231
+ datasets: datasets
532
232
  },
533
233
  options: {
534
234
  responsive: true,
@@ -624,8 +324,11 @@ document.addEventListener('DOMContentLoaded', function () {
624
324
  }
625
325
 
626
326
  // Specialized function for charts with custom formatting (time/speed)
627
- function createGameBarChartWithCustomFormat(canvasId, chartData, chartTitle, yAxisLabel, formatFunction) {
628
- const ctx = document.getElementById(canvasId).getContext('2d');
327
+ function createGameBarChartWithCustomFormat(canvasId, chartData, chartTitle, yAxisLabel, formatFunction, showTrendline = false) {
328
+ const canvas = document.getElementById(canvasId);
329
+ if (!canvas) return null; // Add null check
330
+
331
+ const ctx = canvas.getContext('2d');
629
332
  const colors = generateGameColors(chartData.labels.length);
630
333
 
631
334
  // Track which bars are hidden for toggle functionality
@@ -641,23 +344,50 @@ document.addEventListener('DOMContentLoaded', function () {
641
344
  return getFilteredChartData(originalData, hiddenBars, colors);
642
345
  }
643
346
 
347
+ // Calculate trendline if requested
348
+ let trendlineData = null;
349
+ if (showTrendline && chartData.totals.length > 1) {
350
+ const trendline = calculateTrendline(chartData.totals);
351
+ trendlineData = trendline.points;
352
+ }
353
+
644
354
  // Destroy existing chart if it exists
645
355
  if (window.myCharts[canvasId]) {
646
356
  window.myCharts[canvasId].destroy();
647
357
  }
648
358
 
359
+ // Build datasets array
360
+ const datasets = [{
361
+ label: chartTitle,
362
+ data: chartData.totals,
363
+ backgroundColor: colors.map(color => color + '99'), // Semi-transparent
364
+ borderColor: colors,
365
+ borderWidth: 2,
366
+ order: 2
367
+ }];
368
+
369
+ // Add trendline dataset if available
370
+ if (trendlineData) {
371
+ datasets.push({
372
+ label: 'Trendline',
373
+ data: trendlineData,
374
+ type: 'line',
375
+ borderColor: '#ff6384',
376
+ borderWidth: 3,
377
+ borderDash: [5, 5],
378
+ fill: false,
379
+ pointRadius: 0,
380
+ pointHoverRadius: 0,
381
+ order: 1
382
+ });
383
+ }
384
+
649
385
  // Create new chart and store globally
650
386
  window.myCharts[canvasId] = new Chart(ctx, {
651
387
  type: 'bar',
652
388
  data: {
653
389
  labels: chartData.labels, // Each game as a separate label
654
- datasets: [{
655
- label: chartTitle,
656
- data: chartData.totals,
657
- backgroundColor: colors.map(color => color + '99'), // Semi-transparent
658
- borderColor: colors,
659
- borderWidth: 2
660
- }]
390
+ datasets: datasets
661
391
  },
662
392
  options: {
663
393
  responsive: true,
@@ -751,6 +481,38 @@ document.addEventListener('DOMContentLoaded', function () {
751
481
  return window.myCharts[canvasId];
752
482
  }
753
483
 
484
+ // Helper function to calculate linear regression trendline
485
+ function calculateTrendline(data) {
486
+ const n = data.length;
487
+ if (n === 0) return { slope: 0, intercept: 0, points: [] };
488
+
489
+ // Calculate means
490
+ let sumX = 0, sumY = 0;
491
+ for (let i = 0; i < n; i++) {
492
+ sumX += i;
493
+ sumY += data[i];
494
+ }
495
+ const meanX = sumX / n;
496
+ const meanY = sumY / n;
497
+
498
+ // Calculate slope
499
+ let numerator = 0, denominator = 0;
500
+ for (let i = 0; i < n; i++) {
501
+ numerator += (i - meanX) * (data[i] - meanY);
502
+ denominator += (i - meanX) * (i - meanX);
503
+ }
504
+ const slope = denominator !== 0 ? numerator / denominator : 0;
505
+ const intercept = meanY - slope * meanX;
506
+
507
+ // Generate trendline points
508
+ const points = [];
509
+ for (let i = 0; i < n; i++) {
510
+ points.push(slope * i + intercept);
511
+ }
512
+
513
+ return { slope, intercept, points };
514
+ }
515
+
754
516
  // Helper functions for formatting
755
517
  function formatTime(hours) {
756
518
  if (hours < 1) {
@@ -779,12 +541,17 @@ document.addEventListener('DOMContentLoaded', function () {
779
541
  }
780
542
 
781
543
  function showNoDataPopup() {
782
- document.getElementById("noDataPopup").classList.remove("hidden");
544
+ const popup = document.getElementById("noDataPopup");
545
+ if (popup) popup.classList.remove("hidden");
783
546
  }
784
547
 
785
- document.getElementById("closeNoDataPopup").addEventListener("click", () => {
786
- document.getElementById("noDataPopup").classList.add("hidden");
787
- });
548
+ const closeNoDataPopup = document.getElementById("closeNoDataPopup");
549
+ if (closeNoDataPopup) {
550
+ closeNoDataPopup.addEventListener("click", () => {
551
+ const popup = document.getElementById("noDataPopup");
552
+ if (popup) popup.classList.add("hidden");
553
+ });
554
+ }
788
555
 
789
556
  // Function to load stats data with optional year filter
790
557
  function loadStatsData(start_timestamp = null, end_timestamp = null) {
@@ -805,11 +572,10 @@ document.addEventListener('DOMContentLoaded', function () {
805
572
  return fetch(url)
806
573
  .then(response => response.json())
807
574
  .then(data => {
808
- // Store all lines data globally for heatmap calculations
575
+ // Store all lines data globally for potential future use
809
576
  if (data.allLinesData && Array.isArray(data.allLinesData)) {
810
577
  window.allLinesData = data.allLinesData;
811
578
  } else {
812
- // If not provided by API, we'll work without it
813
579
  window.allLinesData = [];
814
580
  }
815
581
 
@@ -834,22 +600,22 @@ document.addEventListener('DOMContentLoaded', function () {
834
600
  [...charsData.datasets].forEach(d => delete d.hidden);
835
601
 
836
602
  // Charts are re-created with the new data
837
- createChart('linesChart', linesData, 'Cumulative Lines Received');
838
- createChart('charsChart', charsData, 'Cumulative Characters Read');
603
+ createChart('linesChart', linesData, 'Cumulative Lines Received');
604
+ createChart('charsChart', charsData, 'Cumulative Characters Read');
839
605
 
840
- // Create reading chars quantity chart if data exists
606
+ // Create reading chars quantity chart if data exists (with trendline)
841
607
  if (data.totalCharsPerGame) {
842
- createGameBarChart('readingCharsChart', data.totalCharsPerGame, 'Reading Chars Quantity', 'Characters Read');
608
+ createGameBarChart('readingCharsChart', data.totalCharsPerGame, 'Reading Chars Quantity', 'Characters Read', true);
843
609
  }
844
610
 
845
- // Create reading time quantity chart if data exists
611
+ // Create reading time quantity chart if data exists (with trendline)
846
612
  if (data.readingTimePerGame) {
847
- createGameBarChartWithCustomFormat('readingTimeChart', data.readingTimePerGame, 'Reading Time Quantity', 'Time (hours)', formatTime);
613
+ createGameBarChartWithCustomFormat('readingTimeChart', data.readingTimePerGame, 'Reading Time Quantity', 'Time (hours)', formatTime, true);
848
614
  }
849
615
 
850
- // Create reading speed per game chart if data exists
616
+ // Create reading speed per game chart if data exists (with trendline)
851
617
  if (data.readingSpeedPerGame) {
852
- createGameBarChartWithCustomFormat('readingSpeedPerGameChart', data.readingSpeedPerGame, 'Reading Speed Improvement', 'Speed (chars/hour)', formatSpeed);
618
+ createGameBarChartWithCustomFormat('readingSpeedPerGameChart', data.readingSpeedPerGame, 'Reading Speed Improvement', 'Speed (chars/hour)', formatSpeed, true);
853
619
  }
854
620
 
855
621
  // Create kanji grid if data exists
@@ -857,306 +623,14 @@ document.addEventListener('DOMContentLoaded', function () {
857
623
  createKanjiGrid(data.kanjiGridData);
858
624
  }
859
625
 
860
-
861
- // Always update heatmap
862
- if (data.heatmapData) {
863
- const container = document.getElementById('heatmapContainer');
864
- container.innerHTML = '';
865
- createHeatmap(data.heatmapData);
866
- }
867
-
868
- // Load dashboard data
869
- loadDashboardData(data, end_timestamp);
870
-
871
- // Load goal progress chart (always refresh)
872
- if (typeof loadGoalProgress === 'function') {
873
- // Use the current data instead of making another API call
874
- updateGoalProgressWithData(data);
875
- }
876
-
877
626
  return data;
878
627
  })
879
628
  .catch(error => {
880
629
  console.error('Error fetching chart data:', error);
881
- showDashboardError();
882
630
  throw error;
883
631
  });
884
632
  }
885
633
 
886
- // Goal Progress Chart functionality
887
- let goalSettings = window.statsConfig || {};
888
- if (!goalSettings.reading_hours_target) goalSettings.reading_hours_target = 1500;
889
- if (!goalSettings.character_count_target) goalSettings.character_count_target = 25000000;
890
- if (!goalSettings.games_target) goalSettings.games_target = 100;
891
-
892
- // Function to load goal settings from API (fallback)
893
- async function loadGoalSettings() {
894
- // Use global config if available, otherwise fetch
895
- if (window.statsConfig) {
896
- goalSettings.reading_hours_target = window.statsConfig.readingHoursTarget || 1500;
897
- goalSettings.character_count_target = window.statsConfig.characterCountTarget || 25000000;
898
- goalSettings.games_target = window.statsConfig.gamesTarget || 100;
899
- return;
900
- }
901
- try {
902
- const response = await fetch('/api/settings');
903
- if (response.ok) {
904
- const settings = await response.json();
905
- goalSettings = {
906
- reading_hours_target: settings.reading_hours_target || 1500,
907
- character_count_target: settings.character_count_target || 25000000,
908
- games_target: settings.games_target || 100
909
- };
910
- }
911
- } catch (error) {
912
- console.error('Error loading goal settings:', error);
913
- }
914
- }
915
-
916
- // Function to calculate 90-day rolling average for projections
917
- function calculate90DayAverage(allLinesData, metricType) {
918
- if (!allLinesData || allLinesData.length === 0) {
919
- return 0;
920
- }
921
-
922
- const today = new Date();
923
- const ninetyDaysAgo = new Date(today.getTime() - (90 * 24 * 60 * 60 * 1000));
924
-
925
- // Filter data to last 90 days
926
- const recentData = allLinesData.filter(line => {
927
- const lineDate = new Date(line.timestamp * 1000);
928
- return lineDate >= ninetyDaysAgo && lineDate <= today;
929
- });
930
-
931
- if (recentData.length === 0) {
932
- return 0;
933
- }
934
-
935
- let dailyTotals = {};
936
-
937
- if (metricType === 'hours') {
938
- // Group by day and calculate reading time using AFK timer logic
939
- const dailyTimestamps = {};
940
- for (const line of recentData) {
941
- const ts = parseFloat(line.timestamp);
942
- if (isNaN(ts)) continue;
943
- const dateObj = new Date(ts * 1000);
944
- const dateStr = `${dateObj.getFullYear()}-${String(dateObj.getMonth() + 1).padStart(2, '0')}-${String(dateObj.getDate()).padStart(2, '0')}`;
945
- if (!dailyTimestamps[dateStr]) {
946
- dailyTimestamps[dateStr] = [];
947
- }
948
- dailyTimestamps[dateStr].push(ts);
949
- }
950
-
951
- for (const [dateStr, timestamps] of Object.entries(dailyTimestamps)) {
952
- if (timestamps.length >= 2) {
953
- timestamps.sort((a, b) => a - b);
954
- let dayHours = 0;
955
- let afkTimerSeconds = window.statsConfig ? window.statsConfig.afkTimerSeconds : 120;
956
- // Try to get AFK timer from settings modal if available and valid
957
- const afkTimerInput = document.getElementById('afkTimer');
958
- if (afkTimerInput && afkTimerInput.value) {
959
- const parsed = parseInt(afkTimerInput.value, 10);
960
- if (!isNaN(parsed) && parsed > 0) afkTimerSeconds = parsed;
961
- }
962
-
963
- for (let i = 1; i < timestamps.length; i++) {
964
- const gap = timestamps[i] - timestamps[i-1];
965
- dayHours += Math.min(gap, afkTimerSeconds) / 3600;
966
- }
967
- dailyTotals[dateStr] = dayHours;
968
- } else if (timestamps.length === 1) {
969
- dailyTotals[dateStr] = 1 / 3600; // Minimal activity
970
- }
971
- }
972
- } else if (metricType === 'characters') {
973
- // Group by day and sum characters
974
- for (const line of recentData) {
975
- const ts = parseFloat(line.timestamp);
976
- if (isNaN(ts)) continue;
977
- const dateObj = new Date(ts * 1000);
978
- const dateStr = `${dateObj.getFullYear()}-${String(dateObj.getMonth() + 1).padStart(2, '0')}-${String(dateObj.getDate()).padStart(2, '0')}`;
979
- dailyTotals[dateStr] = (dailyTotals[dateStr] || 0) + (line.characters || 0);
980
- }
981
- } else if (metricType === 'games') {
982
- // Group by day and count unique games
983
- const dailyGames = {};
984
- for (const line of recentData) {
985
- const ts = parseFloat(line.timestamp);
986
- if (isNaN(ts)) continue;
987
- const dateObj = new Date(ts * 1000);
988
- const dateStr = `${dateObj.getFullYear()}-${String(dateObj.getMonth() + 1).padStart(2, '0')}-${String(dateObj.getDate()).padStart(2, '0')}`;
989
- if (!dailyGames[dateStr]) {
990
- dailyGames[dateStr] = new Set();
991
- }
992
- dailyGames[dateStr].add(line.game_name);
993
- }
994
-
995
- for (const [dateStr, gamesSet] of Object.entries(dailyGames)) {
996
- dailyTotals[dateStr] = gamesSet.size;
997
- }
998
- }
999
-
1000
- const totalDays = Object.keys(dailyTotals).length;
1001
- if (totalDays === 0) {
1002
- return 0;
1003
- }
1004
-
1005
- const totalValue = Object.values(dailyTotals).reduce((sum, value) => sum + value, 0);
1006
- return totalValue / totalDays;
1007
- }
1008
-
1009
- // Function to format projection text
1010
- function formatProjection(currentValue, targetValue, dailyAverage, metricType) {
1011
- if (currentValue >= targetValue) {
1012
- return 'Goal achieved! 🎉';
1013
- }
1014
-
1015
- if (dailyAverage <= 0) {
1016
- return 'No recent activity';
1017
- }
1018
-
1019
- const remaining = targetValue - currentValue;
1020
- const daysToComplete = Math.ceil(remaining / dailyAverage);
1021
-
1022
- if (daysToComplete <= 0) {
1023
- return 'Goal achieved! 🎉';
1024
- } else if (daysToComplete === 1) {
1025
- return '~1 day remaining';
1026
- } else if (daysToComplete <= 7) {
1027
- return `~${daysToComplete} days remaining`;
1028
- } else if (daysToComplete <= 30) {
1029
- const weeks = Math.ceil(daysToComplete / 7);
1030
- return `~${weeks} week${weeks > 1 ? 's' : ''} remaining`;
1031
- } else if (daysToComplete <= 365) {
1032
- const months = Math.ceil(daysToComplete / 30);
1033
- return `~${months} month${months > 1 ? 's' : ''} remaining`;
1034
- } else {
1035
- const years = Math.ceil(daysToComplete / 365);
1036
- return `~${years} year${years > 1 ? 's' : ''} remaining`;
1037
- }
1038
- }
1039
-
1040
- // Function to format large numbers
1041
- function formatGoalNumber(num) {
1042
- if (num >= 1000000) {
1043
- return (num / 1000000).toFixed(1) + 'M';
1044
- } else if (num >= 1000) {
1045
- return (num / 1000).toFixed(1) + 'K';
1046
- }
1047
- return num.toString();
1048
- }
1049
-
1050
- // Function to update progress bar color based on percentage
1051
- function updateProgressBarColor(progressElement, percentage) {
1052
- // Remove existing completion classes
1053
- progressElement.classList.remove('completion-0', 'completion-25', 'completion-50', 'completion-75', 'completion-100');
1054
-
1055
- // Add appropriate class based on percentage
1056
- if (percentage >= 100) {
1057
- progressElement.classList.add('completion-100');
1058
- } else if (percentage >= 75) {
1059
- progressElement.classList.add('completion-75');
1060
- } else if (percentage >= 50) {
1061
- progressElement.classList.add('completion-50');
1062
- } else if (percentage >= 25) {
1063
- progressElement.classList.add('completion-25');
1064
- } else {
1065
- progressElement.classList.add('completion-0');
1066
- }
1067
- }
1068
-
1069
- // Helper function to update goal progress UI with provided data
1070
- function updateGoalProgressUI(allGamesStats, allLinesData) {
1071
- if (!allGamesStats) {
1072
- throw new Error('No stats data available');
1073
- }
1074
-
1075
- // Calculate current progress
1076
- const currentHours = allGamesStats.total_time_hours || 0;
1077
- const currentCharacters = allGamesStats.total_characters || 0;
1078
- const currentGames = allGamesStats.unique_games || 0;
1079
-
1080
- // Calculate 90-day averages for projections
1081
- const dailyHoursAvg = calculate90DayAverage(allLinesData, 'hours');
1082
- const dailyCharsAvg = calculate90DayAverage(allLinesData, 'characters');
1083
- const dailyGamesAvg = calculate90DayAverage(allLinesData, 'games');
1084
-
1085
- // Update Hours Goal
1086
- const hoursPercentage = Math.min(100, (currentHours / goalSettings.reading_hours_target) * 100);
1087
- document.getElementById('goalHoursCurrent').textContent = Math.floor(currentHours).toLocaleString();
1088
- document.getElementById('goalHoursTarget').textContent = goalSettings.reading_hours_target.toLocaleString();
1089
- document.getElementById('goalHoursPercentage').textContent = Math.floor(hoursPercentage) + '%';
1090
- document.getElementById('goalHoursProjection').textContent =
1091
- formatProjection(currentHours, goalSettings.reading_hours_target, dailyHoursAvg, 'hours');
1092
-
1093
- const hoursProgressBar = document.getElementById('goalHoursProgress');
1094
- hoursProgressBar.style.width = hoursPercentage + '%';
1095
- hoursProgressBar.setAttribute('data-percentage', Math.floor(hoursPercentage / 25) * 25);
1096
- updateProgressBarColor(hoursProgressBar, hoursPercentage);
1097
-
1098
- // Update Characters Goal
1099
- const charsPercentage = Math.min(100, (currentCharacters / goalSettings.character_count_target) * 100);
1100
- document.getElementById('goalCharsCurrent').textContent = formatGoalNumber(currentCharacters);
1101
- document.getElementById('goalCharsTarget').textContent = formatGoalNumber(goalSettings.character_count_target);
1102
- document.getElementById('goalCharsPercentage').textContent = Math.floor(charsPercentage) + '%';
1103
- document.getElementById('goalCharsProjection').textContent =
1104
- formatProjection(currentCharacters, goalSettings.character_count_target, dailyCharsAvg, 'characters');
1105
-
1106
- const charsProgressBar = document.getElementById('goalCharsProgress');
1107
- charsProgressBar.style.width = charsPercentage + '%';
1108
- charsProgressBar.setAttribute('data-percentage', Math.floor(charsPercentage / 25) * 25);
1109
- updateProgressBarColor(charsProgressBar, charsPercentage);
1110
-
1111
- // Update Games Goal
1112
- const gamesPercentage = Math.min(100, (currentGames / goalSettings.games_target) * 100);
1113
- document.getElementById('goalGamesCurrent').textContent = currentGames.toLocaleString();
1114
- document.getElementById('goalGamesTarget').textContent = goalSettings.games_target.toLocaleString();
1115
- document.getElementById('goalGamesPercentage').textContent = Math.floor(gamesPercentage) + '%';
1116
- document.getElementById('goalGamesProjection').textContent =
1117
- formatProjection(currentGames, goalSettings.games_target, dailyGamesAvg, 'games');
1118
-
1119
- const gamesProgressBar = document.getElementById('goalGamesProgress');
1120
- gamesProgressBar.style.width = gamesPercentage + '%';
1121
- gamesProgressBar.setAttribute('data-percentage', Math.floor(gamesPercentage / 25) * 25);
1122
- updateProgressBarColor(gamesProgressBar, gamesPercentage);
1123
- }
1124
-
1125
- // Main function to load and display goal progress
1126
- async function loadGoalProgress() {
1127
- const goalProgressChart = document.getElementById('goalProgressChart');
1128
- const goalProgressLoading = document.getElementById('goalProgressLoading');
1129
- const goalProgressError = document.getElementById('goalProgressError');
1130
-
1131
- if (!goalProgressChart) return;
1132
-
1133
- try {
1134
- // Show loading state
1135
- goalProgressLoading.style.display = 'flex';
1136
- goalProgressError.style.display = 'none';
1137
-
1138
- // Load goal settings and stats data
1139
- await loadGoalSettings();
1140
- const response = await fetch('/api/stats');
1141
- if (!response.ok) throw new Error('Failed to fetch stats data');
1142
-
1143
- const data = await response.json();
1144
- const allGamesStats = data.allGamesStats;
1145
- const allLinesData = data.allLinesData || [];
1146
-
1147
- // Update the UI using the shared helper function
1148
- updateGoalProgressUI(allGamesStats, allLinesData);
1149
-
1150
- // Hide loading state
1151
- goalProgressLoading.style.display = 'none';
1152
-
1153
- } catch (error) {
1154
- console.error('Error loading goal progress:', error);
1155
- goalProgressLoading.style.display = 'none';
1156
- goalProgressError.style.display = 'block';
1157
- }
1158
- }
1159
-
1160
634
  // ================================
1161
635
  // Utility to convert date strings to Unix timestamps
1162
636
  // Returns start of day for startDate and end of day for endDate
@@ -1179,6 +653,8 @@ document.addEventListener('DOMContentLoaded', function () {
1179
653
  const fromDateInput = document.getElementById('fromDate');
1180
654
  const toDateInput = document.getElementById('toDate');
1181
655
 
656
+ if (!fromDateInput || !toDateInput) return; // Null check
657
+
1182
658
  const fromDate = sessionStorage.getItem("fromDate");
1183
659
  const toDate = sessionStorage.getItem("toDate");
1184
660
 
@@ -1192,11 +668,12 @@ document.addEventListener('DOMContentLoaded', function () {
1192
668
 
1193
669
  // Get today's date
1194
670
  const today = new Date();
1195
- toDateInput.value = today.toISOString().split("T")[0];
671
+ const toDate = today.toLocaleDateString('en-CA');
672
+ toDateInput.value = toDate;
1196
673
 
1197
674
  // Save in sessionStorage
1198
675
  sessionStorage.setItem("fromDate", firstDate);
1199
- sessionStorage.setItem("toDate", today.toISOString().split("T")[0]);
676
+ sessionStorage.setItem("toDate", toDate);
1200
677
 
1201
678
  document.dispatchEvent(new Event("datesSet"));
1202
679
  });
@@ -1232,7 +709,7 @@ document.addEventListener('DOMContentLoaded', function () {
1232
709
 
1233
710
  // Validate date order
1234
711
  if (fromDateStr && toDateStr && new Date(fromDateStr) > new Date(toDateStr)) {
1235
- popup.classList.remove("hidden");
712
+ if (popup) popup.classList.remove("hidden");
1236
713
  return;
1237
714
  }
1238
715
 
@@ -1242,15 +719,17 @@ document.addEventListener('DOMContentLoaded', function () {
1242
719
  }
1243
720
 
1244
721
  // Attach listeners to both date inputs
1245
- fromDateInput.addEventListener("change", handleDateChange);
1246
- toDateInput.addEventListener("change", handleDateChange);
722
+ if (fromDateInput) fromDateInput.addEventListener("change", handleDateChange);
723
+ if (toDateInput) toDateInput.addEventListener("change", handleDateChange);
1247
724
 
1248
725
  initializeDates();
1249
726
 
1250
727
  // Popup close button
1251
- closePopupBtn.addEventListener("click", () => {
1252
- popup.classList.add("hidden");
1253
- });
728
+ if (closePopupBtn) {
729
+ closePopupBtn.addEventListener("click", () => {
730
+ if (popup) popup.classList.add("hidden");
731
+ });
732
+ }
1254
733
 
1255
734
  // Populate settings modal with global config values on load
1256
735
  if (window.statsConfig) {
@@ -1268,804 +747,20 @@ document.addEventListener('DOMContentLoaded', function () {
1268
747
 
1269
748
  const gamesTargetInput = document.getElementById('gamesTarget');
1270
749
  if (gamesTargetInput) gamesTargetInput.value = window.statsConfig.gamesTarget || 100;
1271
- }
1272
-
1273
- // Function to update goal progress using existing stats data
1274
- async function updateGoalProgressWithData(statsData) {
1275
- const goalProgressChart = document.getElementById('goalProgressChart');
1276
- const goalProgressLoading = document.getElementById('goalProgressLoading');
1277
- const goalProgressError = document.getElementById('goalProgressError');
1278
-
1279
- if (!goalProgressChart) return;
1280
-
1281
- try {
1282
- // Load goal settings if not already loaded
1283
- if (!goalSettings.reading_hours_target) {
1284
- await loadGoalSettings();
1285
- }
1286
-
1287
- const allGamesStats = statsData.allGamesStats;
1288
- const allLinesData = statsData.allLinesData || [];
1289
-
1290
- // Update the UI using the shared helper function
1291
- updateGoalProgressUI(allGamesStats, allLinesData);
1292
-
1293
- // Hide loading and error states
1294
- goalProgressLoading.style.display = 'none';
1295
- goalProgressError.style.display = 'none';
1296
-
1297
- } catch (error) {
1298
- console.error('Error updating goal progress:', error);
1299
- goalProgressLoading.style.display = 'none';
1300
- goalProgressError.style.display = 'block';
1301
- }
1302
- }
1303
-
1304
- // Load goal progress initially
1305
- setTimeout(() => {
1306
- loadGoalProgress();
1307
- }, 1000);
1308
-
1309
- // Refresh goal progress when settings are updated
1310
- window.addEventListener('settingsUpdated', () => {
1311
- setTimeout(() => {
1312
- loadGoalProgress();
1313
- }, 500);
1314
- });
1315
-
1316
- // Make functions globally available
1317
- window.createHeatmap = createHeatmap;
1318
- window.loadStatsData = loadStatsData;
1319
- window.loadGoalProgress = loadGoalProgress;
1320
-
1321
- // Dashboard functionality
1322
- function loadDashboardData(data = null, end_timestamp = null) {
1323
- function updateTodayOverview(allLinesData) {
1324
- // Get today's date string (YYYY-MM-DD)
1325
- // Get today's date string (YYYY-MM-DD), timezone aware (local time)
1326
- const today = new Date();
1327
- const pad = n => n.toString().padStart(2, '0');
1328
- const todayStr = `${today.getFullYear()}-${pad(today.getMonth() + 1)}-${pad(today.getDate())}`;
1329
- document.getElementById('todayDate').textContent = todayStr;
1330
-
1331
- // Filter lines for today
1332
- const todayLines = (allLinesData || []).filter(line => {
1333
- if (!line.timestamp) return false;
1334
- const ts = parseFloat(line.timestamp);
1335
- if (isNaN(ts)) return false;
1336
- const dateObj = new Date(ts * 1000);
1337
- const lineDate = `${dateObj.getFullYear()}-${pad(dateObj.getMonth() + 1)}-${pad(dateObj.getDate())}`;
1338
- return lineDate === todayStr;
1339
- });
1340
-
1341
- // Calculate total characters read today (only valid numbers)
1342
- const totalChars = todayLines.reduce((sum, line) => {
1343
- const chars = Number(line.characters);
1344
- return sum + (isNaN(chars) ? 0 : chars);
1345
- }, 0);
1346
-
1347
- // Calculate sessions (count gaps > session threshold as new sessions)
1348
- let sessions = 0;
1349
- let sessionGap = window.statsConfig ? window.statsConfig.sessionGapSeconds : 3600;
1350
- // Try to get session gap from settings modal if available and valid
1351
- const sessionGapInput = document.getElementById('sessionGap');
1352
- if (sessionGapInput && sessionGapInput.value) {
1353
- const parsed = parseInt(sessionGapInput.value, 10);
1354
- if (!isNaN(parsed) && parsed > 0) sessionGap = parsed;
1355
- }
1356
- if (todayLines.length > 0 && todayLines[0].session_id !== undefined) {
1357
- const sessionSet = new Set(todayLines.map(l => l.session_id));
1358
- sessions = sessionSet.size;
1359
- } else {
1360
- // Use timestamp gap logic
1361
- const timestamps = todayLines
1362
- .map(l => parseFloat(l.timestamp))
1363
- .filter(ts => !isNaN(ts))
1364
- .sort((a, b) => a - b);
1365
- if (timestamps.length > 0) {
1366
- sessions = 1;
1367
- for (let i = 1; i < timestamps.length; i++) {
1368
- if (timestamps[i] - timestamps[i - 1] > sessionGap) {
1369
- sessions += 1;
1370
- }
1371
- }
1372
- } else {
1373
- sessions = 0;
1374
- }
1375
- }
1376
-
1377
- // Calculate total reading time (reuse AFK logic from calculateHeatmapStreaks)
1378
- let totalSeconds = 0;
1379
- const timestamps = todayLines
1380
- .map(l => parseFloat(l.timestamp))
1381
- .filter(ts => !isNaN(ts))
1382
- .sort((a, b) => a - b);
1383
- // Get AFK timer from settings modal if available
1384
- let afkTimerSeconds = window.statsConfig ? window.statsConfig.afkTimerSeconds : 120;
1385
- const afkTimerInput = document.getElementById('afkTimer');
1386
- if (afkTimerInput && afkTimerInput.value) {
1387
- const parsed = parseInt(afkTimerInput.value, 10);
1388
- if (!isNaN(parsed) && parsed > 0) afkTimerSeconds = parsed;
1389
- }
1390
- if (timestamps.length >= 2) {
1391
- for (let i = 1; i < timestamps.length; i++) {
1392
- const gap = timestamps[i] - timestamps[i-1];
1393
- totalSeconds += Math.min(gap, afkTimerSeconds);
1394
- }
1395
- } else if (timestamps.length === 1) {
1396
- totalSeconds = 1;
1397
- }
1398
- let totalHours = totalSeconds / 3600;
1399
-
1400
- // Calculate chars/hour
1401
- let charsPerHour = '-';
1402
- if (totalChars > 0) {
1403
- // Avoid division by zero, set minimum time to 1 minute if activity exists
1404
- if (totalHours <= 0) totalHours = 1/60;
1405
- charsPerHour = Math.round(totalChars / totalHours).toLocaleString();
1406
- }
1407
-
1408
- // Format hours for display
1409
- let hoursDisplay = '-';
1410
- if (totalHours > 0) {
1411
- const h = Math.floor(totalHours);
1412
- const m = Math.round((totalHours - h) * 60);
1413
- hoursDisplay = h > 0 ? `${h}h${m > 0 ? ' ' + m + 'm' : ''}` : `${m}m`;
1414
- }
1415
-
1416
- document.getElementById('todayTotalHours').textContent = hoursDisplay;
1417
- document.getElementById('todayTotalChars').textContent = totalChars.toLocaleString();
1418
- document.getElementById('todaySessions').textContent = sessions;
1419
- document.getElementById('todayCharsPerHour').textContent = charsPerHour;
1420
- }
1421
-
1422
- function updateOverviewForEndDay(allLinesData, endTimestamp) {
1423
- if (!endTimestamp) return;
1424
-
1425
- const pad = n => n.toString().padStart(2, '0');
1426
-
1427
- // Determine target date string (YYYY-MM-DD) from the end timestamp
1428
- const endDateObj = new Date(endTimestamp * 1000);
1429
- const targetDateStr = `${endDateObj.getFullYear()}-${pad(endDateObj.getMonth() + 1)}-${pad(endDateObj.getDate())}`;
1430
- document.getElementById('todayDate').textContent = targetDateStr;
1431
-
1432
- // Filter lines that fall on the target date
1433
- const targetLines = (allLinesData || []).filter(line => {
1434
- if (!line.timestamp) return false;
1435
- const ts = parseFloat(line.timestamp);
1436
- if (isNaN(ts)) return false;
1437
- const dateObj = new Date(ts * 1000);
1438
- const lineDate = `${dateObj.getFullYear()}-${pad(dateObj.getMonth() + 1)}-${pad(dateObj.getDate())}`;
1439
- return lineDate === targetDateStr;
1440
- });
1441
-
1442
- // Calculate total characters
1443
- const totalChars = targetLines.reduce((sum, line) => {
1444
- const chars = Number(line.characters);
1445
- return sum + (isNaN(chars) ? 0 : chars);
1446
- }, 0);
1447
-
1448
- // Determine session gap (from settings or default)
1449
- let sessionGap = window.statsConfig?.sessionGapSeconds || 3600;
1450
- const sessionGapInput = document.getElementById('sessionGap');
1451
- if (sessionGapInput?.value) {
1452
- const parsed = parseInt(sessionGapInput.value, 10);
1453
- if (!isNaN(parsed) && parsed > 0) sessionGap = parsed;
1454
- }
1455
-
1456
- // Calculate sessions
1457
- let sessions = 0;
1458
- if (targetLines.length > 0 && targetLines[0].session_id !== undefined) {
1459
- const sessionSet = new Set(targetLines.map(l => l.session_id));
1460
- sessions = sessionSet.size;
1461
- } else {
1462
- const timestamps = targetLines
1463
- .map(l => parseFloat(l.timestamp))
1464
- .filter(ts => !isNaN(ts))
1465
- .sort((a, b) => a - b);
1466
- if (timestamps.length > 0) {
1467
- sessions = 1;
1468
- for (let i = 1; i < timestamps.length; i++) {
1469
- if (timestamps[i] - timestamps[i - 1] > sessionGap) {
1470
- sessions += 1;
1471
- }
1472
- }
1473
- }
1474
- }
1475
750
 
1476
- // Calculate total reading time
1477
- let totalSeconds = 0;
1478
- const timestamps = targetLines
1479
- .map(l => parseFloat(l.timestamp))
1480
- .filter(ts => !isNaN(ts))
1481
- .sort((a, b) => a - b);
1482
-
1483
- let afkTimerSeconds = window.statsConfig?.afkTimerSeconds || 120;
1484
- const afkTimerInput = document.getElementById('afkTimer');
1485
- if (afkTimerInput?.value) {
1486
- const parsed = parseInt(afkTimerInput.value, 10);
1487
- if (!isNaN(parsed) && parsed > 0) afkTimerSeconds = parsed;
1488
- }
751
+ const hoursDateInput = document.getElementById('readingHoursTargetDate');
752
+ if (hoursDateInput) hoursDateInput.value = window.statsConfig.readingHoursTargetDate || '';
1489
753
 
1490
- if (timestamps.length >= 2) {
1491
- for (let i = 1; i < timestamps.length; i++) {
1492
- const gap = timestamps[i] - timestamps[i - 1];
1493
- totalSeconds += Math.min(gap, afkTimerSeconds);
1494
- }
1495
- } else if (timestamps.length === 1) {
1496
- totalSeconds = 1;
1497
- }
1498
-
1499
- let totalHours = totalSeconds / 3600;
1500
-
1501
- // Calculate chars/hour
1502
- let charsPerHour = '-';
1503
- if (totalChars > 0) {
1504
- if (totalHours <= 0) totalHours = 1/60; // Minimum 1 minute
1505
- charsPerHour = Math.round(totalChars / totalHours).toLocaleString();
1506
- }
754
+ const charsDateInput = document.getElementById('characterCountTargetDate');
755
+ if (charsDateInput) charsDateInput.value = window.statsConfig.characterCountTargetDate || '';
1507
756
 
1508
- // Format hours for display
1509
- let hoursDisplay = '-';
1510
- if (totalHours > 0) {
1511
- const h = Math.floor(totalHours);
1512
- const m = Math.round((totalHours - h) * 60);
1513
- hoursDisplay = h > 0 ? `${h}h${m > 0 ? ' ' + m + 'm' : ''}` : `${m}m`;
1514
- }
1515
-
1516
- // Update DOM
1517
- document.getElementById('todayTotalHours').textContent = hoursDisplay;
1518
- document.getElementById('todayTotalChars').textContent = totalChars.toLocaleString();
1519
- document.getElementById('todaySessions').textContent = sessions;
1520
- document.getElementById('todayCharsPerHour').textContent = charsPerHour;
1521
- }
1522
-
1523
- if (data && data.currentGameStats && data.allGamesStats) {
1524
- // Use existing data if available
1525
- updateCurrentGameDashboard(data.currentGameStats);
1526
- updateAllGamesDashboard(data.allGamesStats);
1527
-
1528
- if (data.allLinesData) {
1529
- end_timestamp == null ? updateTodayOverview(data.allLinesData) : updateOverviewForEndDay(data.allLinesData, end_timestamp)
1530
- }
1531
-
1532
- hideDashboardLoading();
1533
- } else {
1534
- // Fetch fresh data
1535
- showDashboardLoading();
1536
- fetch('/api/stats')
1537
- .then(response => response.json())
1538
- .then(data => {
1539
- if (data.currentGameStats && data.allGamesStats) {
1540
- updateCurrentGameDashboard(data.currentGameStats);
1541
- updateAllGamesDashboard(data.allGamesStats);
1542
- if (data.allLinesData) {
1543
- end_timestamp == null ? updateTodayOverview(data.allLinesData) : updateOverviewForEndDay(data.allLinesData, end_timestamp)
1544
- }
1545
- } else {
1546
- showDashboardError();
1547
- }
1548
- hideDashboardLoading();
1549
- })
1550
- .catch(error => {
1551
- console.error('Error fetching dashboard data:', error);
1552
- showDashboardError();
1553
- hideDashboardLoading();
1554
- });
1555
- }
1556
- }
1557
-
1558
- function updateCurrentGameDashboard(stats) {
1559
- if (!stats) {
1560
- showNoDashboardData('currentGameCard', 'No current game data available');
1561
- return;
1562
- }
1563
-
1564
- // Update game name and subtitle
1565
- document.getElementById('currentGameName').textContent = stats.game_name;
1566
-
1567
- // Update main statistics
1568
- document.getElementById('currentTotalChars').textContent = stats.total_characters_formatted;
1569
- document.getElementById('currentTotalTime').textContent = stats.total_time_formatted;
1570
- document.getElementById('currentReadingSpeed').textContent = stats.reading_speed_formatted;
1571
- document.getElementById('currentSessions').textContent = stats.sessions.toLocaleString();
1572
-
1573
- // Update progress section
1574
- document.getElementById('currentMonthlyChars').textContent = stats.monthly_characters_formatted;
1575
- document.getElementById('currentFirstDate').textContent = stats.first_date;
1576
- document.getElementById('currentLastDate').textContent = stats.last_date;
1577
-
1578
- // Update streak indicator
1579
- const streakElement = document.getElementById('currentGameStreak');
1580
- const streakValue = document.getElementById('currentStreakValue');
1581
- if (stats.current_streak > 0) {
1582
- streakValue.textContent = stats.current_streak;
1583
- streakElement.style.display = 'inline-flex';
1584
- } else {
1585
- streakElement.style.display = 'none';
1586
- }
1587
-
1588
- // Show the card
1589
- document.getElementById('currentGameCard').style.display = 'block';
757
+ const gamesDateInput = document.getElementById('gamesTargetDate');
758
+ if (gamesDateInput) gamesDateInput.value = window.statsConfig.gamesTargetDate || '';
1590
759
  }
1591
760
 
1592
- function updateAllGamesDashboard(stats) {
1593
- if (!stats) {
1594
- showNoDashboardData('allGamesCard', 'No games data available');
1595
- return;
1596
- }
1597
-
1598
- // Update subtitle
1599
- const gamesText = stats.unique_games === 1 ? '1 game played' : `${stats.unique_games} games played`;
1600
- document.getElementById('totalGamesCount').textContent = gamesText;
1601
-
1602
- // Update main statistics
1603
- document.getElementById('allTotalChars').textContent = stats.total_characters_formatted;
1604
- document.getElementById('allTotalTime').textContent = stats.total_time_formatted;
1605
- document.getElementById('allReadingSpeed').textContent = stats.reading_speed_formatted;
1606
- document.getElementById('allSessions').textContent = stats.sessions.toLocaleString();
1607
-
1608
- // Update progress section
1609
- document.getElementById('allMonthlyChars').textContent = stats.monthly_characters_formatted;
1610
- document.getElementById('allUniqueGames').textContent = stats.unique_games.toLocaleString();
1611
- document.getElementById('allTotalSentences').textContent = stats.total_sentences.toLocaleString();
1612
-
1613
- // Update streak indicator
1614
- const streakElement = document.getElementById('allGamesStreak');
1615
- const streakValue = document.getElementById('allStreakValue');
1616
- if (stats.current_streak > 0) {
1617
- streakValue.textContent = stats.current_streak;
1618
- streakElement.style.display = 'inline-flex';
1619
- } else {
1620
- streakElement.style.display = 'none';
1621
- }
1622
-
1623
-
1624
- // Show the card
1625
- document.getElementById('allGamesCard').style.display = 'block';
1626
- }
1627
-
1628
- function showDashboardLoading() {
1629
- document.getElementById('dashboardLoading').style.display = 'flex';
1630
- document.getElementById('dashboardError').style.display = 'none';
1631
- document.getElementById('currentGameCard').style.display = 'none';
1632
- document.getElementById('allGamesCard').style.display = 'none';
1633
- }
1634
-
1635
- function hideDashboardLoading() {
1636
- document.getElementById('dashboardLoading').style.display = 'none';
1637
- }
1638
-
1639
- function showDashboardError() {
1640
- document.getElementById('dashboardError').style.display = 'block';
1641
- document.getElementById('dashboardLoading').style.display = 'none';
1642
- document.getElementById('currentGameCard').style.display = 'none';
1643
- document.getElementById('allGamesCard').style.display = 'none';
1644
- }
1645
-
1646
- function showNoDashboardData(cardId, message) {
1647
- const card = document.getElementById(cardId);
1648
- const statsGrid = card.querySelector('.dashboard-stats-grid');
1649
- const progressSection = card.querySelector('.dashboard-progress-section');
1650
-
1651
- // Hide stats and progress sections
1652
- statsGrid.style.display = 'none';
1653
- progressSection.style.display = 'none';
1654
-
1655
- // Add no data message
1656
- let noDataMsg = card.querySelector('.no-data-message');
1657
- if (!noDataMsg) {
1658
- noDataMsg = document.createElement('div');
1659
- noDataMsg.className = 'no-data-message';
1660
- noDataMsg.style.cssText = 'text-align: center; padding: 40px 20px; color: var(--text-tertiary); font-style: italic;';
1661
- card.appendChild(noDataMsg);
1662
- }
1663
- noDataMsg.textContent = message;
1664
-
1665
- card.style.display = 'block';
1666
- }
1667
-
1668
- // Add click animations for dashboard stat items
1669
- const statItems = document.querySelectorAll('.dashboard-stat-item');
1670
- statItems.forEach(item => {
1671
- item.addEventListener('click', function() {
1672
- // Add click animation
1673
- this.style.transform = 'scale(0.95)';
1674
- setTimeout(() => {
1675
- this.style.transform = '';
1676
- }, 150);
1677
- });
1678
- });
1679
-
1680
- // Add accessibility improvements
1681
- statItems.forEach(item => {
1682
- item.setAttribute('tabindex', '0');
1683
- item.setAttribute('role', 'button');
1684
-
1685
- item.addEventListener('keydown', function(e) {
1686
- if (e.key === 'Enter' || e.key === ' ') {
1687
- e.preventDefault();
1688
- this.click();
1689
- }
1690
- });
1691
- });
1692
-
1693
- // Global function to retry dashboard loading
1694
- window.loadDashboardData = loadDashboardData;
761
+ // Make functions globally available
762
+ window.loadStatsData = loadStatsData;
1695
763
 
1696
- // Delete Game Entry Functionality
1697
- class GameDeletionManager {
1698
- constructor() {
1699
- this.games = [];
1700
- this.selectedGames = new Set();
1701
- this.isLoading = false;
1702
-
1703
- this.initializeElements();
1704
- this.attachEventListeners();
1705
- this.loadGames();
1706
- }
1707
-
1708
- initializeElements() {
1709
- // Control elements
1710
- this.selectAllBtn = document.getElementById('selectAllBtn');
1711
- this.selectNoneBtn = document.getElementById('selectNoneBtn');
1712
- this.deleteSelectedBtn = document.getElementById('deleteSelectedBtn');
1713
- this.headerCheckbox = document.getElementById('headerCheckbox');
1714
-
1715
- // Table elements
1716
- this.gamesTableBody = document.getElementById('gamesTableBody');
1717
- this.loadingIndicator = document.getElementById('loadingIndicator');
1718
- this.noGamesMessage = document.getElementById('noGamesMessage');
1719
- this.errorMessage = document.getElementById('errorMessage');
1720
- this.retryBtn = document.getElementById('retryBtn');
1721
-
1722
- // Modal elements
1723
- this.confirmationModal = document.getElementById('confirmationModal');
1724
- this.progressModal = document.getElementById('progressModal');
1725
- this.resultModal = document.getElementById('resultModal');
1726
-
1727
- // Modal content elements
1728
- this.selectedGamesList = document.getElementById('selectedGamesList');
1729
- this.totalGamesCount = document.getElementById('totalGamesCount');
1730
- this.totalSentencesCount = document.getElementById('totalSentencesCount');
1731
- this.totalCharactersCount = document.getElementById('totalCharactersCount');
1732
- this.progressText = document.getElementById('progressText');
1733
- this.resultContent = document.getElementById('resultContent');
1734
- this.resultTitle = document.getElementById('resultTitle');
1735
- }
1736
-
1737
- attachEventListeners() {
1738
- // Control buttons
1739
- if (this.selectAllBtn) this.selectAllBtn.addEventListener('click', () => this.selectAll());
1740
- if (this.selectNoneBtn) this.selectNoneBtn.addEventListener('click', () => this.selectNone());
1741
- if (this.deleteSelectedBtn) this.deleteSelectedBtn.addEventListener('click', () => this.showConfirmation());
1742
- if (this.headerCheckbox) this.headerCheckbox.addEventListener('change', (e) => this.toggleAll(e.target.checked));
1743
-
1744
- // Modal controls
1745
- const closeModalBtn = document.getElementById('closeModal');
1746
- if (closeModalBtn) closeModalBtn.addEventListener('click', () => this.hideModal('confirmationModal'));
1747
- const cancelDeleteBtn = document.getElementById('cancelDeleteBtn');
1748
- if (cancelDeleteBtn) cancelDeleteBtn.addEventListener('click', () => this.hideModal('confirmationModal'));
1749
- const confirmDeleteBtn = document.getElementById('confirmDeleteBtn');
1750
- if (confirmDeleteBtn) confirmDeleteBtn.addEventListener('click', () => this.performDeletion());
1751
- const closeResultModalBtn = document.getElementById('closeResultModal');
1752
- if (closeResultModalBtn) closeResultModalBtn.addEventListener('click', () => this.hideModal('resultModal'));
1753
- const okBtn = document.getElementById('okBtn');
1754
- if (okBtn) okBtn.addEventListener('click', () => this.hideModal('resultModal'));
1755
-
1756
- // Retry button
1757
- if (this.retryBtn) this.retryBtn.addEventListener('click', () => this.loadGames());
1758
-
1759
- // Close modals when clicking outside
1760
- [this.confirmationModal, this.progressModal, this.resultModal].forEach(modal => {
1761
- modal.addEventListener('click', (e) => {
1762
- if (e.target === modal) {
1763
- this.hideModal(modal.id);
1764
- }
1765
- });
1766
- });
1767
- }
1768
-
1769
- async loadGames() {
1770
- this.showLoading(true);
1771
- hideElement(this.errorMessage);
1772
- hideElement(this.noGamesMessage);
1773
-
1774
- try {
1775
- const response = await fetch('/api/games-list');
1776
- const data = await response.json();
1777
-
1778
- if (!response.ok) {
1779
- throw new Error(data.error || 'Failed to fetch games');
1780
- }
1781
-
1782
- this.games = data.games;
1783
- this.selectedGames.clear();
1784
- this.renderGamesTable();
1785
- this.updateDeleteButton();
1786
-
1787
- if (this.games.length === 0) {
1788
- showElement(this.noGamesMessage);
1789
- }
1790
-
1791
- } catch (error) {
1792
- console.error('Error loading games:', error);
1793
- this.showError(error.message);
1794
- } finally {
1795
- this.showLoading(false);
1796
- }
1797
- }
1798
-
1799
- renderGamesTable() {
1800
- this.gamesTableBody.innerHTML = '';
1801
-
1802
- this.games.forEach(game => {
1803
- const row = document.createElement('tr');
1804
- row.dataset.gameName = game.name;
1805
-
1806
- row.innerHTML = `
1807
- <td class="checkbox-cell">
1808
- <input type="checkbox" class="game-checkbox" data-game="${game.name}">
1809
- </td>
1810
- <td><strong>${escapeHtml(game.name)}</strong></td>
1811
- <td>${game.sentence_count.toLocaleString()}</td>
1812
- <td>${game.total_characters.toLocaleString()}</td>
1813
- <td>${game.date_range}</td>
1814
- <td>${game.first_entry_date}</td>
1815
- <td>${game.last_entry_date}</td>
1816
- `;
1817
-
1818
- // Add checkbox event listener
1819
- const checkbox = row.querySelector('.game-checkbox');
1820
- checkbox.addEventListener('change', (e) => {
1821
- this.toggleGameSelection(game.name, e.target.checked);
1822
- });
1823
-
1824
- this.gamesTableBody.appendChild(row);
1825
- });
1826
- }
1827
-
1828
- toggleGameSelection(gameName, isSelected) {
1829
- if (isSelected) {
1830
- this.selectedGames.add(gameName);
1831
- } else {
1832
- this.selectedGames.delete(gameName);
1833
- }
1834
-
1835
- this.updateRowSelection(gameName, isSelected);
1836
- this.updateHeaderCheckbox();
1837
- this.updateDeleteButton();
1838
- }
1839
-
1840
- updateRowSelection(gameName, isSelected) {
1841
- const row = document.querySelector(`tr[data-game-name="${gameName}"]`);
1842
- if (row) {
1843
- if (isSelected) {
1844
- row.classList.add('selected');
1845
- } else {
1846
- row.classList.remove('selected');
1847
- }
1848
- }
1849
- }
1850
-
1851
- selectAll() {
1852
- this.games.forEach(game => {
1853
- this.selectedGames.add(game.name);
1854
- const checkbox = document.querySelector(`input[data-game="${game.name}"]`);
1855
- if (checkbox) {
1856
- checkbox.checked = true;
1857
- this.updateRowSelection(game.name, true);
1858
- }
1859
- });
1860
- this.updateHeaderCheckbox();
1861
- this.updateDeleteButton();
1862
- }
1863
-
1864
- selectNone() {
1865
- this.selectedGames.clear();
1866
- document.querySelectorAll('.game-checkbox').forEach(checkbox => {
1867
- checkbox.checked = false;
1868
- });
1869
- document.querySelectorAll('tr.selected').forEach(row => {
1870
- row.classList.remove('selected');
1871
- });
1872
- this.updateHeaderCheckbox();
1873
- this.updateDeleteButton();
1874
- }
1875
-
1876
- toggleAll(checked) {
1877
- if (checked) {
1878
- this.selectAll();
1879
- } else {
1880
- this.selectNone();
1881
- }
1882
- }
1883
-
1884
- updateHeaderCheckbox() {
1885
- const totalGames = this.games.length;
1886
- const selectedCount = this.selectedGames.size;
1887
-
1888
- if (selectedCount === 0) {
1889
- this.headerCheckbox.checked = false;
1890
- this.headerCheckbox.indeterminate = false;
1891
- } else if (selectedCount === totalGames) {
1892
- this.headerCheckbox.checked = true;
1893
- this.headerCheckbox.indeterminate = false;
1894
- } else {
1895
- this.headerCheckbox.checked = false;
1896
- this.headerCheckbox.indeterminate = true;
1897
- }
1898
- }
1899
-
1900
- updateDeleteButton() {
1901
- this.deleteSelectedBtn.disabled = this.selectedGames.size === 0;
1902
- this.deleteSelectedBtn.textContent = this.selectedGames.size > 0
1903
- ? `Delete Selected Games (${this.selectedGames.size})`
1904
- : 'Delete Selected Games';
1905
- }
1906
-
1907
- showConfirmation() {
1908
- if (this.selectedGames.size === 0) return;
1909
-
1910
- // Populate confirmation modal
1911
- this.populateConfirmationModal();
1912
- this.showModal('confirmationModal');
1913
- }
1914
-
1915
- populateConfirmationModal() {
1916
- const selectedGameData = this.games.filter(game => this.selectedGames.has(game.name));
1917
-
1918
- // Populate games list
1919
- this.selectedGamesList.innerHTML = '';
1920
- selectedGameData.forEach(game => {
1921
- const gameItem = document.createElement('div');
1922
- gameItem.className = 'game-item';
1923
- gameItem.innerHTML = `
1924
- <div>
1925
- <div class="game-name">${escapeHtml(game.name)}</div>
1926
- <div class="game-stats">${game.date_range}</div>
1927
- </div>
1928
- <div class="game-stats">
1929
- ${game.sentence_count} sentences, ${game.total_characters.toLocaleString()} chars
1930
- </div>
1931
- `;
1932
- this.selectedGamesList.appendChild(gameItem);
1933
- });
1934
-
1935
- // Calculate totals
1936
- const totalGames = selectedGameData.length;
1937
- const totalSentences = selectedGameData.reduce((sum, game) => sum + game.sentence_count, 0);
1938
- const totalCharacters = selectedGameData.reduce((sum, game) => sum + game.total_characters, 0);
1939
-
1940
- this.totalGamesCount.textContent = totalGames;
1941
- this.totalSentencesCount.textContent = totalSentences.toLocaleString();
1942
- this.totalCharactersCount.textContent = totalCharacters.toLocaleString();
1943
- }
1944
-
1945
- async performDeletion() {
1946
- this.hideModal('confirmationModal');
1947
- this.showModal('progressModal');
1948
-
1949
- // Show native confirmation as second stage
1950
- const gameNames = Array.from(this.selectedGames);
1951
- const confirmText = `Are you absolutely sure you want to delete ${gameNames.length} game(s)? This action cannot be undone.`;
1952
-
1953
- if (!confirm(confirmText)) {
1954
- this.hideModal('progressModal');
1955
- return;
1956
- }
1957
-
1958
- try {
1959
- this.progressText.textContent = `Deleting ${gameNames.length} games...`;
1960
-
1961
- const response = await fetch('/api/delete-games', {
1962
- method: 'POST',
1963
- headers: {
1964
- 'Content-Type': 'application/json',
1965
- },
1966
- body: JSON.stringify({ game_names: gameNames })
1967
- });
1968
-
1969
- const result = await response.json();
1970
-
1971
- this.hideModal('progressModal');
1972
- this.showResult(result, response.status);
1973
-
1974
- } catch (error) {
1975
- console.error('Error deleting games:', error);
1976
- this.hideModal('progressModal');
1977
- this.showResult({ error: error.message }, 500);
1978
- }
1979
- }
1980
-
1981
- showResult(result, status) {
1982
- let title, content, isSuccess = false;
1983
-
1984
- if (status === 200) {
1985
- // Complete success
1986
- title = 'Deletion Successful';
1987
- isSuccess = true;
1988
- content = `
1989
- <div class="success-message">
1990
- <p>✅ Successfully deleted ${result.successful_games.length} games!</p>
1991
- <p><strong>Total sentences deleted:</strong> ${result.total_sentences_deleted.toLocaleString()}</p>
1992
- </div>
1993
- `;
1994
- } else if (status === 207) {
1995
- // Partial success
1996
- title = 'Deletion Partially Successful';
1997
- content = `
1998
- <div class="warning-result">
1999
- <p>⚠️ ${result.successful_games.length} games deleted successfully</p>
2000
- <p>${result.failed_games.length} games failed to delete</p>
2001
- <p><strong>Total sentences deleted:</strong> ${result.total_sentences_deleted.toLocaleString()}</p>
2002
- </div>
2003
- <div style="margin-top: 15px;">
2004
- <strong>Failed games:</strong>
2005
- <ul style="margin: 10px 0; padding-left: 20px;">
2006
- ${result.failed_games.map(game => `<li>${escapeHtml(game)}</li>`).join('')}
2007
- </ul>
2008
- </div>
2009
- `;
2010
- isSuccess = true; // Still refresh since some succeeded
2011
- } else {
2012
- // Complete failure
2013
- title = 'Deletion Failed';
2014
- content = `
2015
- <div class="error-result">
2016
- <p>❌ Failed to delete games</p>
2017
- <p><strong>Error:</strong> ${escapeHtml(result.error || 'Unknown error occurred')}</p>
2018
- </div>
2019
- `;
2020
- }
2021
-
2022
- this.resultTitle.textContent = title;
2023
- this.resultContent.innerHTML = content;
2024
- this.showModal('resultModal');
2025
-
2026
- // Auto-refresh if any deletions were successful
2027
- if (isSuccess) {
2028
- setTimeout(() => {
2029
- this.hideModal('resultModal');
2030
- window.location.reload();
2031
- }, 3000);
2032
- }
2033
- }
2034
-
2035
- showModal(modalId) {
2036
- const modal = document.getElementById(modalId);
2037
- modal.classList.add('show');
2038
- modal.style.display = 'flex';
2039
- }
2040
-
2041
- hideModal(modalId) {
2042
- const modal = document.getElementById(modalId);
2043
- modal.classList.remove('show');
2044
- modal.style.display = 'none';
2045
- }
2046
-
2047
- showLoading(show) {
2048
- this.isLoading = show;
2049
- if (show) {
2050
- showElement(this.loadingIndicator);
2051
- hideElement(this.gamesTableBody.parentElement);
2052
- } else {
2053
- hideElement(this.loadingIndicator);
2054
- showElement(this.gamesTableBody.parentElement);
2055
- }
2056
- }
2057
-
2058
- showError(message) {
2059
- document.getElementById('errorText').textContent = message;
2060
- showElement(this.errorMessage);
2061
- }
2062
- }
2063
-
2064
- // Initialize the deletion manager
2065
- if (document.getElementById('gamesTableBody')) {
2066
- new GameDeletionManager();
2067
- }
2068
-
2069
764
  // ExStatic Import Functionality
2070
765
  const exstaticFileInput = document.getElementById('exstaticFile');
2071
766
  const importExstaticBtn = document.getElementById('importExstaticBtn');