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
@@ -0,0 +1,1176 @@
1
+ // Overview Page JavaScript
2
+ // Dependencies: shared.js (provides utility functions like showElement, hideElement, escapeHtml)
3
+
4
+ // Helper function to detect the current theme based on the app's theme system
5
+ function getCurrentTheme() {
6
+ const dataTheme = document.documentElement.getAttribute('data-theme');
7
+ if (dataTheme === 'dark' || dataTheme === 'light') {
8
+ return dataTheme;
9
+ }
10
+
11
+ // Fallback to system preference if no manual theme is set
12
+ if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
13
+ return 'dark';
14
+ }
15
+ return 'light';
16
+ }
17
+
18
+ // Helper function to get theme-appropriate text color
19
+ function getThemeTextColor() {
20
+ return getCurrentTheme() === 'dark' ? '#fff' : '#222';
21
+ }
22
+
23
+ document.addEventListener('DOMContentLoaded', function () {
24
+
25
+ // Helper function to get week number of year (GitHub style - week starts on Sunday)
26
+ function getWeekOfYear(date) {
27
+ const yearStart = new Date(date.getFullYear(), 0, 1);
28
+ const dayOfYear = Math.floor((date - yearStart) / (24 * 60 * 60 * 1000)) + 1;
29
+ const dayOfWeek = yearStart.getDay(); // 0 = Sunday
30
+
31
+ // Calculate week number (1-indexed)
32
+ const weekNum = Math.ceil((dayOfYear + dayOfWeek) / 7);
33
+ return Math.min(53, weekNum); // Cap at 53 weeks
34
+ }
35
+
36
+ // Helper function to get day of week (0 = Sunday, 6 = Saturday)
37
+ function getDayOfWeek(date) {
38
+ return date.getDay();
39
+ }
40
+
41
+ // Helper function to get the first Sunday of the year (or before)
42
+ function getFirstSunday(year) {
43
+ const jan1 = new Date(year, 0, 1);
44
+ const dayOfWeek = jan1.getDay();
45
+ const firstSunday = new Date(year, 0, 1 - dayOfWeek);
46
+ return firstSunday;
47
+ }
48
+
49
+ // Function to calculate heatmap streaks and average daily time
50
+ function calculateHeatmapStreaks(grid, yearData, allLinesForYear = []) {
51
+ const dates = [];
52
+
53
+ // Collect all dates in chronological order
54
+ for (let week = 0; week < 53; week++) {
55
+ for (let day = 0; day < 7; day++) {
56
+ const date = grid[day][week];
57
+ if (date) {
58
+ const dateStr = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
59
+ const activity = yearData[dateStr] || 0;
60
+ dates.push({ date: dateStr, activity: activity });
61
+ }
62
+ }
63
+ }
64
+
65
+ // Sort dates chronologically
66
+ dates.sort((a, b) => new Date(a.date) - new Date(b.date));
67
+
68
+
69
+ let longestStreak = 0;
70
+ let currentStreak = 0;
71
+ let tempStreak = 0;
72
+
73
+ // Calculate longest streak
74
+ for (let i = 0; i < dates.length; i++) {
75
+ if (dates[i].activity > 0) {
76
+ tempStreak++;
77
+ longestStreak = Math.max(longestStreak, tempStreak);
78
+ } else {
79
+ tempStreak = 0;
80
+ }
81
+ }
82
+
83
+ // Calculate current streak from today backwards, using streak requirement hours from config
84
+ const date = new Date();
85
+ const today = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
86
+ const streakRequirement = window.statsConfig ? window.statsConfig.streakRequirementHours : 1.0;
87
+
88
+ // Find today's index or the most recent date before today
89
+ let todayIndex = -1;
90
+ for (let i = dates.length - 1; i >= 0; i--) {
91
+ if (dates[i].date <= today) {
92
+ todayIndex = i;
93
+ break;
94
+ }
95
+ }
96
+
97
+ // Count backwards from today (or most recent date)
98
+ if (todayIndex >= 0) {
99
+ for (let i = todayIndex; i >= 0; i--) {
100
+ if (dates[i].activity >= streakRequirement) {
101
+ currentStreak++;
102
+ } else {
103
+ break;
104
+ }
105
+ }
106
+ }
107
+
108
+ // Calculate average daily time for this year
109
+ let avgDailyTime = "-";
110
+ if (allLinesForYear && allLinesForYear.length > 0) {
111
+ // Group timestamps by day for this year
112
+ const dailyTimestamps = {};
113
+ for (const line of allLinesForYear) {
114
+ const ts = parseFloat(line.timestamp);
115
+ if (isNaN(ts)) continue;
116
+ const dateObj = new Date(ts * 1000);
117
+ const dateStr = `${dateObj.getFullYear()}-${String(dateObj.getMonth() + 1).padStart(2, '0')}-${String(dateObj.getDate()).padStart(2, '0')}`;
118
+ if (!dailyTimestamps[dateStr]) {
119
+ dailyTimestamps[dateStr] = [];
120
+ }
121
+ dailyTimestamps[dateStr].push(parseFloat(line.timestamp));
122
+ }
123
+
124
+ // Calculate reading time for each day with activity
125
+ let totalHours = 0;
126
+ let activeDays = 0;
127
+ let afkTimerSeconds = window.statsConfig ? window.statsConfig.afkTimerSeconds : 120;
128
+
129
+ for (const [dateStr, timestamps] of Object.entries(dailyTimestamps)) {
130
+ if (timestamps.length >= 2) {
131
+ timestamps.sort((a, b) => a - b);
132
+ let dayReadingTime = 0;
133
+
134
+ for (let i = 1; i < timestamps.length; i++) {
135
+ const gap = timestamps[i] - timestamps[i-1];
136
+ dayReadingTime += Math.min(gap, afkTimerSeconds);
137
+ }
138
+
139
+ if (dayReadingTime > 0) {
140
+ totalHours += dayReadingTime / 3600;
141
+ activeDays++;
142
+ }
143
+ } else if (timestamps.length === 1) {
144
+ // Single timestamp - count as minimal activity (1 second)
145
+ totalHours += 1 / 3600;
146
+ activeDays++;
147
+ }
148
+ }
149
+
150
+ if (activeDays > 0) {
151
+ const avgHours = totalHours / activeDays;
152
+ if (avgHours < 1) {
153
+ const minutes = Math.round(avgHours * 60);
154
+ avgDailyTime = `${minutes}m`;
155
+ } else {
156
+ const hours = Math.floor(avgHours);
157
+ const minutes = Math.round((avgHours - hours) * 60);
158
+ avgDailyTime = minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`;
159
+ }
160
+ }
161
+ }
162
+
163
+ return { longestStreak, currentStreak, avgDailyTime };
164
+ }
165
+
166
+ // Function to create GitHub-style heatmap
167
+ function createHeatmap(heatmapData) {
168
+ const container = document.getElementById('heatmapContainer');
169
+
170
+ Object.keys(heatmapData).sort().forEach(year => {
171
+ const yearData = heatmapData[year];
172
+ const yearDiv = document.createElement('div');
173
+ yearDiv.className = 'heatmap-year';
174
+
175
+ const yearTitle = document.createElement('h3');
176
+ yearTitle.textContent = year;
177
+ yearDiv.appendChild(yearTitle);
178
+
179
+ // Find maximum activity value for this year to scale colors
180
+ const maxActivity = Math.max(...Object.values(yearData));
181
+
182
+ // Create main wrapper to center everything
183
+ const mainWrapper = document.createElement('div');
184
+ mainWrapper.className = 'heatmap-wrapper';
185
+
186
+ // Create container wrapper for labels and grid
187
+ const containerWrapper = document.createElement('div');
188
+ containerWrapper.className = 'heatmap-container-wrapper';
189
+
190
+ // Create day labels (S, M, T, W, T, F, S)
191
+ const dayLabels = document.createElement('div');
192
+ dayLabels.className = 'heatmap-day-labels';
193
+ const dayNames = ['S', '', 'M', '', 'W', '', 'F']; // Only show some labels for space
194
+ dayNames.forEach(dayName => {
195
+ const dayLabel = document.createElement('div');
196
+ dayLabel.className = 'heatmap-day-label';
197
+ dayLabel.textContent = dayName;
198
+ dayLabels.appendChild(dayLabel);
199
+ });
200
+
201
+ // Create grid container
202
+ const gridContainer = document.createElement('div');
203
+
204
+ // Create month labels
205
+ const monthLabels = document.createElement('div');
206
+ monthLabels.className = 'heatmap-month-labels';
207
+
208
+ // Create the main grid
209
+ const gridDiv = document.createElement('div');
210
+ gridDiv.className = 'heatmap-grid';
211
+
212
+ // Initialize 7x53 grid with empty cells
213
+ const grid = Array(7).fill(null).map(() => Array(53).fill(null));
214
+
215
+ // Get the first Sunday of the year (start of week 1)
216
+ const firstSunday = getFirstSunday(parseInt(year));
217
+
218
+ // Populate grid with dates for the entire year
219
+ for (let week = 0; week < 53; week++) {
220
+ for (let day = 0; day < 7; day++) {
221
+ const currentDate = new Date(firstSunday);
222
+ currentDate.setDate(firstSunday.getDate() + (week * 7) + day);
223
+
224
+ // Only include dates that belong to the current year
225
+ if (currentDate.getFullYear() === parseInt(year)) {
226
+ grid[day][week] = currentDate;
227
+ }
228
+ }
229
+ }
230
+
231
+ // Create month labels based on grid positions
232
+ const monthTracker = new Set();
233
+ for (let week = 0; week < 53; week++) {
234
+ const dateInWeek = grid[0][week] || grid[1][week] || grid[2][week] ||
235
+ grid[3][week] || grid[4][week] || grid[5][week] || grid[6][week];
236
+
237
+ if (dateInWeek) {
238
+ const month = dateInWeek.getMonth();
239
+ const monthName = dateInWeek.toLocaleDateString('en', { month: 'short' });
240
+
241
+ // Add month label if it's the first week of the month
242
+ if (!monthTracker.has(month) && dateInWeek.getDate() <= 7) {
243
+ const monthLabel = document.createElement('div');
244
+ monthLabel.className = 'heatmap-month-label';
245
+ monthLabel.style.gridColumn = `${week + 1}`;
246
+ monthLabel.textContent = monthName;
247
+ monthLabels.appendChild(monthLabel);
248
+ monthTracker.add(month);
249
+ }
250
+ }
251
+ }
252
+
253
+ // Create cells for the grid
254
+ for (let day = 0; day < 7; day++) {
255
+ for (let week = 0; week < 53; week++) {
256
+ const cell = document.createElement('div');
257
+ cell.className = 'heatmap-cell';
258
+
259
+ const date = grid[day][week];
260
+ if (date) {
261
+ const dateStr = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
262
+ const activity = yearData[dateStr] || 0;
263
+
264
+ if (activity > 0 && maxActivity > 0) {
265
+ // Calculate percentage of maximum activity
266
+ const percentage = (activity / maxActivity) * 100;
267
+
268
+ // Assign discrete color levels based on percentage thresholds
269
+ let colorLevel;
270
+ if (percentage <= 25) {
271
+ colorLevel = 1; // Light green
272
+ } else if (percentage <= 50) {
273
+ colorLevel = 2; // Medium green
274
+ } else if (percentage <= 75) {
275
+ colorLevel = 3; // Dark green
276
+ } else {
277
+ colorLevel = 4; // Darkest green
278
+ }
279
+
280
+ // Define discrete colors for each level
281
+ const colors = {
282
+ 1: '#c6e48b', // Light green (1-25%)
283
+ 2: '#7bc96f', // Medium green (26-50%)
284
+ 3: '#239a3b', // Dark green (51-75%)
285
+ 4: '#196127' // Darkest green (76-100%)
286
+ };
287
+
288
+ cell.style.backgroundColor = colors[colorLevel];
289
+ }
290
+
291
+ cell.title = `${dateStr}: ${activity} characters`;
292
+ } else {
293
+ // Empty cell for dates outside the year
294
+ cell.style.backgroundColor = 'transparent';
295
+ cell.style.cursor = 'default';
296
+ }
297
+
298
+ gridDiv.appendChild(cell);
299
+ }
300
+ }
301
+
302
+ gridContainer.appendChild(monthLabels);
303
+ gridContainer.appendChild(gridDiv);
304
+ containerWrapper.appendChild(dayLabels);
305
+ containerWrapper.appendChild(gridContainer);
306
+ mainWrapper.appendChild(containerWrapper);
307
+
308
+ // Calculate and display streaks with average daily time
309
+ const yearLines = window.allLinesData ? window.allLinesData.filter(line => {
310
+ if (!line.timestamp) return false;
311
+ const lineYear = new Date(parseFloat(line.timestamp) * 1000).getFullYear();
312
+ return lineYear === parseInt(year);
313
+ }) : [];
314
+
315
+ const streaks = calculateHeatmapStreaks(grid, yearData, yearLines);
316
+ const streaksDiv = document.createElement('div');
317
+ streaksDiv.className = 'heatmap-streaks';
318
+ streaksDiv.innerHTML = `
319
+ <div class="heatmap-streak-item">
320
+ <div class="heatmap-streak-number">${streaks.longestStreak}</div>
321
+ <div class="heatmap-streak-label">Longest Streak</div>
322
+ </div>
323
+ <div class="heatmap-streak-item">
324
+ <div class="heatmap-streak-number">${streaks.currentStreak}</div>
325
+ <div class="heatmap-streak-label">Current Streak</div>
326
+ </div>
327
+ <div class="heatmap-streak-item">
328
+ <div class="heatmap-streak-number">${streaks.avgDailyTime}</div>
329
+ <div class="heatmap-streak-label">Avg Daily Time</div>
330
+ </div>
331
+ `;
332
+ mainWrapper.appendChild(streaksDiv);
333
+ yearDiv.appendChild(mainWrapper);
334
+
335
+ // Add legend with discrete colors
336
+ const legend = document.createElement('div');
337
+ legend.className = 'heatmap-legend';
338
+ legend.innerHTML = `
339
+ <span>Less</span>
340
+ <div class="heatmap-legend-item" style="background-color: #ebedf0;" title="No activity"></div>
341
+ <div class="heatmap-legend-item" style="background-color: #c6e48b;" title="1-25% of max activity"></div>
342
+ <div class="heatmap-legend-item" style="background-color: #7bc96f;" title="26-50% of max activity"></div>
343
+ <div class="heatmap-legend-item" style="background-color: #239a3b;" title="51-75% of max activity"></div>
344
+ <div class="heatmap-legend-item" style="background-color: #196127;" title="76-100% of max activity"></div>
345
+ <span>More</span>
346
+ `;
347
+ yearDiv.appendChild(legend);
348
+
349
+ container.appendChild(yearDiv);
350
+ });
351
+ }
352
+
353
+ function showNoDataPopup() {
354
+ document.getElementById("noDataPopup").classList.remove("hidden");
355
+ }
356
+
357
+ document.getElementById("closeNoDataPopup").addEventListener("click", () => {
358
+ document.getElementById("noDataPopup").classList.add("hidden");
359
+ });
360
+
361
+ // Function to load stats data with optional year filter
362
+ function loadStatsData(start_timestamp = null, end_timestamp = null) {
363
+ let url = '/api/stats';
364
+ const params = new URLSearchParams();
365
+
366
+ if (start_timestamp && end_timestamp) {
367
+ // Only filter by timestamps
368
+ params.append('start', start_timestamp);
369
+ params.append('end', end_timestamp);
370
+ }
371
+
372
+ const queryString = params.toString();
373
+ if (queryString) {
374
+ url += `?${queryString}`;
375
+ }
376
+
377
+ return fetch(url)
378
+ .then(response => response.json())
379
+ .then(data => {
380
+ // Store all lines data globally for heatmap calculations
381
+ if (data.allLinesData && Array.isArray(data.allLinesData)) {
382
+ window.allLinesData = data.allLinesData;
383
+ } else {
384
+ // If not provided by API, we'll work without it
385
+ window.allLinesData = [];
386
+ }
387
+
388
+ if (!data.labels || data.labels.length === 0) {
389
+ console.log("No data to display.");
390
+ showNoDataPopup();
391
+ return data;
392
+ }
393
+
394
+ // Always update heatmap
395
+ if (data.heatmapData) {
396
+ const container = document.getElementById('heatmapContainer');
397
+ container.innerHTML = '';
398
+ createHeatmap(data.heatmapData);
399
+ }
400
+
401
+ // Load dashboard data
402
+ loadDashboardData(data, end_timestamp);
403
+
404
+ // Load goal progress chart (always refresh)
405
+ if (typeof loadGoalProgress === 'function') {
406
+ // Use the current data instead of making another API call
407
+ updateGoalProgressWithData(data);
408
+ }
409
+
410
+ return data;
411
+ })
412
+ .catch(error => {
413
+ console.error('Error fetching chart data:', error);
414
+ showDashboardError();
415
+ throw error;
416
+ });
417
+ }
418
+
419
+ // Goal Progress Chart functionality
420
+ let goalSettings = window.statsConfig || {};
421
+ if (!goalSettings.reading_hours_target) goalSettings.reading_hours_target = 1500;
422
+ if (!goalSettings.character_count_target) goalSettings.character_count_target = 25000000;
423
+ if (!goalSettings.games_target) goalSettings.games_target = 100;
424
+
425
+ // Function to load goal settings from API (fallback)
426
+ async function loadGoalSettings() {
427
+ // Use global config if available, otherwise fetch
428
+ if (window.statsConfig) {
429
+ goalSettings.reading_hours_target = window.statsConfig.readingHoursTarget || 1500;
430
+ goalSettings.character_count_target = window.statsConfig.characterCountTarget || 25000000;
431
+ goalSettings.games_target = window.statsConfig.gamesTarget || 100;
432
+ return;
433
+ }
434
+ try {
435
+ const response = await fetch('/api/settings');
436
+ if (response.ok) {
437
+ const settings = await response.json();
438
+ goalSettings = {
439
+ reading_hours_target: settings.reading_hours_target || 1500,
440
+ character_count_target: settings.character_count_target || 25000000,
441
+ games_target: settings.games_target || 100
442
+ };
443
+ }
444
+ } catch (error) {
445
+ console.error('Error loading goal settings:', error);
446
+ }
447
+ }
448
+
449
+ // Function to calculate 90-day rolling average for projections
450
+ function calculate90DayAverage(allLinesData, metricType) {
451
+ if (!allLinesData || allLinesData.length === 0) {
452
+ return 0;
453
+ }
454
+
455
+ const today = new Date();
456
+ const ninetyDaysAgo = new Date(today.getTime() - (90 * 24 * 60 * 60 * 1000));
457
+
458
+ // Filter data to last 90 days
459
+ const recentData = allLinesData.filter(line => {
460
+ const lineDate = new Date(line.timestamp * 1000);
461
+ return lineDate >= ninetyDaysAgo && lineDate <= today;
462
+ });
463
+
464
+ if (recentData.length === 0) {
465
+ return 0;
466
+ }
467
+
468
+ let dailyTotals = {};
469
+
470
+ if (metricType === 'hours') {
471
+ // Group by day and calculate reading time using AFK timer logic
472
+ const dailyTimestamps = {};
473
+ for (const line of recentData) {
474
+ const ts = parseFloat(line.timestamp);
475
+ if (isNaN(ts)) continue;
476
+ const dateObj = new Date(ts * 1000);
477
+ const dateStr = `${dateObj.getFullYear()}-${String(dateObj.getMonth() + 1).padStart(2, '0')}-${String(dateObj.getDate()).padStart(2, '0')}`;
478
+ if (!dailyTimestamps[dateStr]) {
479
+ dailyTimestamps[dateStr] = [];
480
+ }
481
+ dailyTimestamps[dateStr].push(ts);
482
+ }
483
+
484
+ for (const [dateStr, timestamps] of Object.entries(dailyTimestamps)) {
485
+ if (timestamps.length >= 2) {
486
+ timestamps.sort((a, b) => a - b);
487
+ let dayHours = 0;
488
+ let afkTimerSeconds = window.statsConfig ? window.statsConfig.afkTimerSeconds : 120;
489
+
490
+ for (let i = 1; i < timestamps.length; i++) {
491
+ const gap = timestamps[i] - timestamps[i-1];
492
+ dayHours += Math.min(gap, afkTimerSeconds) / 3600;
493
+ }
494
+ dailyTotals[dateStr] = dayHours;
495
+ } else if (timestamps.length === 1) {
496
+ dailyTotals[dateStr] = 1 / 3600; // Minimal activity
497
+ }
498
+ }
499
+ } else if (metricType === 'characters') {
500
+ // Group by day and sum characters
501
+ for (const line of recentData) {
502
+ const ts = parseFloat(line.timestamp);
503
+ if (isNaN(ts)) continue;
504
+ const dateObj = new Date(ts * 1000);
505
+ const dateStr = `${dateObj.getFullYear()}-${String(dateObj.getMonth() + 1).padStart(2, '0')}-${String(dateObj.getDate()).padStart(2, '0')}`;
506
+ dailyTotals[dateStr] = (dailyTotals[dateStr] || 0) + (line.characters || 0);
507
+ }
508
+ } else if (metricType === 'games') {
509
+ // Group by day and count unique games
510
+ const dailyGames = {};
511
+ for (const line of recentData) {
512
+ const ts = parseFloat(line.timestamp);
513
+ if (isNaN(ts)) continue;
514
+ const dateObj = new Date(ts * 1000);
515
+ const dateStr = `${dateObj.getFullYear()}-${String(dateObj.getMonth() + 1).padStart(2, '0')}-${String(dateObj.getDate()).padStart(2, '0')}`;
516
+ if (!dailyGames[dateStr]) {
517
+ dailyGames[dateStr] = new Set();
518
+ }
519
+ dailyGames[dateStr].add(line.game_name);
520
+ }
521
+
522
+ for (const [dateStr, gamesSet] of Object.entries(dailyGames)) {
523
+ dailyTotals[dateStr] = gamesSet.size;
524
+ }
525
+ }
526
+
527
+ const totalDays = Object.keys(dailyTotals).length;
528
+ if (totalDays === 0) {
529
+ return 0;
530
+ }
531
+
532
+ const totalValue = Object.values(dailyTotals).reduce((sum, value) => sum + value, 0);
533
+ return totalValue / totalDays;
534
+ }
535
+
536
+ // Function to format projection text
537
+ function formatProjection(currentValue, targetValue, dailyAverage, metricType) {
538
+ if (currentValue >= targetValue) {
539
+ return 'Goal achieved! 🎉';
540
+ }
541
+
542
+ if (dailyAverage <= 0) {
543
+ return 'No recent activity';
544
+ }
545
+
546
+ const remaining = targetValue - currentValue;
547
+ const daysToComplete = Math.ceil(remaining / dailyAverage);
548
+
549
+ if (daysToComplete <= 0) {
550
+ return 'Goal achieved! 🎉';
551
+ } else if (daysToComplete === 1) {
552
+ return '~1 day remaining';
553
+ } else if (daysToComplete <= 7) {
554
+ return `~${daysToComplete} days remaining`;
555
+ } else if (daysToComplete <= 30) {
556
+ const weeks = Math.ceil(daysToComplete / 7);
557
+ return `~${weeks} week${weeks > 1 ? 's' : ''} remaining`;
558
+ } else if (daysToComplete <= 365) {
559
+ const months = Math.ceil(daysToComplete / 30);
560
+ return `~${months} month${months > 1 ? 's' : ''} remaining`;
561
+ } else {
562
+ const years = Math.ceil(daysToComplete / 365);
563
+ return `~${years} year${years > 1 ? 's' : ''} remaining`;
564
+ }
565
+ }
566
+
567
+ // Function to format large numbers
568
+ function formatGoalNumber(num) {
569
+ if (num >= 1000000) {
570
+ return (num / 1000000).toFixed(1) + 'M';
571
+ } else if (num >= 1000) {
572
+ return (num / 1000).toFixed(1) + 'K';
573
+ }
574
+ return num.toString();
575
+ }
576
+
577
+ // Function to update progress bar color based on percentage
578
+ function updateProgressBarColor(progressElement, percentage) {
579
+ // Remove existing completion classes
580
+ progressElement.classList.remove('completion-0', 'completion-25', 'completion-50', 'completion-75', 'completion-100');
581
+
582
+ // Add appropriate class based on percentage
583
+ if (percentage >= 100) {
584
+ progressElement.classList.add('completion-100');
585
+ } else if (percentage >= 75) {
586
+ progressElement.classList.add('completion-75');
587
+ } else if (percentage >= 50) {
588
+ progressElement.classList.add('completion-50');
589
+ } else if (percentage >= 25) {
590
+ progressElement.classList.add('completion-25');
591
+ } else {
592
+ progressElement.classList.add('completion-0');
593
+ }
594
+ }
595
+
596
+ // Helper function to update goal progress UI with provided data
597
+ function updateGoalProgressUI(allGamesStats, allLinesData) {
598
+ if (!allGamesStats) {
599
+ throw new Error('No stats data available');
600
+ }
601
+
602
+ // Calculate current progress
603
+ const currentHours = allGamesStats.total_time_hours || 0;
604
+ const currentCharacters = allGamesStats.total_characters || 0;
605
+ const currentGames = allGamesStats.unique_games || 0;
606
+
607
+ // Calculate 90-day averages for projections
608
+ const dailyHoursAvg = calculate90DayAverage(allLinesData, 'hours');
609
+ const dailyCharsAvg = calculate90DayAverage(allLinesData, 'characters');
610
+ const dailyGamesAvg = calculate90DayAverage(allLinesData, 'games');
611
+
612
+ // Update Hours Goal
613
+ const hoursPercentage = Math.min(100, (currentHours / goalSettings.reading_hours_target) * 100);
614
+ document.getElementById('goalHoursCurrent').textContent = Math.floor(currentHours).toLocaleString();
615
+ document.getElementById('goalHoursTarget').textContent = goalSettings.reading_hours_target.toLocaleString();
616
+ document.getElementById('goalHoursPercentage').textContent = Math.floor(hoursPercentage) + '%';
617
+ document.getElementById('goalHoursProjection').textContent =
618
+ formatProjection(currentHours, goalSettings.reading_hours_target, dailyHoursAvg, 'hours');
619
+
620
+ const hoursProgressBar = document.getElementById('goalHoursProgress');
621
+ hoursProgressBar.style.width = hoursPercentage + '%';
622
+ hoursProgressBar.setAttribute('data-percentage', Math.floor(hoursPercentage / 25) * 25);
623
+ updateProgressBarColor(hoursProgressBar, hoursPercentage);
624
+
625
+ // Update Characters Goal
626
+ const charsPercentage = Math.min(100, (currentCharacters / goalSettings.character_count_target) * 100);
627
+ document.getElementById('goalCharsCurrent').textContent = formatGoalNumber(currentCharacters);
628
+ document.getElementById('goalCharsTarget').textContent = formatGoalNumber(goalSettings.character_count_target);
629
+ document.getElementById('goalCharsPercentage').textContent = Math.floor(charsPercentage) + '%';
630
+ document.getElementById('goalCharsProjection').textContent =
631
+ formatProjection(currentCharacters, goalSettings.character_count_target, dailyCharsAvg, 'characters');
632
+
633
+ const charsProgressBar = document.getElementById('goalCharsProgress');
634
+ charsProgressBar.style.width = charsPercentage + '%';
635
+ charsProgressBar.setAttribute('data-percentage', Math.floor(charsPercentage / 25) * 25);
636
+ updateProgressBarColor(charsProgressBar, charsPercentage);
637
+
638
+ // Update Games Goal
639
+ const gamesPercentage = Math.min(100, (currentGames / goalSettings.games_target) * 100);
640
+ document.getElementById('goalGamesCurrent').textContent = currentGames.toLocaleString();
641
+ document.getElementById('goalGamesTarget').textContent = goalSettings.games_target.toLocaleString();
642
+ document.getElementById('goalGamesPercentage').textContent = Math.floor(gamesPercentage) + '%';
643
+ document.getElementById('goalGamesProjection').textContent =
644
+ formatProjection(currentGames, goalSettings.games_target, dailyGamesAvg, 'games');
645
+
646
+ const gamesProgressBar = document.getElementById('goalGamesProgress');
647
+ gamesProgressBar.style.width = gamesPercentage + '%';
648
+ gamesProgressBar.setAttribute('data-percentage', Math.floor(gamesPercentage / 25) * 25);
649
+ updateProgressBarColor(gamesProgressBar, gamesPercentage);
650
+ }
651
+
652
+ // Main function to load and display goal progress
653
+ async function loadGoalProgress() {
654
+ const goalProgressChart = document.getElementById('goalProgressChart');
655
+ const goalProgressLoading = document.getElementById('goalProgressLoading');
656
+ const goalProgressError = document.getElementById('goalProgressError');
657
+
658
+ if (!goalProgressChart) return;
659
+
660
+ try {
661
+ // Show loading state
662
+ goalProgressLoading.style.display = 'flex';
663
+ goalProgressError.style.display = 'none';
664
+
665
+ // Load goal settings and stats data
666
+ await loadGoalSettings();
667
+ const response = await fetch('/api/stats');
668
+ if (!response.ok) throw new Error('Failed to fetch stats data');
669
+
670
+ const data = await response.json();
671
+ const allGamesStats = data.allGamesStats;
672
+ const allLinesData = data.allLinesData || [];
673
+
674
+ // Update the UI using the shared helper function
675
+ updateGoalProgressUI(allGamesStats, allLinesData);
676
+
677
+ // Hide loading state
678
+ goalProgressLoading.style.display = 'none';
679
+
680
+ } catch (error) {
681
+ console.error('Error loading goal progress:', error);
682
+ goalProgressLoading.style.display = 'none';
683
+ goalProgressError.style.display = 'block';
684
+ }
685
+ }
686
+
687
+ // ================================
688
+ // Utility to convert date strings to Unix timestamps
689
+ // Returns start of day for startDate and end of day for endDate
690
+ // ================================
691
+ function getUnixTimestamps(startDate, endDate) {
692
+ const start = new Date(startDate + 'T00:00:00');
693
+ const startTimestamp = Math.floor(start.getTime() / 1000); // convert ms to s
694
+
695
+ const end = new Date(endDate + 'T23:59:59.999');
696
+ const endTimestamp = Math.floor(end.getTime() / 1000); // convert ms to s
697
+
698
+ return { startTimestamp, endTimestamp };
699
+ }
700
+
701
+ // ================================
702
+ // Initialize date inputs with sessionStorage or fetch initial values
703
+ // Dispatches "datesSet" event once dates are set
704
+ // ================================
705
+ function initializeDates() {
706
+ const fromDateInput = document.getElementById('fromDate');
707
+ const toDateInput = document.getElementById('toDate');
708
+
709
+ const fromDate = sessionStorage.getItem("fromDate");
710
+ const toDate = sessionStorage.getItem("toDate");
711
+
712
+ if (!(fromDate && toDate)) {
713
+ fetch('/api/stats')
714
+ .then(response => response.json())
715
+ .then(response_json => {
716
+ // Get first date from API
717
+ const firstDate = response_json.allGamesStats.first_date;
718
+ fromDateInput.value = firstDate;
719
+
720
+ // Get today's date
721
+ const today = new Date();
722
+ const toDate = today.toLocaleDateString('en-CA');
723
+ toDateInput.value = toDate;
724
+
725
+ // Save in sessionStorage
726
+ sessionStorage.setItem("fromDate", firstDate);
727
+ sessionStorage.setItem("toDate", toDate);
728
+
729
+ document.dispatchEvent(new Event("datesSet"));
730
+ });
731
+ } else {
732
+ // If values already in sessionStorage, set inputs from there
733
+ fromDateInput.value = fromDate;
734
+ toDateInput.value = toDate;
735
+
736
+ document.dispatchEvent(new Event("datesSet"));
737
+ }
738
+ }
739
+
740
+ const fromDateInput = document.getElementById('fromDate');
741
+ const toDateInput = document.getElementById('toDate');
742
+ const popup = document.getElementById('dateErrorPopup');
743
+ const closePopupBtn = document.getElementById('closePopupBtn');
744
+
745
+ document.addEventListener("datesSet", () => {
746
+ const fromDate = sessionStorage.getItem("fromDate");
747
+ const toDate = sessionStorage.getItem("toDate");
748
+ const { startTimestamp, endTimestamp } = getUnixTimestamps(fromDate, toDate);
749
+
750
+ loadStatsData(startTimestamp, endTimestamp);
751
+ });
752
+
753
+
754
+ function handleDateChange() {
755
+ const fromDateStr = fromDateInput.value;
756
+ const toDateStr = toDateInput.value;
757
+
758
+ sessionStorage.setItem("fromDate", fromDateStr);
759
+ sessionStorage.setItem("toDate", toDateStr);
760
+
761
+ // Validate date order
762
+ if (fromDateStr && toDateStr && new Date(fromDateStr) > new Date(toDateStr)) {
763
+ popup.classList.remove("hidden");
764
+ return;
765
+ }
766
+
767
+ const { startTimestamp, endTimestamp } = getUnixTimestamps(fromDateStr, toDateStr);
768
+
769
+ loadStatsData(startTimestamp, endTimestamp);
770
+ }
771
+
772
+ // Attach listeners to both date inputs
773
+ fromDateInput.addEventListener("change", handleDateChange);
774
+ toDateInput.addEventListener("change", handleDateChange);
775
+
776
+ initializeDates();
777
+
778
+ // Popup close button
779
+ closePopupBtn.addEventListener("click", () => {
780
+ popup.classList.add("hidden");
781
+ });
782
+
783
+ // Function to update goal progress using existing stats data
784
+ async function updateGoalProgressWithData(statsData) {
785
+ const goalProgressChart = document.getElementById('goalProgressChart');
786
+ const goalProgressLoading = document.getElementById('goalProgressLoading');
787
+ const goalProgressError = document.getElementById('goalProgressError');
788
+
789
+ if (!goalProgressChart) return;
790
+
791
+ try {
792
+ // Load goal settings if not already loaded
793
+ if (!goalSettings.reading_hours_target) {
794
+ await loadGoalSettings();
795
+ }
796
+
797
+ const allGamesStats = statsData.allGamesStats;
798
+ const allLinesData = statsData.allLinesData || [];
799
+
800
+ // Update the UI using the shared helper function
801
+ updateGoalProgressUI(allGamesStats, allLinesData);
802
+
803
+ // Hide loading and error states
804
+ goalProgressLoading.style.display = 'none';
805
+ goalProgressError.style.display = 'none';
806
+
807
+ } catch (error) {
808
+ console.error('Error updating goal progress:', error);
809
+ goalProgressLoading.style.display = 'none';
810
+ goalProgressError.style.display = 'block';
811
+ }
812
+ }
813
+
814
+ // Load goal progress initially
815
+ setTimeout(() => {
816
+ loadGoalProgress();
817
+ }, 1000);
818
+
819
+ // Make functions globally available
820
+ window.createHeatmap = createHeatmap;
821
+ window.loadStatsData = loadStatsData;
822
+ window.loadGoalProgress = loadGoalProgress;
823
+
824
+ // Dashboard functionality
825
+ function loadDashboardData(data = null, end_timestamp = null) {
826
+ function updateTodayOverview(allLinesData) {
827
+ // Get today's date string (YYYY-MM-DD), timezone aware (local time)
828
+ const today = new Date();
829
+ const pad = n => n.toString().padStart(2, '0');
830
+ const todayStr = `${today.getFullYear()}-${pad(today.getMonth() + 1)}-${pad(today.getDate())}`;
831
+ document.getElementById('todayDate').textContent = todayStr;
832
+
833
+ // Filter lines for today
834
+ const todayLines = (allLinesData || []).filter(line => {
835
+ if (!line.timestamp) return false;
836
+ const ts = parseFloat(line.timestamp);
837
+ if (isNaN(ts)) return false;
838
+ const dateObj = new Date(ts * 1000);
839
+ const lineDate = `${dateObj.getFullYear()}-${pad(dateObj.getMonth() + 1)}-${pad(dateObj.getDate())}`;
840
+ return lineDate === todayStr;
841
+ });
842
+
843
+ // Calculate total characters read today (only valid numbers)
844
+ const totalChars = todayLines.reduce((sum, line) => {
845
+ const chars = Number(line.characters);
846
+ return sum + (isNaN(chars) ? 0 : chars);
847
+ }, 0);
848
+
849
+ // Calculate sessions (count gaps > session threshold as new sessions)
850
+ let sessions = 0;
851
+ let sessionGap = window.statsConfig ? window.statsConfig.sessionGapSeconds : 3600;
852
+ if (todayLines.length > 0 && todayLines[0].session_id !== undefined) {
853
+ const sessionSet = new Set(todayLines.map(l => l.session_id));
854
+ sessions = sessionSet.size;
855
+ } else {
856
+ // Use timestamp gap logic
857
+ const timestamps = todayLines
858
+ .map(l => parseFloat(l.timestamp))
859
+ .filter(ts => !isNaN(ts))
860
+ .sort((a, b) => a - b);
861
+ if (timestamps.length > 0) {
862
+ sessions = 1;
863
+ for (let i = 1; i < timestamps.length; i++) {
864
+ if (timestamps[i] - timestamps[i - 1] > sessionGap) {
865
+ sessions += 1;
866
+ }
867
+ }
868
+ } else {
869
+ sessions = 0;
870
+ }
871
+ }
872
+
873
+ // Calculate total reading time (reuse AFK logic from calculateHeatmapStreaks)
874
+ let totalSeconds = 0;
875
+ const timestamps = todayLines
876
+ .map(l => parseFloat(l.timestamp))
877
+ .filter(ts => !isNaN(ts))
878
+ .sort((a, b) => a - b);
879
+ // Get AFK timer from settings modal if available
880
+ let afkTimerSeconds = window.statsConfig ? window.statsConfig.afkTimerSeconds : 120;
881
+ if (timestamps.length >= 2) {
882
+ for (let i = 1; i < timestamps.length; i++) {
883
+ const gap = timestamps[i] - timestamps[i-1];
884
+ totalSeconds += Math.min(gap, afkTimerSeconds);
885
+ }
886
+ } else if (timestamps.length === 1) {
887
+ totalSeconds = 1;
888
+ }
889
+ let totalHours = totalSeconds / 3600;
890
+
891
+ // Calculate chars/hour
892
+ let charsPerHour = '-';
893
+ if (totalChars > 0) {
894
+ // Avoid division by zero, set minimum time to 1 minute if activity exists
895
+ if (totalHours <= 0) totalHours = 1/60;
896
+ charsPerHour = Math.round(totalChars / totalHours).toLocaleString();
897
+ }
898
+
899
+ // Format hours for display
900
+ let hoursDisplay = '-';
901
+ if (totalHours > 0) {
902
+ const h = Math.floor(totalHours);
903
+ const m = Math.round((totalHours - h) * 60);
904
+ hoursDisplay = h > 0 ? `${h}h${m > 0 ? ' ' + m + 'm' : ''}` : `${m}m`;
905
+ }
906
+
907
+ document.getElementById('todayTotalHours').textContent = hoursDisplay;
908
+ document.getElementById('todayTotalChars').textContent = totalChars.toLocaleString();
909
+ document.getElementById('todaySessions').textContent = sessions;
910
+ document.getElementById('todayCharsPerHour').textContent = charsPerHour;
911
+ }
912
+
913
+ function updateOverviewForEndDay(allLinesData, endTimestamp) {
914
+ if (!endTimestamp) return;
915
+
916
+ const pad = n => n.toString().padStart(2, '0');
917
+
918
+ // Determine target date string (YYYY-MM-DD) from the end timestamp
919
+ const endDateObj = new Date(endTimestamp * 1000);
920
+ const targetDateStr = `${endDateObj.getFullYear()}-${pad(endDateObj.getMonth() + 1)}-${pad(endDateObj.getDate())}`;
921
+ document.getElementById('todayDate').textContent = targetDateStr;
922
+
923
+ // Filter lines that fall on the target date
924
+ const targetLines = (allLinesData || []).filter(line => {
925
+ if (!line.timestamp) return false;
926
+ const ts = parseFloat(line.timestamp);
927
+ if (isNaN(ts)) return false;
928
+ const dateObj = new Date(ts * 1000);
929
+ const lineDate = `${dateObj.getFullYear()}-${pad(dateObj.getMonth() + 1)}-${pad(dateObj.getDate())}`;
930
+ return lineDate === targetDateStr;
931
+ });
932
+
933
+ // Calculate total characters
934
+ const totalChars = targetLines.reduce((sum, line) => {
935
+ const chars = Number(line.characters);
936
+ return sum + (isNaN(chars) ? 0 : chars);
937
+ }, 0);
938
+
939
+ // Determine session gap (from settings or default)
940
+ let sessionGap = window.statsConfig?.sessionGapSeconds || 3600;
941
+
942
+ // Calculate sessions
943
+ let sessions = 0;
944
+ if (targetLines.length > 0 && targetLines[0].session_id !== undefined) {
945
+ const sessionSet = new Set(targetLines.map(l => l.session_id));
946
+ sessions = sessionSet.size;
947
+ } else {
948
+ const timestamps = targetLines
949
+ .map(l => parseFloat(l.timestamp))
950
+ .filter(ts => !isNaN(ts))
951
+ .sort((a, b) => a - b);
952
+ if (timestamps.length > 0) {
953
+ sessions = 1;
954
+ for (let i = 1; i < timestamps.length; i++) {
955
+ if (timestamps[i] - timestamps[i - 1] > sessionGap) {
956
+ sessions += 1;
957
+ }
958
+ }
959
+ }
960
+ }
961
+
962
+ // Calculate total reading time
963
+ let totalSeconds = 0;
964
+ const timestamps = targetLines
965
+ .map(l => parseFloat(l.timestamp))
966
+ .filter(ts => !isNaN(ts))
967
+ .sort((a, b) => a - b);
968
+
969
+ let afkTimerSeconds = window.statsConfig?.afkTimerSeconds || 120;
970
+
971
+ if (timestamps.length >= 2) {
972
+ for (let i = 1; i < timestamps.length; i++) {
973
+ const gap = timestamps[i] - timestamps[i - 1];
974
+ totalSeconds += Math.min(gap, afkTimerSeconds);
975
+ }
976
+ } else if (timestamps.length === 1) {
977
+ totalSeconds = 1;
978
+ }
979
+
980
+ let totalHours = totalSeconds / 3600;
981
+
982
+ // Calculate chars/hour
983
+ let charsPerHour = '-';
984
+ if (totalChars > 0) {
985
+ if (totalHours <= 0) totalHours = 1/60; // Minimum 1 minute
986
+ charsPerHour = Math.round(totalChars / totalHours).toLocaleString();
987
+ }
988
+
989
+ // Format hours for display
990
+ let hoursDisplay = '-';
991
+ if (totalHours > 0) {
992
+ const h = Math.floor(totalHours);
993
+ const m = Math.round((totalHours - h) * 60);
994
+ hoursDisplay = h > 0 ? `${h}h${m > 0 ? ' ' + m + 'm' : ''}` : `${m}m`;
995
+ }
996
+
997
+ // Update DOM
998
+ document.getElementById('todayTotalHours').textContent = hoursDisplay;
999
+ document.getElementById('todayTotalChars').textContent = totalChars.toLocaleString();
1000
+ document.getElementById('todaySessions').textContent = sessions;
1001
+ document.getElementById('todayCharsPerHour').textContent = charsPerHour;
1002
+ }
1003
+
1004
+ if (data && data.currentGameStats && data.allGamesStats) {
1005
+ // Use existing data if available
1006
+ updateCurrentGameDashboard(data.currentGameStats);
1007
+ updateAllGamesDashboard(data.allGamesStats);
1008
+
1009
+ if (data.allLinesData) {
1010
+ end_timestamp == null ? updateTodayOverview(data.allLinesData) : updateOverviewForEndDay(data.allLinesData, end_timestamp)
1011
+ }
1012
+
1013
+ hideDashboardLoading();
1014
+ } else {
1015
+ // Fetch fresh data
1016
+ showDashboardLoading();
1017
+ fetch('/api/stats')
1018
+ .then(response => response.json())
1019
+ .then(data => {
1020
+ if (data.currentGameStats && data.allGamesStats) {
1021
+ updateCurrentGameDashboard(data.currentGameStats);
1022
+ updateAllGamesDashboard(data.allGamesStats);
1023
+ if (data.allLinesData) {
1024
+ end_timestamp == null ? updateTodayOverview(data.allLinesData) : updateOverviewForEndDay(data.allLinesData, end_timestamp)
1025
+ }
1026
+ } else {
1027
+ showDashboardError();
1028
+ }
1029
+ hideDashboardLoading();
1030
+ })
1031
+ .catch(error => {
1032
+ console.error('Error fetching dashboard data:', error);
1033
+ showDashboardError();
1034
+ hideDashboardLoading();
1035
+ });
1036
+ }
1037
+ }
1038
+
1039
+ function updateCurrentGameDashboard(stats) {
1040
+ if (!stats) {
1041
+ showNoDashboardData('currentGameCard', 'No current game data available');
1042
+ return;
1043
+ }
1044
+
1045
+ // Update game name and subtitle
1046
+ document.getElementById('currentGameName').textContent = stats.game_name;
1047
+
1048
+ // Update main statistics
1049
+ document.getElementById('currentTotalChars').textContent = stats.total_characters_formatted;
1050
+ document.getElementById('currentTotalTime').textContent = stats.total_time_formatted;
1051
+ document.getElementById('currentReadingSpeed').textContent = stats.reading_speed_formatted;
1052
+ document.getElementById('currentSessions').textContent = stats.sessions.toLocaleString();
1053
+
1054
+ // Update progress section
1055
+ document.getElementById('currentMonthlyChars').textContent = stats.monthly_characters_formatted;
1056
+ document.getElementById('currentFirstDate').textContent = stats.first_date;
1057
+ document.getElementById('currentLastDate').textContent = stats.last_date;
1058
+
1059
+ // Update streak indicator
1060
+ const streakElement = document.getElementById('currentGameStreak');
1061
+ const streakValue = document.getElementById('currentStreakValue');
1062
+ if (stats.current_streak > 0) {
1063
+ streakValue.textContent = stats.current_streak;
1064
+ streakElement.style.display = 'inline-flex';
1065
+ } else {
1066
+ streakElement.style.display = 'none';
1067
+ }
1068
+
1069
+ // Show the card
1070
+ document.getElementById('currentGameCard').style.display = 'block';
1071
+ }
1072
+
1073
+ function updateAllGamesDashboard(stats) {
1074
+ if (!stats) {
1075
+ showNoDashboardData('allGamesCard', 'No games data available');
1076
+ return;
1077
+ }
1078
+
1079
+ // Update subtitle
1080
+ const gamesText = stats.unique_games === 1 ? '1 game played' : `${stats.unique_games} games played`;
1081
+ document.getElementById('totalGamesCount').textContent = gamesText;
1082
+
1083
+ // Update main statistics
1084
+ document.getElementById('allTotalChars').textContent = stats.total_characters_formatted;
1085
+ document.getElementById('allTotalTime').textContent = stats.total_time_formatted;
1086
+ document.getElementById('allReadingSpeed').textContent = stats.reading_speed_formatted;
1087
+ document.getElementById('allSessions').textContent = stats.sessions.toLocaleString();
1088
+
1089
+ // Update progress section
1090
+ document.getElementById('allMonthlyChars').textContent = stats.monthly_characters_formatted;
1091
+ document.getElementById('allUniqueGames').textContent = stats.unique_games.toLocaleString();
1092
+ document.getElementById('allTotalSentences').textContent = stats.total_sentences.toLocaleString();
1093
+
1094
+ // Update streak indicator
1095
+ const streakElement = document.getElementById('allGamesStreak');
1096
+ const streakValue = document.getElementById('allStreakValue');
1097
+ if (stats.current_streak > 0) {
1098
+ streakValue.textContent = stats.current_streak;
1099
+ streakElement.style.display = 'inline-flex';
1100
+ } else {
1101
+ streakElement.style.display = 'none';
1102
+ }
1103
+
1104
+
1105
+ // Show the card
1106
+ document.getElementById('allGamesCard').style.display = 'block';
1107
+ }
1108
+
1109
+ function showDashboardLoading() {
1110
+ document.getElementById('dashboardLoading').style.display = 'flex';
1111
+ document.getElementById('dashboardError').style.display = 'none';
1112
+ document.getElementById('currentGameCard').style.display = 'none';
1113
+ document.getElementById('allGamesCard').style.display = 'none';
1114
+ }
1115
+
1116
+ function hideDashboardLoading() {
1117
+ document.getElementById('dashboardLoading').style.display = 'none';
1118
+ }
1119
+
1120
+ function showDashboardError() {
1121
+ document.getElementById('dashboardError').style.display = 'block';
1122
+ document.getElementById('dashboardLoading').style.display = 'none';
1123
+ document.getElementById('currentGameCard').style.display = 'none';
1124
+ document.getElementById('allGamesCard').style.display = 'none';
1125
+ }
1126
+
1127
+ function showNoDashboardData(cardId, message) {
1128
+ const card = document.getElementById(cardId);
1129
+ const statsGrid = card.querySelector('.dashboard-stats-grid');
1130
+ const progressSection = card.querySelector('.dashboard-progress-section');
1131
+
1132
+ // Hide stats and progress sections
1133
+ statsGrid.style.display = 'none';
1134
+ progressSection.style.display = 'none';
1135
+
1136
+ // Add no data message
1137
+ let noDataMsg = card.querySelector('.no-data-message');
1138
+ if (!noDataMsg) {
1139
+ noDataMsg = document.createElement('div');
1140
+ noDataMsg.className = 'no-data-message';
1141
+ noDataMsg.style.cssText = 'text-align: center; padding: 40px 20px; color: var(--text-tertiary); font-style: italic;';
1142
+ card.appendChild(noDataMsg);
1143
+ }
1144
+ noDataMsg.textContent = message;
1145
+
1146
+ card.style.display = 'block';
1147
+ }
1148
+
1149
+ // Add click animations for dashboard stat items
1150
+ const statItems = document.querySelectorAll('.dashboard-stat-item');
1151
+ statItems.forEach(item => {
1152
+ item.addEventListener('click', function() {
1153
+ // Add click animation
1154
+ this.style.transform = 'scale(0.95)';
1155
+ setTimeout(() => {
1156
+ this.style.transform = '';
1157
+ }, 150);
1158
+ });
1159
+ });
1160
+
1161
+ // Add accessibility improvements
1162
+ statItems.forEach(item => {
1163
+ item.setAttribute('tabindex', '0');
1164
+ item.setAttribute('role', 'button');
1165
+
1166
+ item.addEventListener('keydown', function(e) {
1167
+ if (e.key === 'Enter' || e.key === ' ') {
1168
+ e.preventDefault();
1169
+ this.click();
1170
+ }
1171
+ });
1172
+ });
1173
+
1174
+ // Global function to retry dashboard loading
1175
+ window.loadDashboardData = loadDashboardData;
1176
+ });