GameSentenceMiner 2.19.16__py3-none-any.whl → 2.20.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.

Potentially problematic release.


This version of GameSentenceMiner might be problematic. Click here for more details.

Files changed (70) hide show
  1. GameSentenceMiner/__init__.py +39 -0
  2. GameSentenceMiner/anki.py +6 -3
  3. GameSentenceMiner/gametext.py +13 -2
  4. GameSentenceMiner/gsm.py +40 -3
  5. GameSentenceMiner/locales/en_us.json +4 -0
  6. GameSentenceMiner/locales/ja_jp.json +4 -0
  7. GameSentenceMiner/locales/zh_cn.json +4 -0
  8. GameSentenceMiner/obs.py +4 -1
  9. GameSentenceMiner/owocr/owocr/ocr.py +304 -134
  10. GameSentenceMiner/owocr/owocr/run.py +1 -1
  11. GameSentenceMiner/ui/anki_confirmation.py +4 -2
  12. GameSentenceMiner/ui/config_gui.py +12 -0
  13. GameSentenceMiner/util/configuration.py +6 -2
  14. GameSentenceMiner/util/cron/__init__.py +12 -0
  15. GameSentenceMiner/util/cron/daily_rollup.py +613 -0
  16. GameSentenceMiner/util/cron/jiten_update.py +397 -0
  17. GameSentenceMiner/util/cron/populate_games.py +154 -0
  18. GameSentenceMiner/util/cron/run_crons.py +148 -0
  19. GameSentenceMiner/util/cron/setup_populate_games_cron.py +118 -0
  20. GameSentenceMiner/util/cron_table.py +334 -0
  21. GameSentenceMiner/util/db.py +236 -49
  22. GameSentenceMiner/util/ffmpeg.py +23 -4
  23. GameSentenceMiner/util/games_table.py +340 -93
  24. GameSentenceMiner/util/jiten_api_client.py +188 -0
  25. GameSentenceMiner/util/stats_rollup_table.py +216 -0
  26. GameSentenceMiner/web/anki_api_endpoints.py +438 -220
  27. GameSentenceMiner/web/database_api.py +955 -1259
  28. GameSentenceMiner/web/jiten_database_api.py +1015 -0
  29. GameSentenceMiner/web/rollup_stats.py +672 -0
  30. GameSentenceMiner/web/static/css/dashboard-shared.css +75 -13
  31. GameSentenceMiner/web/static/css/overview.css +604 -47
  32. GameSentenceMiner/web/static/css/search.css +226 -0
  33. GameSentenceMiner/web/static/css/shared.css +762 -0
  34. GameSentenceMiner/web/static/css/stats.css +221 -0
  35. GameSentenceMiner/web/static/js/components/bar-chart.js +339 -0
  36. GameSentenceMiner/web/static/js/database-bulk-operations.js +320 -0
  37. GameSentenceMiner/web/static/js/database-game-data.js +390 -0
  38. GameSentenceMiner/web/static/js/database-game-operations.js +213 -0
  39. GameSentenceMiner/web/static/js/database-helpers.js +44 -0
  40. GameSentenceMiner/web/static/js/database-jiten-integration.js +750 -0
  41. GameSentenceMiner/web/static/js/database-popups.js +89 -0
  42. GameSentenceMiner/web/static/js/database-tabs.js +64 -0
  43. GameSentenceMiner/web/static/js/database-text-management.js +371 -0
  44. GameSentenceMiner/web/static/js/database.js +86 -718
  45. GameSentenceMiner/web/static/js/goals.js +79 -18
  46. GameSentenceMiner/web/static/js/heatmap.js +29 -23
  47. GameSentenceMiner/web/static/js/overview.js +1205 -339
  48. GameSentenceMiner/web/static/js/regex-patterns.js +100 -0
  49. GameSentenceMiner/web/static/js/search.js +215 -18
  50. GameSentenceMiner/web/static/js/shared.js +193 -39
  51. GameSentenceMiner/web/static/js/stats.js +1536 -179
  52. GameSentenceMiner/web/stats.py +1142 -269
  53. GameSentenceMiner/web/stats_api.py +2104 -0
  54. GameSentenceMiner/web/templates/anki_stats.html +4 -18
  55. GameSentenceMiner/web/templates/components/date-range.html +118 -3
  56. GameSentenceMiner/web/templates/components/html-head.html +40 -6
  57. GameSentenceMiner/web/templates/components/js-config.html +8 -8
  58. GameSentenceMiner/web/templates/components/regex-input.html +160 -0
  59. GameSentenceMiner/web/templates/database.html +564 -117
  60. GameSentenceMiner/web/templates/goals.html +41 -5
  61. GameSentenceMiner/web/templates/overview.html +159 -129
  62. GameSentenceMiner/web/templates/search.html +78 -9
  63. GameSentenceMiner/web/templates/stats.html +159 -5
  64. GameSentenceMiner/web/texthooking_page.py +280 -111
  65. {gamesentenceminer-2.19.16.dist-info → gamesentenceminer-2.20.0.dist-info}/METADATA +43 -2
  66. {gamesentenceminer-2.19.16.dist-info → gamesentenceminer-2.20.0.dist-info}/RECORD +70 -47
  67. {gamesentenceminer-2.19.16.dist-info → gamesentenceminer-2.20.0.dist-info}/WHEEL +0 -0
  68. {gamesentenceminer-2.19.16.dist-info → gamesentenceminer-2.20.0.dist-info}/entry_points.txt +0 -0
  69. {gamesentenceminer-2.19.16.dist-info → gamesentenceminer-2.20.0.dist-info}/licenses/LICENSE +0 -0
  70. {gamesentenceminer-2.19.16.dist-info → gamesentenceminer-2.20.0.dist-info}/top_level.txt +0 -0
@@ -1,6 +1,186 @@
1
1
  // Overview Page JavaScript
2
2
  // Dependencies: shared.js (provides utility functions like showElement, hideElement, escapeHtml)
3
3
 
4
+ // ============================================================================
5
+ // PERFORMANCE OPTIMIZATION: Cache frequently accessed DOM elements
6
+ // ============================================================================
7
+ const DOM_CACHE = {
8
+ // Dashboard cards
9
+ currentGameCard: null,
10
+ allGamesCard: null,
11
+ todayOverviewCard: null,
12
+
13
+ // Current game elements
14
+ currentGameName: null,
15
+ currentTotalChars: null,
16
+ currentTotalTime: null,
17
+ currentReadingSpeed: null,
18
+ currentEstimatedTimeLeft: null,
19
+ currentGameStreak: null,
20
+ currentStreakValue: null,
21
+ gameCompletionBtn: null,
22
+
23
+ // Session elements
24
+ currentSessionTotalHours: null,
25
+ currentSessionTotalChars: null,
26
+ currentSessionStartTime: null,
27
+ currentSessionEndTime: null,
28
+ currentSessionCharsPerHour: null,
29
+
30
+ // Game metadata elements
31
+ gameContentGrid: null,
32
+ gamePhotoSection: null,
33
+ gamePhoto: null,
34
+ gameTitleOriginal: null,
35
+ gameTitleRomaji: null,
36
+ gameTitleEnglish: null,
37
+ gameTypeBadge: null,
38
+ gameDescription: null,
39
+ descriptionExpandBtn: null,
40
+ gameLinksContainer: null,
41
+ gameLinksPills: null,
42
+ gameProgressContainer: null,
43
+ gameProgressPercentage: null,
44
+ gameProgressFill: null,
45
+ gameStartDate: null,
46
+ gameEstimatedEndDate: null,
47
+
48
+ // Today's overview elements
49
+ todayDate: null,
50
+ todayTotalHours: null,
51
+ todayTotalChars: null,
52
+ todaySessions: null,
53
+ todayCharsPerHour: null,
54
+
55
+ // All games elements
56
+ totalGamesCount: null,
57
+ allTotalChars: null,
58
+ allTotalTime: null,
59
+ allReadingSpeed: null,
60
+ allSessions: null,
61
+ allUniqueGames: null,
62
+ allTotalSentences: null,
63
+ allGamesStreak: null,
64
+ allStreakValue: null,
65
+
66
+ // Loading/error states
67
+ dashboardLoading: null,
68
+ dashboardError: null,
69
+
70
+ // Heatmap
71
+ heatmapContainer: null,
72
+
73
+ // Session navigation
74
+ prevSessionBtn: null,
75
+ nextSessionBtn: null,
76
+ deleteSessionBtn: null,
77
+
78
+ // Initialize all cached references
79
+ init() {
80
+ // Dashboard cards
81
+ this.currentGameCard = document.getElementById('currentGameCard');
82
+ this.allGamesCard = document.getElementById('allGamesCard');
83
+ this.todayOverviewCard = document.getElementById('todayOverviewCard');
84
+
85
+ // Current game elements
86
+ this.currentGameName = document.getElementById('currentGameName');
87
+ this.currentTotalChars = document.getElementById('currentTotalChars');
88
+ this.currentTotalTime = document.getElementById('currentTotalTime');
89
+ this.currentReadingSpeed = document.getElementById('currentReadingSpeed');
90
+ this.currentEstimatedTimeLeft = document.getElementById('currentEstimatedTimeLeft');
91
+ this.currentGameStreak = document.getElementById('currentGameStreak');
92
+ this.currentStreakValue = document.getElementById('currentStreakValue');
93
+ this.gameCompletionBtn = document.getElementById('gameCompletionBtn');
94
+
95
+ // Session elements
96
+ this.currentSessionTotalHours = document.getElementById('currentSessionTotalHours');
97
+ this.currentSessionTotalChars = document.getElementById('currentSessionTotalChars');
98
+ this.currentSessionStartTime = document.getElementById('currentSessionStartTime');
99
+ this.currentSessionEndTime = document.getElementById('currentSessionEndTime');
100
+ this.currentSessionCharsPerHour = document.getElementById('currentSessionCharsPerHour');
101
+
102
+ // Game metadata elements
103
+ this.gameContentGrid = document.getElementById('gameContentGrid');
104
+ this.gamePhotoSection = document.getElementById('gamePhotoSection');
105
+ this.gamePhoto = document.getElementById('gamePhoto');
106
+ this.gameTitleOriginal = document.getElementById('gameTitleOriginal');
107
+ this.gameTitleRomaji = document.getElementById('gameTitleRomaji');
108
+ this.gameTitleEnglish = document.getElementById('gameTitleEnglish');
109
+ this.gameTypeBadge = document.getElementById('gameTypeBadge');
110
+ this.gameDescription = document.getElementById('gameDescription');
111
+ this.descriptionExpandBtn = document.getElementById('descriptionExpandBtn');
112
+ this.gameLinksContainer = document.getElementById('gameLinksContainer');
113
+ this.gameLinksPills = document.getElementById('gameLinksPills');
114
+ this.gameProgressContainer = document.getElementById('gameProgressContainer');
115
+ this.gameProgressPercentage = document.getElementById('gameProgressPercentage');
116
+ this.gameProgressFill = document.getElementById('gameProgressFill');
117
+ this.gameStartDate = document.getElementById('gameStartDate');
118
+ this.gameEstimatedEndDate = document.getElementById('gameEstimatedEndDate');
119
+
120
+ // Today's overview elements
121
+ this.todayDate = document.getElementById('todayDate');
122
+ this.todayTotalHours = document.getElementById('todayTotalHours');
123
+ this.todayTotalChars = document.getElementById('todayTotalChars');
124
+ this.todaySessions = document.getElementById('todaySessions');
125
+ this.todayCharsPerHour = document.getElementById('todayCharsPerHour');
126
+
127
+ // All games elements
128
+ this.totalGamesCount = document.getElementById('totalGamesCount');
129
+ this.allTotalChars = document.getElementById('allTotalChars');
130
+ this.allTotalTime = document.getElementById('allTotalTime');
131
+ this.allReadingSpeed = document.getElementById('allReadingSpeed');
132
+ this.allSessions = document.getElementById('allSessions');
133
+ this.allUniqueGames = document.getElementById('allUniqueGames');
134
+ this.allTotalSentences = document.getElementById('allTotalSentences');
135
+ this.allGamesStreak = document.getElementById('allGamesStreak');
136
+ this.allStreakValue = document.getElementById('allStreakValue');
137
+
138
+ // Loading/error states
139
+ this.dashboardLoading = document.getElementById('dashboardLoading');
140
+ this.dashboardError = document.getElementById('dashboardError');
141
+
142
+ // Heatmap
143
+ this.heatmapContainer = document.getElementById('heatmapContainer');
144
+
145
+ // Session navigation
146
+ this.prevSessionBtn = document.querySelector('.prev-session-btn');
147
+ this.nextSessionBtn = document.querySelector('.next-session-btn');
148
+ this.deleteSessionBtn = document.querySelector('.delete-session-btn');
149
+ }
150
+ };
151
+
152
+ // ============================================================================
153
+ // PERFORMANCE OPTIMIZATION: Cache API responses to avoid redundant fetches
154
+ // ============================================================================
155
+ const API_CACHE = {
156
+ statsData: null,
157
+ statsDataTimestamp: null,
158
+ CACHE_DURATION: 5000, // 5 seconds cache
159
+
160
+ setStatsData(data) {
161
+ this.statsData = data;
162
+ this.statsDataTimestamp = Date.now();
163
+ },
164
+
165
+ getStatsData() {
166
+ if (!this.statsData || !this.statsDataTimestamp) {
167
+ return null;
168
+ }
169
+ // Check if cache is still valid
170
+ if (Date.now() - this.statsDataTimestamp > this.CACHE_DURATION) {
171
+ this.statsData = null;
172
+ this.statsDataTimestamp = null;
173
+ return null;
174
+ }
175
+ return this.statsData;
176
+ },
177
+
178
+ clearStatsData() {
179
+ this.statsData = null;
180
+ this.statsDataTimestamp = null;
181
+ }
182
+ };
183
+
4
184
  // Helper function to detect the current theme based on the app's theme system
5
185
  function getCurrentTheme() {
6
186
  const dataTheme = document.documentElement.getAttribute('data-theme');
@@ -21,6 +201,8 @@ function getThemeTextColor() {
21
201
  }
22
202
 
23
203
  document.addEventListener('DOMContentLoaded', function () {
204
+ // Initialize DOM cache
205
+ DOM_CACHE.init();
24
206
 
25
207
  // Custom streak calculation function for activity heatmap (includes average daily time)
26
208
  function calculateActivityStreaks(grid, yearData, allLinesForYear = []) {
@@ -84,54 +266,82 @@ document.addEventListener('DOMContentLoaded', function () {
84
266
  // Calculate average daily time for this year
85
267
  let avgDailyTime = "-";
86
268
  if (allLinesForYear && allLinesForYear.length > 0) {
87
- // Group timestamps by day for this year
88
- const dailyTimestamps = {};
89
- for (const line of allLinesForYear) {
90
- const ts = parseFloat(line.timestamp);
91
- if (isNaN(ts)) continue;
92
- const dateObj = new Date(ts * 1000);
93
- const dateStr = `${dateObj.getFullYear()}-${String(dateObj.getMonth() + 1).padStart(2, '0')}-${String(dateObj.getDate()).padStart(2, '0')}`;
94
- if (!dailyTimestamps[dateStr]) {
95
- dailyTimestamps[dateStr] = [];
96
- }
97
- dailyTimestamps[dateStr].push(parseFloat(line.timestamp));
98
- }
269
+ // Check if we have pre-calculated reading time from rollup data
270
+ const hasReadingTimeData = allLinesForYear.some(line => line.reading_time_seconds !== undefined);
99
271
 
100
- // Calculate reading time for each day with activity
101
- let totalHours = 0;
102
- let activeDays = 0;
103
- let afkTimerSeconds = window.statsConfig ? window.statsConfig.afkTimerSeconds : 120;
104
-
105
- for (const [dateStr, timestamps] of Object.entries(dailyTimestamps)) {
106
- if (timestamps.length >= 2) {
107
- timestamps.sort((a, b) => a - b);
108
- let dayReadingTime = 0;
109
-
110
- for (let i = 1; i < timestamps.length; i++) {
111
- const gap = timestamps[i] - timestamps[i-1];
112
- dayReadingTime += Math.min(gap, afkTimerSeconds);
272
+ if (hasReadingTimeData) {
273
+ // Use pre-calculated reading time from rollup data (FAST!)
274
+ let totalHours = 0;
275
+ let activeDays = 0;
276
+
277
+ for (const line of allLinesForYear) {
278
+ if (line.reading_time_seconds !== undefined && line.reading_time_seconds > 0) {
279
+ totalHours += line.reading_time_seconds / 3600;
280
+ activeDays++;
113
281
  }
282
+ }
283
+
284
+ if (activeDays > 0) {
285
+ const avgHours = totalHours / activeDays;
286
+ if (avgHours < 1) {
287
+ const minutes = Math.round(avgHours * 60);
288
+ avgDailyTime = `${minutes}m`;
289
+ } else {
290
+ const hours = Math.floor(avgHours);
291
+ const minutes = Math.round((avgHours - hours) * 60);
292
+ avgDailyTime = minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`;
293
+ }
294
+ }
295
+ } else {
296
+ // Fallback: Calculate from individual timestamps (for today's data)
297
+ const dailyTimestamps = {};
298
+ for (const line of allLinesForYear) {
299
+ const ts = parseFloat(line.timestamp);
300
+ if (isNaN(ts)) continue;
301
+ const dateObj = new Date(ts * 1000);
302
+ const dateStr = `${dateObj.getFullYear()}-${String(dateObj.getMonth() + 1).padStart(2, '0')}-${String(dateObj.getDate()).padStart(2, '0')}`;
303
+ if (!dailyTimestamps[dateStr]) {
304
+ dailyTimestamps[dateStr] = [];
305
+ }
306
+ dailyTimestamps[dateStr].push(parseFloat(line.timestamp));
307
+ }
308
+
309
+ // Calculate reading time for each day with activity
310
+ let totalHours = 0;
311
+ let activeDays = 0;
312
+ let afkTimerSeconds = window.statsConfig ? window.statsConfig.afkTimerSeconds : 120;
313
+
314
+ for (const [dateStr, timestamps] of Object.entries(dailyTimestamps)) {
315
+ if (timestamps.length >= 2) {
316
+ timestamps.sort((a, b) => a - b);
317
+ let dayReadingTime = 0;
318
+
319
+ for (let i = 1; i < timestamps.length; i++) {
320
+ const gap = timestamps[i] - timestamps[i-1];
321
+ dayReadingTime += Math.min(gap, afkTimerSeconds);
322
+ }
114
323
 
115
- if (dayReadingTime > 0) {
116
- totalHours += dayReadingTime / 3600;
324
+ if (dayReadingTime > 0) {
325
+ totalHours += dayReadingTime / 3600;
326
+ activeDays++;
327
+ }
328
+ } else if (timestamps.length === 1) {
329
+ // Single timestamp - count as minimal activity (1 second)
330
+ totalHours += 1 / 3600;
117
331
  activeDays++;
118
332
  }
119
- } else if (timestamps.length === 1) {
120
- // Single timestamp - count as minimal activity (1 second)
121
- totalHours += 1 / 3600;
122
- activeDays++;
123
333
  }
124
- }
125
-
126
- if (activeDays > 0) {
127
- const avgHours = totalHours / activeDays;
128
- if (avgHours < 1) {
129
- const minutes = Math.round(avgHours * 60);
130
- avgDailyTime = `${minutes}m`;
131
- } else {
132
- const hours = Math.floor(avgHours);
133
- const minutes = Math.round((avgHours - hours) * 60);
134
- avgDailyTime = minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`;
334
+
335
+ if (activeDays > 0) {
336
+ const avgHours = totalHours / activeDays;
337
+ if (avgHours < 1) {
338
+ const minutes = Math.round(avgHours * 60);
339
+ avgDailyTime = `${minutes}m`;
340
+ } else {
341
+ const hours = Math.floor(avgHours);
342
+ const minutes = Math.round((avgHours - hours) * 60);
343
+ avgDailyTime = minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`;
344
+ }
135
345
  }
136
346
  }
137
347
  }
@@ -161,20 +371,8 @@ document.addEventListener('DOMContentLoaded', function () {
161
371
  });
162
372
 
163
373
  // Function to load stats data with optional year filter
164
- function loadStatsData(start_timestamp = null, end_timestamp = null) {
374
+ function loadStatsData() {
165
375
  let url = '/api/stats';
166
- const params = new URLSearchParams();
167
-
168
- if (start_timestamp && end_timestamp) {
169
- // Only filter by timestamps
170
- params.append('start', start_timestamp);
171
- params.append('end', end_timestamp);
172
- }
173
-
174
- const queryString = params.toString();
175
- if (queryString) {
176
- url += `?${queryString}`;
177
- }
178
376
 
179
377
  return fetch(url)
180
378
  .then(response => response.json())
@@ -201,7 +399,7 @@ document.addEventListener('DOMContentLoaded', function () {
201
399
  }
202
400
 
203
401
  // Load dashboard data
204
- loadDashboardData(data, end_timestamp);
402
+ loadDashboardData(data);
205
403
 
206
404
  // Load goal progress chart (always refresh)
207
405
  if (typeof loadGoalProgress === 'function') {
@@ -404,7 +602,7 @@ document.addEventListener('DOMContentLoaded', function () {
404
602
  // Calculate current progress
405
603
  const currentHours = allGamesStats.total_time_hours || 0;
406
604
  const currentCharacters = allGamesStats.total_characters || 0;
407
- const currentGames = allGamesStats.unique_games || 0;
605
+ const currentGames = allGamesStats.completed_games || 0;
408
606
 
409
607
  // Calculate 90-day averages for projections
410
608
  const dailyHoursAvg = calculate90DayAverage(allLinesData, 'hours');
@@ -486,95 +684,6 @@ document.addEventListener('DOMContentLoaded', function () {
486
684
  }
487
685
  }
488
686
 
489
- // ================================
490
- // Utility to convert date strings to Unix timestamps
491
- // Returns start of day for startDate and end of day for endDate
492
- // ================================
493
- function getUnixTimestamps(startDate, endDate) {
494
- const start = new Date(startDate + 'T00:00:00');
495
- const startTimestamp = Math.floor(start.getTime() / 1000); // convert ms to s
496
-
497
- const end = new Date(endDate + 'T23:59:59.999');
498
- const endTimestamp = Math.floor(end.getTime() / 1000); // convert ms to s
499
-
500
- return { startTimestamp, endTimestamp };
501
- }
502
-
503
- // ================================
504
- // Initialize date inputs with sessionStorage or fetch initial values
505
- // Dispatches "datesSet" event once dates are set
506
- // ================================
507
- function initializeDates() {
508
- const fromDateInput = document.getElementById('fromDate');
509
- const toDateInput = document.getElementById('toDate');
510
-
511
- const fromDate = sessionStorage.getItem("fromDate");
512
- const toDate = sessionStorage.getItem("toDate");
513
-
514
- if (!(fromDate && toDate)) {
515
- fetch('/api/stats')
516
- .then(response => response.json())
517
- .then(response_json => {
518
- // Get first date from API
519
- const firstDate = response_json.allGamesStats.first_date;
520
- fromDateInput.value = firstDate;
521
-
522
- // Get today's date
523
- const today = new Date();
524
- const toDate = today.toLocaleDateString('en-CA');
525
- toDateInput.value = toDate;
526
-
527
- // Save in sessionStorage
528
- sessionStorage.setItem("fromDate", firstDate);
529
- sessionStorage.setItem("toDate", toDate);
530
-
531
- document.dispatchEvent(new Event("datesSet"));
532
- });
533
- } else {
534
- // If values already in sessionStorage, set inputs from there
535
- fromDateInput.value = fromDate;
536
- toDateInput.value = toDate;
537
-
538
- document.dispatchEvent(new Event("datesSet"));
539
- }
540
- }
541
-
542
- const fromDateInput = document.getElementById('fromDate');
543
- const toDateInput = document.getElementById('toDate');
544
- const popup = document.getElementById('dateErrorPopup');
545
- const closePopupBtn = document.getElementById('closePopupBtn');
546
-
547
- document.addEventListener("datesSet", () => {
548
- const fromDate = sessionStorage.getItem("fromDate");
549
- const toDate = sessionStorage.getItem("toDate");
550
- const { startTimestamp, endTimestamp } = getUnixTimestamps(fromDate, toDate);
551
-
552
- loadStatsData(startTimestamp, endTimestamp);
553
- });
554
-
555
-
556
- function handleDateChange() {
557
- const fromDateStr = fromDateInput.value;
558
- const toDateStr = toDateInput.value;
559
-
560
- sessionStorage.setItem("fromDate", fromDateStr);
561
- sessionStorage.setItem("toDate", toDateStr);
562
-
563
- // Validate date order
564
- if (fromDateStr && toDateStr && new Date(fromDateStr) > new Date(toDateStr)) {
565
- popup.classList.remove("hidden");
566
- return;
567
- }
568
-
569
- const { startTimestamp, endTimestamp } = getUnixTimestamps(fromDateStr, toDateStr);
570
-
571
- loadStatsData(startTimestamp, endTimestamp);
572
- }
573
-
574
- // Attach listeners to both date inputs
575
- fromDateInput.addEventListener("change", handleDateChange);
576
- toDateInput.addEventListener("change", handleDateChange);
577
-
578
687
  // Session navigation button handlers
579
688
  const prevSessionBtn = document.querySelector('.prev-session-btn');
580
689
  const nextSessionBtn = document.querySelector('.next-session-btn');
@@ -582,12 +691,16 @@ document.addEventListener('DOMContentLoaded', function () {
582
691
 
583
692
  function updateSessionNavigationButtons() {
584
693
  if (!window.todaySessionDetails || window.todaySessionDetails.length === 0) {
694
+ // Keep buttons visible but disabled when no sessions
585
695
  prevSessionBtn.disabled = true;
586
696
  nextSessionBtn.disabled = true;
697
+ deleteSessionBtn.disabled = true;
587
698
  return;
588
699
  }
700
+ // Enable/disable based on navigation state
589
701
  prevSessionBtn.disabled = window.currentSessionIndex <= 0;
590
702
  nextSessionBtn.disabled = window.currentSessionIndex >= window.todaySessionDetails.length - 1;
703
+ deleteSessionBtn.disabled = false;
591
704
  }
592
705
 
593
706
  function showSessionAtIndex(index) {
@@ -646,29 +759,15 @@ document.addEventListener('DOMContentLoaded', function () {
646
759
  const sessionToDelete = window.todaySessionDetails[idx];
647
760
  if (!sessionToDelete) return;
648
761
 
649
- // Confirm deletion
650
- const confirm1 = confirm(`Are you sure you want to delete the session starting at ${new Date(sessionToDelete.startTime * 1000).toLocaleString()}? This will delete ${sessionToDelete.lines.length} lines. This action cannot be undone.`);
651
- if (!confirm1) return;
652
- const confirm2 = confirm("Are you REALLY sure? This cannot be undone.");
653
- if (!confirm2) return;
654
- const confirm3 = confirm("Final warning: Delete this session permanently?");
655
- if (!confirm3) return;
762
+ // Confirm deletion with clear warning
763
+ const confirmMsg = `All session data will be deleted.\n\nSession: ${new Date(sessionToDelete.startTime * 1000).toLocaleString()}\nLines: ${sessionToDelete.lines.length}\n\nThis action cannot be undone. Continue?`;
764
+ if (!confirm(confirmMsg)) return;
656
765
 
657
766
  // Call the delete function
658
767
  deleteSession(sessionToDelete);
659
768
  });
660
769
 
661
- // Update navigation buttons whenever sessions are loaded
662
- document.addEventListener('datesSet', () => {
663
- setTimeout(updateSessionNavigationButtons, 1200);
664
- });
665
-
666
- initializeDates();
667
-
668
- // Popup close button
669
- closePopupBtn.addEventListener("click", () => {
670
- popup.classList.add("hidden");
671
- });
770
+ loadStatsData();
672
771
 
673
772
  // Function to update goal progress using existing stats data
674
773
  async function updateGoalProgressWithData(statsData) {
@@ -706,10 +805,119 @@ document.addEventListener('DOMContentLoaded', function () {
706
805
  loadGoalProgress();
707
806
  }, 1000);
708
807
 
808
+ // Function to update progress timeline with start and estimated end dates
809
+ function updateProgressTimeline(stats) {
810
+ const startDateEl = document.getElementById('gameStartDate');
811
+ const endDateEl = document.getElementById('gameEstimatedEndDate');
812
+
813
+ // Set start date
814
+ if (stats.first_date) {
815
+ startDateEl.textContent = stats.first_date;
816
+ } else {
817
+ startDateEl.textContent = '-';
818
+ }
819
+
820
+ // Calculate and set estimated end date
821
+ if (!stats.game_character_count || stats.game_character_count <= 0 ||
822
+ !stats.total_characters || stats.total_characters <= 0 ||
823
+ !stats.reading_speed || stats.reading_speed <= 0) {
824
+ endDateEl.textContent = '-';
825
+ return;
826
+ }
827
+
828
+ const charsRead = stats.total_characters;
829
+ const totalChars = stats.game_character_count;
830
+ const charsRemaining = Math.max(0, totalChars - charsRead);
831
+
832
+ if (charsRemaining === 0) {
833
+ endDateEl.textContent = 'Completed! 🎉';
834
+ return;
835
+ }
836
+
837
+ // Calculate daily character progress
838
+ let dailyCharProgress = 0;
839
+ if (stats.daily_activity && Object.keys(stats.daily_activity).length > 0) {
840
+ const activityDays = Object.values(stats.daily_activity).filter(chars => chars > 0);
841
+ if (activityDays.length > 0) {
842
+ dailyCharProgress = activityDays.reduce((sum, chars) => sum + chars, 0) / activityDays.length;
843
+ }
844
+ }
845
+
846
+ if (dailyCharProgress === 0) {
847
+ dailyCharProgress = stats.reading_speed; // Fallback: assume 1 hour per day
848
+ }
849
+
850
+ const daysUntilCompletion = Math.ceil(charsRemaining / dailyCharProgress);
851
+ const today = new Date();
852
+ const completionDate = new Date(today);
853
+ completionDate.setDate(completionDate.getDate() + daysUntilCompletion);
854
+
855
+ // Format as YYYY-MM-DD (estimated)
856
+ const year = completionDate.getFullYear();
857
+ const month = String(completionDate.getMonth() + 1).padStart(2, '0');
858
+ const day = String(completionDate.getDate()).padStart(2, '0');
859
+ endDateEl.textContent = `${year}-${month}-${day} (estimated)`;
860
+ }
861
+
862
+ // Function to update estimated time left stat
863
+ function updateEstimatedTimeLeft(stats) {
864
+ const estimatedTimeLeftEl = document.getElementById('currentEstimatedTimeLeft');
865
+ const estimatedTimeLeftBox = estimatedTimeLeftEl.closest('.dashboard-stat-item');
866
+
867
+ if (!stats.game_character_count || stats.game_character_count <= 0 ||
868
+ !stats.total_characters || stats.total_characters <= 0 ||
869
+ !stats.reading_speed || stats.reading_speed <= 0) {
870
+ // Hide the entire stat box when we can't calculate estimated time
871
+ if (estimatedTimeLeftBox) {
872
+ estimatedTimeLeftBox.style.display = 'none';
873
+ }
874
+ return;
875
+ }
876
+
877
+ // Show the stat box if it was hidden
878
+ if (estimatedTimeLeftBox) {
879
+ estimatedTimeLeftBox.style.display = '';
880
+ }
881
+
882
+ const charsRead = stats.total_characters;
883
+ const totalChars = stats.game_character_count;
884
+ const charsRemaining = Math.max(0, totalChars - charsRead);
885
+
886
+ if (charsRemaining === 0) {
887
+ estimatedTimeLeftEl.textContent = '0h';
888
+ return;
889
+ }
890
+
891
+ const readingSpeed = stats.reading_speed;
892
+ const hoursRemaining = charsRemaining / readingSpeed;
893
+
894
+ // Format hours remaining
895
+ let hoursText;
896
+ if (hoursRemaining < 1) {
897
+ const minutes = Math.round(hoursRemaining * 60);
898
+ hoursText = `${minutes}m`;
899
+ } else if (hoursRemaining < 24) {
900
+ const hours = Math.floor(hoursRemaining);
901
+ const minutes = Math.round((hoursRemaining - hours) * 60);
902
+ hoursText = minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`;
903
+ } else if (hoursRemaining < 168) {
904
+ const days = Math.floor(hoursRemaining / 24);
905
+ const hours = Math.round(hoursRemaining % 24);
906
+ hoursText = hours > 0 ? `${days}d ${hours}h` : `${days}d`;
907
+ } else {
908
+ const days = Math.floor(hoursRemaining / 24);
909
+ hoursText = `${days}d`;
910
+ }
911
+
912
+ estimatedTimeLeftEl.textContent = hoursText;
913
+ }
914
+
709
915
  // Make functions globally available
710
916
  window.createHeatmap = createHeatmap;
711
917
  window.loadStatsData = loadStatsData;
712
918
  window.loadGoalProgress = loadGoalProgress;
919
+ window.updateProgressTimeline = updateProgressTimeline;
920
+ window.updateEstimatedTimeLeft = updateEstimatedTimeLeft;
713
921
 
714
922
  function updateCurrentSessionOverview(sessionDetails, index = sessionDetails.length - 1) {
715
923
  window.currentSessionIndex = index; // Store globally for potential future use
@@ -718,20 +926,21 @@ document.addEventListener('DOMContentLoaded', function () {
718
926
  const lastSession = sessionDetails && sessionDetails.length > 0 ? sessionDetails[index] : null;
719
927
 
720
928
  if (!lastSession) {
721
- // No current session
722
- document.getElementById('currentSessionStatus').textContent = 'No active session';
723
- document.getElementById('currentSessionTotalHours').textContent = '-';
724
- document.getElementById('currentSessionTotalChars').textContent = '-';
725
- document.getElementById('currentSessionStartTime').textContent = '-';
726
- document.getElementById('currentSessionEndTime').textContent = '-';
727
- document.getElementById('currentSessionCharsPerHour').textContent = '-';
929
+ // No current session - clear session stats
930
+ const sessionHoursEl = document.getElementById('currentSessionTotalHours');
931
+ const sessionCharsEl = document.getElementById('currentSessionTotalChars');
932
+ const sessionStartEl = document.getElementById('currentSessionStartTime');
933
+ const sessionEndEl = document.getElementById('currentSessionEndTime');
934
+ const sessionSpeedEl = document.getElementById('currentSessionCharsPerHour');
935
+
936
+ if (sessionHoursEl) sessionHoursEl.textContent = '-';
937
+ if (sessionCharsEl) sessionCharsEl.textContent = '-';
938
+ if (sessionStartEl) sessionStartEl.textContent = '-';
939
+ if (sessionEndEl) sessionEndEl.textContent = '-';
940
+ if (sessionSpeedEl) sessionSpeedEl.textContent = '-';
728
941
  return;
729
942
  }
730
943
 
731
- // Update session status (show game name if available)
732
- const statusText = lastSession.gameName ? `Playing: ${lastSession.gameName}` : 'Active session';
733
- document.getElementById('currentSessionStatus').textContent = statusText;
734
-
735
944
  // Format session duration
736
945
  let hoursDisplay = '-';
737
946
  const sessionHours = lastSession.totalSeconds / 3600;
@@ -756,170 +965,245 @@ document.addEventListener('DOMContentLoaded', function () {
756
965
 
757
966
  // Update the DOM elements
758
967
  document.getElementById('currentSessionTotalHours').textContent = hoursDisplay;
759
- document.getElementById('currentSessionTotalChars').textContent = lastSession.totalChars.toLocaleString();
968
+
969
+ // Update Session Chars with native tooltip
970
+ const sessionCharsEl = document.getElementById('currentSessionTotalChars');
971
+ const sessionCharsBox = sessionCharsEl.closest('.dashboard-stat-item');
972
+ sessionCharsEl.textContent = Math.round(lastSession.totalChars).toLocaleString();
973
+ if (sessionCharsBox) {
974
+ sessionCharsBox.setAttribute('title', `${lastSession.totalChars.toLocaleString(undefined, {maximumFractionDigits: 2})} characters`);
975
+ }
976
+
760
977
  document.getElementById('currentSessionStartTime').textContent = startTimeDisplay;
761
978
  document.getElementById('currentSessionEndTime').textContent = endTimeDisplay;
762
- document.getElementById('currentSessionCharsPerHour').textContent = lastSession.readSpeed !== '-' ? lastSession.readSpeed.toLocaleString() : '-';
763
- }
764
-
765
- // Dashboard functionality
766
- function loadDashboardData(data = null, end_timestamp = null) {
767
- function updateTodayOverview(allLinesData) {
768
- // Get today's date string (YYYY-MM-DD), timezone aware (local time)
769
- const today = new Date();
770
- const pad = n => n.toString().padStart(2, '0');
771
- const todayStr = `${today.getFullYear()}-${pad(today.getMonth() + 1)}-${pad(today.getDate())}`;
772
- const afkTimerSeconds = window.statsConfig ? window.statsConfig.afkTimerSeconds : 120;
773
- document.getElementById('todayDate').textContent = todayStr;
774
-
775
- // Filter lines for today
776
- const todayLines = (allLinesData || []).filter(line => {
777
- if (!line.timestamp) return false;
778
- const ts = parseFloat(line.timestamp);
779
- if (isNaN(ts)) return false;
780
- const dateObj = new Date(ts * 1000);
781
- const lineDate = `${dateObj.getFullYear()}-${pad(dateObj.getMonth() + 1)}-${pad(dateObj.getDate())}`;
782
- return lineDate === todayStr;
783
- });
784
-
785
- // Calculate total characters read today (only valid numbers)
786
- const totalChars = todayLines.reduce((sum, line) => {
787
- const chars = Number(line.characters);
788
- return sum + (isNaN(chars) ? 0 : chars);
789
- }, 0);
790
-
791
- // Calculate sessions (count gaps > session threshold as new sessions)
792
- let sessions = 0;
793
- let sessionGap = window.statsConfig ? window.statsConfig.sessionGapSeconds : 3600;
794
- let minimumSessionLength = 300; // 5 minutes minimum session length
795
- let sessionDetails = [];
796
- if (todayLines.length > 0) {
797
- // Sort lines by timestamp
798
- const sortedLines = todayLines.slice().sort((a, b) => parseFloat(a.timestamp) - parseFloat(b.timestamp));
799
- let currentSession = null;
800
- let lastTimestamp = null;
801
- let lastGameName = null;
802
-
803
- for (let i = 0; i < sortedLines.length; i++) {
804
- const line = sortedLines[i];
805
- const ts = parseFloat(line.timestamp);
806
- const gameName = line.game_name || '';
807
- const chars = Number(line.characters) || 0;
808
-
809
- // Determine if new session: gap or new game
810
- const isNewSession =
811
- (lastTimestamp !== null && ts - lastTimestamp > sessionGap) ||
812
- (lastGameName !== null && gameName !== lastGameName);
979
+
980
+ // Update Session Chars/Hour with native tooltip
981
+ const sessionSpeedEl = document.getElementById('currentSessionCharsPerHour');
982
+ const sessionSpeedBox = sessionSpeedEl.closest('.dashboard-stat-item');
983
+ sessionSpeedEl.textContent = lastSession.charsPerHour > 0 ? Math.round(lastSession.charsPerHour).toLocaleString() : '-';
984
+ if (sessionSpeedBox && lastSession.charsPerHour > 0) {
985
+ sessionSpeedBox.setAttribute('title', `${lastSession.charsPerHour.toLocaleString(undefined, {maximumFractionDigits: 2})} chars/hour`);
986
+ }
813
987
 
814
- if (!currentSession || isNewSession) {
815
- // Finish previous session
816
- if (currentSession) {
817
- // Calculate read speed for session
818
- if (currentSession.totalSeconds > 0) {
819
- currentSession.readSpeed = Math.round(currentSession.totalChars / (currentSession.totalSeconds / 3600));
820
- } else {
821
- currentSession.readSpeed = '-';
822
- }
823
- // Only add session if it meets minimum length requirement
824
- if (currentSession.totalSeconds >= minimumSessionLength) {
825
- sessionDetails.push(currentSession);
826
- }
827
- }
828
- // Start new session
829
- currentSession = {
830
- startTime: ts,
831
- endTime: ts,
832
- gameName: gameName,
833
- totalChars: chars,
834
- totalSeconds: 0,
835
- lines: [line]
836
- };
837
- } else {
838
- // Continue current session
839
- currentSession.endTime = ts + afkTimerSeconds;
840
- currentSession.totalChars += chars;
841
- currentSession.lines.push(line);
842
- if (lastTimestamp !== null) {
843
- currentSession.totalSeconds += Math.min(ts - lastTimestamp, afkTimerSeconds);
844
- }
845
- }
988
+ // Render game metadata if available
989
+ renderSessionGameMetadata(lastSession);
990
+ }
846
991
 
847
- lastTimestamp = ts;
848
- lastGameName = gameName;
992
+ function renderSessionGameMetadata(session) {
993
+ const gameContentGrid = document.getElementById('gameContentGrid');
994
+ const noGameDataMessage = document.getElementById('noGameDataMessage');
995
+ const noGameDataTitle = document.getElementById('noGameDataTitle');
996
+ const gameMetadata = session.gameMetadata;
997
+
998
+ // Check if we have meaningful game data (image or description)
999
+ const hasImage = gameMetadata && gameMetadata.image && gameMetadata.image.trim();
1000
+ const hasDescription = gameMetadata && gameMetadata.description && gameMetadata.description.trim();
1001
+ const hasManualOverrides = !!(gameMetadata && gameMetadata.manual_overrides && gameMetadata.manual_overrides.length > 0);
1002
+
1003
+ // Show message if: no metadata OR (no image AND no description AND no manual overrides)
1004
+ if (!gameMetadata || (!hasImage && !hasDescription && !hasManualOverrides)) {
1005
+ if (gameContentGrid) {
1006
+ gameContentGrid.style.display = 'none';
1007
+ }
1008
+ if (noGameDataMessage) {
1009
+ // Set the game title in the message
1010
+ if (noGameDataTitle && session.gameName) {
1011
+ noGameDataTitle.textContent = session.gameName;
849
1012
  }
1013
+ noGameDataMessage.style.display = 'block';
1014
+ }
1015
+ return;
1016
+ }
850
1017
 
851
- // Push last session
852
- if (currentSession) {
853
- if (currentSession.totalSeconds > 0) {
854
- currentSession.readSpeed = Math.round(currentSession.totalChars / (currentSession.totalSeconds / 3600));
855
- } else {
856
- currentSession.readSpeed = '-';
857
- }
858
- sessionDetails.push(currentSession);
859
- }
1018
+ // Hide the message and show the game content grid
1019
+ if (noGameDataMessage) {
1020
+ noGameDataMessage.style.display = 'none';
1021
+ }
1022
+ if (gameContentGrid) {
1023
+ gameContentGrid.style.display = 'flex';
1024
+ }
860
1025
 
861
- sessions = sessionDetails.length;
1026
+ // Clear existing content
1027
+ const gamePhotoSection = document.getElementById('gamePhotoSection');
1028
+ const gamePhoto = document.getElementById('gamePhoto');
1029
+ const gameTitleOriginal = document.getElementById('gameTitleOriginal');
1030
+ const gameTitleRomaji = document.getElementById('gameTitleRomaji');
1031
+ const gameTitleEnglish = document.getElementById('gameTitleEnglish');
1032
+ const gameTypeBadge = document.getElementById('gameTypeBadge');
1033
+ const gameDescription = document.getElementById('gameDescription');
1034
+ const descriptionExpandBtn = document.getElementById('descriptionExpandBtn');
1035
+ const gameLinksContainer = document.getElementById('gameLinksContainer');
1036
+ const gameLinksPills = document.getElementById('gameLinksPills');
1037
+
1038
+ // Update photo - all images are now stored as PNG base64
1039
+ if (gameMetadata.image && gameMetadata.image.trim()) {
1040
+ let imageSrc = gameMetadata.image.trim();
1041
+
1042
+ // Handle different image formats
1043
+ if (imageSrc.startsWith('data:image')) {
1044
+ // Already has data URI prefix
1045
+ gamePhoto.src = imageSrc;
1046
+ } else if (imageSrc.startsWith('http')) {
1047
+ // External URL
1048
+ gamePhoto.src = imageSrc;
862
1049
  } else {
863
- sessions = 0;
864
- sessionDetails = [];
1050
+ // Raw base64 data - add PNG data URI prefix (all uploads are converted to PNG)
1051
+ gamePhoto.src = `data:image/png;base64,${imageSrc}`;
865
1052
  }
1053
+
1054
+ gamePhotoSection.style.display = 'block';
1055
+ gamePhoto.style.display = 'block';
1056
+ } else {
1057
+ gamePhotoSection.style.display = 'none';
1058
+ }
866
1059
 
867
- // Optionally, you can expose sessionDetails for debugging or further UI use:
868
- // console.log(sessionDetails);
869
- window.todaySessionDetails = sessionDetails;
870
-
871
- // Calculate total reading time (reuse AFK logic from calculateHeatmapStreaks)
872
- let totalSeconds = 0;
873
- const timestamps = todayLines
874
- .map(l => parseFloat(l.timestamp))
875
- .filter(ts => !isNaN(ts))
876
- .sort((a, b) => a - b);
877
- // Get AFK timer from settings modal if available
878
- if (timestamps.length >= 2) {
879
- for (let i = 1; i < timestamps.length; i++) {
880
- const gap = timestamps[i] - timestamps[i-1];
881
- totalSeconds += Math.min(gap, afkTimerSeconds);
882
- }
883
- } else if (timestamps.length === 1) {
884
- totalSeconds = 1;
885
- }
886
- let totalHours = totalSeconds / 3600;
1060
+ // Update titles
1061
+ if (gameMetadata.title_original) {
1062
+ gameTitleOriginal.textContent = gameMetadata.title_original;
1063
+ gameTitleOriginal.style.display = 'block';
1064
+ } else {
1065
+ gameTitleOriginal.style.display = 'none';
1066
+ }
1067
+
1068
+ if (gameMetadata.title_romaji) {
1069
+ gameTitleRomaji.textContent = gameMetadata.title_romaji;
1070
+ gameTitleRomaji.style.display = 'block';
1071
+ } else {
1072
+ gameTitleRomaji.style.display = 'none';
1073
+ }
1074
+
1075
+ if (gameMetadata.title_english) {
1076
+ gameTitleEnglish.textContent = gameMetadata.title_english;
1077
+ gameTitleEnglish.style.display = 'block';
1078
+ } else {
1079
+ gameTitleEnglish.style.display = 'none';
1080
+ }
887
1081
 
888
- // Calculate chars/hour
889
- let charsPerHour = '-';
890
- if (totalChars > 0) {
891
- // Avoid division by zero, set minimum time to 1 minute if activity exists
892
- if (totalHours <= 0) totalHours = 1/60;
893
- charsPerHour = Math.round(totalChars / totalHours).toLocaleString();
894
- }
1082
+ // Update type badge
1083
+ if (gameMetadata.type) {
1084
+ gameTypeBadge.textContent = gameMetadata.type;
1085
+ gameTypeBadge.style.display = 'inline-block';
1086
+ } else {
1087
+ gameTypeBadge.style.display = 'none';
1088
+ }
895
1089
 
896
- // Format hours for display
897
- let hoursDisplay = '-';
898
- if (totalHours > 0) {
899
- const h = Math.floor(totalHours);
900
- const m = Math.round((totalHours - h) * 60);
901
- hoursDisplay = h > 0 ? `${h}h${m > 0 ? ' ' + m + 'm' : ''}` : `${m}m`;
1090
+ // Update description
1091
+ if (gameMetadata.description) {
1092
+ gameDescription.textContent = gameMetadata.description;
1093
+ gameDescription.classList.remove('expanded');
1094
+
1095
+ // Show/hide expand button based on description length
1096
+ if (gameMetadata.description.length > 150) {
1097
+ descriptionExpandBtn.style.display = 'block';
1098
+ const expandText = descriptionExpandBtn.querySelector('.expand-text');
1099
+ const collapseText = descriptionExpandBtn.querySelector('.collapse-text');
1100
+ if (expandText) expandText.style.display = 'inline';
1101
+ if (collapseText) collapseText.style.display = 'none';
1102
+ } else {
1103
+ descriptionExpandBtn.style.display = 'none';
902
1104
  }
1105
+ } else {
1106
+ gameDescription.textContent = '';
1107
+ descriptionExpandBtn.style.display = 'none';
1108
+ }
903
1109
 
904
- document.getElementById('todayTotalHours').textContent = hoursDisplay;
905
- document.getElementById('todayTotalChars').textContent = totalChars.toLocaleString();
906
- document.getElementById('todaySessions').textContent = sessions;
907
- document.getElementById('todayCharsPerHour').textContent = charsPerHour;
908
-
909
- // Update current session overview with the last session
910
- showSessionAtIndex(sessionDetails.length - 1);
1110
+ // Update links
1111
+ if (gameMetadata.links && gameMetadata.links.length > 0) {
1112
+ gameLinksPills.innerHTML = '';
1113
+
1114
+ gameMetadata.links.forEach(link => {
1115
+ if (link.url) {
1116
+ const pill = document.createElement('a');
1117
+ pill.href = link.url;
1118
+ pill.target = '_blank';
1119
+ pill.rel = 'noopener noreferrer';
1120
+ pill.className = 'game-link-pill';
1121
+ pill.textContent = extractDomainName(link.url);
1122
+ gameLinksPills.appendChild(pill);
1123
+ }
1124
+ });
1125
+
1126
+ gameLinksContainer.style.display = 'flex';
1127
+ } else {
1128
+ gameLinksContainer.style.display = 'none';
911
1129
  }
1130
+ }
912
1131
 
913
- function updateOverviewForEndDay(allLinesData, endTimestamp) {
914
- if (!endTimestamp) return;
1132
+ // Function to load today's stats from new API endpoint
1133
+ function loadTodayStats() {
1134
+ fetch('/api/today-stats')
1135
+ .then(response => response.json())
1136
+ .then(data => {
1137
+ // Update today's total hours
1138
+ const totalHours = data.todayTotalHours || 0;
1139
+ let hoursDisplay = '-';
1140
+ if (totalHours > 0) {
1141
+ const h = Math.floor(totalHours);
1142
+ const m = Math.round((totalHours - h) * 60);
1143
+ hoursDisplay = h > 0 ? `${h}h${m > 0 ? ' ' + m + 'm' : ''}` : `${m}m`;
1144
+ }
1145
+ document.getElementById('todayTotalHours').textContent = hoursDisplay;
1146
+
1147
+ // Update today's total characters with native tooltip
1148
+ const todayCharsEl = document.getElementById('todayTotalChars');
1149
+ const todayCharsBox = todayCharsEl.closest('.dashboard-stat-item');
1150
+ todayCharsEl.textContent = data.todayTotalChars.toLocaleString();
1151
+ if (todayCharsBox) {
1152
+ todayCharsBox.setAttribute('title', `${data.todayTotalChars.toLocaleString(undefined, {maximumFractionDigits: 2})} characters`);
1153
+ }
1154
+
1155
+ // Update today's sessions count
1156
+ document.getElementById('todaySessions').textContent = data.todaySessions || 0;
1157
+
1158
+ // Update today's chars/hour with native tooltip
1159
+ const todaySpeedEl = document.getElementById('todayCharsPerHour');
1160
+ const todaySpeedBox = todaySpeedEl.closest('.dashboard-stat-item');
1161
+ todaySpeedEl.textContent = data.todayCharsPerHour > 0 ? data.todayCharsPerHour.toLocaleString() : '-';
1162
+ if (todaySpeedBox && data.todayCharsPerHour > 0) {
1163
+ todaySpeedBox.setAttribute('title', `${data.todayCharsPerHour.toLocaleString(undefined, {maximumFractionDigits: 2})} chars/hour`);
1164
+ }
1165
+
1166
+ // Store sessions globally for navigation
1167
+ window.todaySessionDetails = data.sessions || [];
1168
+
1169
+ // Show the latest session (most recent)
1170
+ if (window.todaySessionDetails.length > 0) {
1171
+ showSessionAtIndex(window.todaySessionDetails.length - 1);
1172
+ } else {
1173
+ // No sessions - clear session displays
1174
+ document.getElementById('currentSessionTotalChars').textContent = '0';
1175
+ document.getElementById('currentSessionCharsPerHour').textContent = '-';
1176
+ }
1177
+
1178
+ // Update session navigation buttons
1179
+ updateSessionNavigationButtons();
1180
+ })
1181
+ .catch(error => {
1182
+ console.error('Error fetching today\'s stats:', error);
1183
+ // Set default values on error
1184
+ document.getElementById('todayTotalHours').textContent = '-';
1185
+ document.getElementById('todayTotalChars').textContent = '0';
1186
+ document.getElementById('todaySessions').textContent = '0';
1187
+ document.getElementById('todayCharsPerHour').textContent = '-';
1188
+ document.getElementById('currentSessionTotalChars').textContent = '0';
1189
+ document.getElementById('currentSessionCharsPerHour').textContent = '-';
1190
+ });
1191
+ }
915
1192
 
1193
+ // Dashboard functionality
1194
+ function loadDashboardData(data = null) {
1195
+ function updateOverviewForEndDay(allLinesData) {
916
1196
  const pad = n => n.toString().padStart(2, '0');
917
1197
 
918
1198
  // Determine target date string (YYYY-MM-DD) from the end timestamp
919
- const endDateObj = new Date(endTimestamp * 1000);
1199
+ const endDateObj = new Date();
920
1200
  const targetDateStr = `${endDateObj.getFullYear()}-${pad(endDateObj.getMonth() + 1)}-${pad(endDateObj.getDate())}`;
921
1201
  const afkTimerSeconds = window.statsConfig ? window.statsConfig.afkTimerSeconds : 120;
922
1202
  document.getElementById('todayDate').textContent = targetDateStr;
1203
+
1204
+ // Load today's stats from new API
1205
+ loadTodayStats();
1206
+ return; // Skip old calculation logic below
923
1207
 
924
1208
  // Filter lines that fall on the target date
925
1209
  const targetLines = (allLinesData || []).filter(line => {
@@ -1068,8 +1352,8 @@ document.addEventListener('DOMContentLoaded', function () {
1068
1352
  updateAllGamesDashboard(data.allGamesStats);
1069
1353
 
1070
1354
  if (data.allLinesData) {
1071
- end_timestamp == null ? updateTodayOverview(data.allLinesData) : updateOverviewForEndDay(data.allLinesData, end_timestamp)
1072
- }
1355
+ updateOverviewForEndDay(data.allLinesData);
1356
+ }
1073
1357
 
1074
1358
  hideDashboardLoading();
1075
1359
  } else {
@@ -1081,9 +1365,12 @@ document.addEventListener('DOMContentLoaded', function () {
1081
1365
  if (data.currentGameStats && data.allGamesStats) {
1082
1366
  updateCurrentGameDashboard(data.currentGameStats);
1083
1367
  updateAllGamesDashboard(data.allGamesStats);
1084
- if (data.allLinesData) {
1085
- end_timestamp == null ? updateTodayOverview(data.allLinesData) : updateOverviewForEndDay(data.allLinesData, end_timestamp)
1086
- }
1368
+
1369
+ // Always fetch today's data live (don't use rollup data for today)
1370
+
1371
+ if (data.allLinesData) {
1372
+ updateOverviewForEndDay(data.allLinesData);
1373
+ }
1087
1374
  } else {
1088
1375
  showDashboardError();
1089
1376
  }
@@ -1097,25 +1384,317 @@ document.addEventListener('DOMContentLoaded', function () {
1097
1384
  }
1098
1385
  }
1099
1386
 
1387
+ // Helper function to extract and format domain names from URLs
1388
+ function extractDomainName(url) {
1389
+ if (!url) return 'Link';
1390
+
1391
+ try {
1392
+ // Parse the URL
1393
+ const urlObj = new URL(url);
1394
+ let domain = urlObj.hostname;
1395
+
1396
+ // Remove 'www.' prefix if present
1397
+ domain = domain.replace(/^www\./, '');
1398
+
1399
+ // Map common domains to friendly names
1400
+ const domainMap = {
1401
+ 'vndb.org': 'VNDB',
1402
+ 'myanimelist.net': 'MAL',
1403
+ 'anilist.co': 'AniList',
1404
+ 'anime-planet.com': 'Anime-Planet',
1405
+ 'kitsu.io': 'Kitsu',
1406
+ 'anidb.net': 'AniDB',
1407
+ 'mangaupdates.com': 'MangaUpdates',
1408
+ 'novelupdates.com': 'NovelUpdates',
1409
+ 'wikipedia.org': 'Wikipedia',
1410
+ 'fandom.com': 'Fandom',
1411
+ 'steam.com': 'Steam',
1412
+ 'steampowered.com': 'Steam',
1413
+ 'store.steampowered.com': 'Steam',
1414
+ "Itch.io": "Itch.io",
1415
+ 'gog.com': 'GOG',
1416
+ 'epicgames.com': 'Epic Games',
1417
+ 'nintendo.com': 'Nintendo',
1418
+ 'playstation.com': 'PlayStation',
1419
+ 'xbox.com': 'Xbox',
1420
+ 'crunchyroll.com': 'Crunchyroll',
1421
+ 'hidive.com': 'HIDIVE',
1422
+ 'funimation.com': 'Funimation',
1423
+ 'animenewsnetwork.com': 'ANN',
1424
+ 'tvdb.com': 'TheTVDB',
1425
+ 'themoviedb.org': 'TMDB',
1426
+ 'imdb.com': 'IMDb',
1427
+ 'letterboxd.com': 'Letterboxd',
1428
+ 'goodreads.com': 'Goodreads',
1429
+ 'bookwalker.jp': 'BookWalker',
1430
+ 'dlsite.com': 'DLsite',
1431
+ 'jlist.com': 'J-List',
1432
+ 'getchu.com': 'Getchu',
1433
+ 'erogamescape.dyndns.org': 'ErogameScape',
1434
+ 'itch.io': 'Itch.io',
1435
+ 'gamejolt.com': 'Game Jolt',
1436
+ 'mobygames.com': 'MobyGames',
1437
+ 'giantbomb.com': 'GiantBomb',
1438
+ 'howlongtobeat.com': 'HowLongToBeat',
1439
+ 'backloggd.com': 'Backloggd',
1440
+ 'vndb.org': 'VNDB',
1441
+ 'mangadex.org': 'MangaDex',
1442
+ 'animeuknews.net': 'Anime UK News',
1443
+ 'mydramalist.com': 'MyDramaList',
1444
+ 'metacritic.com': 'Metacritic',
1445
+ 'opencritic.com': 'OpenCritic',
1446
+ 'itch.io': 'Itch.io',
1447
+ 'indiedb.com': 'IndieDB',
1448
+ 'moddb.com': 'ModDB',
1449
+ 'romhacking.net': 'Romhacking',
1450
+ 'nexusmods.com': 'Nexus Mods',
1451
+ 'archiveofourown.org': 'AO3',
1452
+ 'fanfiction.net': 'FanFiction.net',
1453
+ 'tumblr.com': 'Tumblr',
1454
+ 'pixiv.net': 'Pixiv',
1455
+ 'deviantart.com': 'DeviantArt',
1456
+ 'booth.pm': 'BOOTH',
1457
+ 'patreon.com': 'Patreon',
1458
+ 'kickstarter.com': 'Kickstarter'
1459
+ };
1460
+
1461
+ // Check if we have a friendly name for this domain
1462
+ if (domainMap[domain]) {
1463
+ return domainMap[domain];
1464
+ }
1465
+
1466
+ // Otherwise, capitalize the main domain name
1467
+ const parts = domain.split('.');
1468
+ if (parts.length >= 2) {
1469
+ // Get the second-to-last part (e.g., 'example' from 'example.com')
1470
+ const mainPart = parts[parts.length - 2];
1471
+ return mainPart.charAt(0).toUpperCase() + mainPart.slice(1);
1472
+ }
1473
+
1474
+ return domain;
1475
+ } catch (e) {
1476
+ // If URL parsing fails, return a generic label
1477
+ return 'Link';
1478
+ }
1479
+ }
1480
+
1100
1481
  function updateCurrentGameDashboard(stats) {
1101
1482
  if (!stats) {
1102
1483
  showNoDashboardData('currentGameCard', 'No current game data available');
1103
1484
  return;
1104
1485
  }
1105
1486
 
1106
- // Update game name and subtitle
1107
- document.getElementById('currentGameName').textContent = stats.game_name;
1487
+ // Update subtitle with game name only if title_original is not set
1488
+ // (If title_original exists, it will be shown in the game content grid instead)
1489
+ const currentGameNameEl = document.getElementById('currentGameName');
1490
+ if (stats.title_original && stats.title_original.trim()) {
1491
+ // Hide subtitle when we have a proper title in the game content grid
1492
+ currentGameNameEl.style.display = 'none';
1493
+ } else {
1494
+ // Show game name in subtitle when no title_original is available
1495
+ const gameName = stats.game_name || 'Unknown Game';
1496
+ currentGameNameEl.textContent = gameName;
1497
+ currentGameNameEl.style.display = 'block';
1498
+ }
1499
+
1500
+ // Handle completion button visibility and state
1501
+ const completionBtn = document.getElementById('gameCompletionBtn');
1502
+ const currentGameCard = document.getElementById('currentGameCard');
1503
+
1504
+ if (completionBtn) {
1505
+ const completion = stats.progress_percentage || 0;
1506
+ const isCompleted = stats.completed || false;
1507
+ const hasCharacterCount = stats.game_character_count && stats.game_character_count > 0;
1508
+
1509
+ if (isCompleted) {
1510
+ // Game is already completed - show completed state
1511
+ completionBtn.textContent = 'Completed ✓';
1512
+ completionBtn.disabled = true;
1513
+ completionBtn.classList.add('completed');
1514
+ completionBtn.style.display = 'inline-block';
1515
+ currentGameCard.classList.add('completed');
1516
+ } else if (!hasCharacterCount || completion >= 90) {
1517
+ // Show button if: no character count set OR game is ≥90% complete
1518
+ completionBtn.textContent = 'Mark as completed?';
1519
+ completionBtn.disabled = false;
1520
+ completionBtn.classList.remove('completed');
1521
+ completionBtn.style.display = 'inline-block';
1522
+ currentGameCard.classList.remove('completed');
1523
+ } else {
1524
+ // Game has character count and is <90% complete - hide button
1525
+ completionBtn.style.display = 'none';
1526
+ currentGameCard.classList.remove('completed');
1527
+ }
1528
+ }
1108
1529
 
1109
- // Update main statistics
1110
- document.getElementById('currentTotalChars').textContent = stats.total_characters_formatted;
1530
+ // Check if we have meaningful game data
1531
+ const gameContentGrid = document.getElementById('gameContentGrid');
1532
+ const noGameDataMessage = document.getElementById('noGameDataMessage');
1533
+ const gamePhotoSection = document.getElementById('gamePhotoSection');
1534
+ const gamePhoto = document.getElementById('gamePhoto');
1535
+
1536
+ // Check if we have meaningful game data (image or description)
1537
+ const hasImage = stats.image && stats.image.trim();
1538
+ const hasDescription = stats.description && stats.description.trim();
1539
+ const hasManualOverrides = !!(stats.manual_overrides && stats.manual_overrides.length > 0);
1540
+
1541
+ // Show message if: no image AND no description AND no manual overrides
1542
+ // (If user has manually edited ANY field, don't show the message)
1543
+ if (!hasImage && !hasDescription && !hasManualOverrides) {
1544
+ if (gameContentGrid) {
1545
+ gameContentGrid.style.display = 'none';
1546
+ }
1547
+ if (noGameDataMessage) {
1548
+ // Set the game title in the message
1549
+ const noGameDataTitle = document.getElementById('noGameDataTitle');
1550
+ if (noGameDataTitle) {
1551
+ const gameTitle = stats.title_original || stats.game_name || 'Game';
1552
+ noGameDataTitle.textContent = gameTitle;
1553
+ }
1554
+ noGameDataMessage.style.display = 'block';
1555
+ }
1556
+ } else {
1557
+ // Hide the message and display the content grid
1558
+ if (noGameDataMessage) {
1559
+ noGameDataMessage.style.display = 'none';
1560
+ }
1561
+ gameContentGrid.style.display = 'flex';
1562
+ }
1563
+
1564
+ // Update game photo - all images are now stored as PNG base64
1565
+ if (stats.image && stats.image.trim()) {
1566
+ let imageSrc = stats.image.trim();
1567
+
1568
+ // Handle different image formats
1569
+ if (imageSrc.startsWith('data:image')) {
1570
+ // Already has data URI prefix
1571
+ gamePhoto.src = imageSrc;
1572
+ } else if (imageSrc.startsWith('http')) {
1573
+ // External URL
1574
+ gamePhoto.src = imageSrc;
1575
+ } else {
1576
+ // Raw base64 data - add PNG data URI prefix (all uploads are converted to PNG)
1577
+ gamePhoto.src = `data:image/png;base64,${imageSrc}`;
1578
+ }
1579
+
1580
+ gamePhotoSection.style.display = 'block';
1581
+ gamePhoto.style.display = 'block';
1582
+ } else {
1583
+ gamePhotoSection.style.display = 'none';
1584
+ }
1585
+
1586
+ // Update game titles
1587
+ const titleOriginal = document.getElementById('gameTitleOriginal');
1588
+ const titleRomaji = document.getElementById('gameTitleRomaji');
1589
+ const titleEnglish = document.getElementById('gameTitleEnglish');
1590
+
1591
+ if (stats.title_original) {
1592
+ titleOriginal.textContent = stats.title_original;
1593
+ titleOriginal.style.display = 'block';
1594
+ } else {
1595
+ titleOriginal.style.display = 'none';
1596
+ }
1597
+
1598
+ if (stats.title_romaji) {
1599
+ titleRomaji.textContent = stats.title_romaji;
1600
+ titleRomaji.style.display = 'block';
1601
+ } else {
1602
+ titleRomaji.style.display = 'none';
1603
+ }
1604
+
1605
+ if (stats.title_english) {
1606
+ titleEnglish.textContent = stats.title_english;
1607
+ titleEnglish.style.display = 'block';
1608
+ } else {
1609
+ titleEnglish.style.display = 'none';
1610
+ }
1611
+
1612
+ // Update game type badge
1613
+ const typeBadge = document.getElementById('gameTypeBadge');
1614
+ if (stats.type) {
1615
+ typeBadge.textContent = stats.type;
1616
+ typeBadge.style.display = 'inline-block';
1617
+ } else {
1618
+ typeBadge.style.display = 'none';
1619
+ }
1620
+
1621
+ // Update game description
1622
+ const description = document.getElementById('gameDescription');
1623
+ const expandBtn = document.getElementById('descriptionExpandBtn');
1624
+ if (stats.description) {
1625
+ description.textContent = stats.description;
1626
+ // Show expand button if description is long (more than ~150 characters)
1627
+ if (stats.description.length > 150) {
1628
+ expandBtn.style.display = 'block';
1629
+ } else {
1630
+ expandBtn.style.display = 'none';
1631
+ }
1632
+ } else {
1633
+ description.textContent = '';
1634
+ expandBtn.style.display = 'none';
1635
+ }
1636
+
1637
+ // Update game links
1638
+ const linksContainer = document.getElementById('gameLinksContainer');
1639
+ const linksPills = document.getElementById('gameLinksPills');
1640
+ if (stats.links && stats.links.length > 0) {
1641
+ // Clear existing pills
1642
+ linksPills.innerHTML = '';
1643
+
1644
+ // Create a pill for each link
1645
+ stats.links.forEach(link => {
1646
+ if (link.url) {
1647
+ const pill = document.createElement('a');
1648
+ pill.href = link.url;
1649
+ pill.target = '_blank';
1650
+ pill.rel = 'noopener noreferrer';
1651
+ pill.className = 'game-link-pill';
1652
+ pill.textContent = extractDomainName(link.url);
1653
+ linksPills.appendChild(pill);
1654
+ }
1655
+ });
1656
+
1657
+ // Show the links container
1658
+ linksContainer.style.display = 'flex';
1659
+ } else {
1660
+ // Hide the links container if no links
1661
+ linksContainer.style.display = 'none';
1662
+ }
1663
+
1664
+ // Update progress bar and timeline
1665
+ const progressContainer = document.getElementById('gameProgressContainer');
1666
+ if (stats.game_character_count > 0) {
1667
+ const percentage = stats.progress_percentage || 0;
1668
+ document.getElementById('gameProgressPercentage').textContent = Math.floor(percentage) + '%';
1669
+ document.getElementById('gameProgressFill').style.width = percentage + '%';
1670
+
1671
+ // Update timeline dates
1672
+ updateProgressTimeline(stats);
1673
+
1674
+ progressContainer.style.display = 'block';
1675
+ } else {
1676
+ progressContainer.style.display = 'none';
1677
+ }
1678
+
1679
+ // Update estimated time left stat
1680
+ updateEstimatedTimeLeft(stats);
1681
+
1682
+ // Update main statistics with native tooltips
1683
+ const currentTotalCharsEl = document.getElementById('currentTotalChars');
1684
+ const currentTotalCharsBox = currentTotalCharsEl.closest('.dashboard-stat-item');
1685
+ currentTotalCharsEl.textContent = stats.total_characters_formatted;
1686
+ if (currentTotalCharsBox && stats.total_characters) {
1687
+ currentTotalCharsBox.setAttribute('title', `${stats.total_characters.toLocaleString(undefined, {maximumFractionDigits: 2})} characters`);
1688
+ }
1689
+
1111
1690
  document.getElementById('currentTotalTime').textContent = stats.total_time_formatted;
1112
- document.getElementById('currentReadingSpeed').textContent = stats.reading_speed_formatted;
1113
- document.getElementById('currentSessions').textContent = stats.sessions.toLocaleString();
1114
-
1115
- // Update progress section
1116
- document.getElementById('currentMonthlyChars').textContent = stats.monthly_characters_formatted;
1117
- document.getElementById('currentFirstDate').textContent = stats.first_date;
1118
- document.getElementById('currentLastDate').textContent = stats.last_date;
1691
+
1692
+ const currentReadingSpeedEl = document.getElementById('currentReadingSpeed');
1693
+ const currentReadingSpeedBox = currentReadingSpeedEl.closest('.dashboard-stat-item');
1694
+ currentReadingSpeedEl.textContent = stats.reading_speed_formatted;
1695
+ if (currentReadingSpeedBox && stats.reading_speed) {
1696
+ currentReadingSpeedBox.setAttribute('title', `${stats.reading_speed.toLocaleString(undefined, {maximumFractionDigits: 2})} chars/hour`);
1697
+ }
1119
1698
 
1120
1699
  // Update streak indicator
1121
1700
  const streakElement = document.getElementById('currentGameStreak');
@@ -1138,18 +1717,30 @@ document.addEventListener('DOMContentLoaded', function () {
1138
1717
  }
1139
1718
 
1140
1719
  // Update subtitle
1141
- const gamesText = stats.unique_games === 1 ? '1 game played' : `${stats.unique_games} games played`;
1720
+ const gamesText = stats.completed_games === 1 ? '1 game completed' : `${stats.completed_games} games completed`;
1142
1721
  document.getElementById('totalGamesCount').textContent = gamesText;
1143
1722
 
1144
- // Update main statistics
1145
- document.getElementById('allTotalChars').textContent = stats.total_characters_formatted;
1723
+ // Update main statistics with native tooltips
1724
+ const allTotalCharsEl = document.getElementById('allTotalChars');
1725
+ const allTotalCharsBox = allTotalCharsEl.closest('.dashboard-stat-item');
1726
+ allTotalCharsEl.textContent = stats.total_characters_formatted;
1727
+ if (allTotalCharsBox && stats.total_characters) {
1728
+ allTotalCharsBox.setAttribute('title', `${stats.total_characters.toLocaleString(undefined, {maximumFractionDigits: 2})} characters`);
1729
+ }
1730
+
1146
1731
  document.getElementById('allTotalTime').textContent = stats.total_time_formatted;
1147
- document.getElementById('allReadingSpeed').textContent = stats.reading_speed_formatted;
1732
+
1733
+ const allReadingSpeedEl = document.getElementById('allReadingSpeed');
1734
+ const allReadingSpeedBox = allReadingSpeedEl.closest('.dashboard-stat-item');
1735
+ allReadingSpeedEl.textContent = stats.reading_speed_formatted;
1736
+ if (allReadingSpeedBox && stats.reading_speed) {
1737
+ allReadingSpeedBox.setAttribute('title', `${stats.reading_speed.toLocaleString(undefined, {maximumFractionDigits: 2})} chars/hour`);
1738
+ }
1739
+
1148
1740
  document.getElementById('allSessions').textContent = stats.sessions.toLocaleString();
1149
1741
 
1150
- // Update progress section
1151
- document.getElementById('allMonthlyChars').textContent = stats.monthly_characters_formatted;
1152
- document.getElementById('allUniqueGames').textContent = stats.unique_games.toLocaleString();
1742
+ // Update progress section (removed monthly characters)
1743
+ document.getElementById('allUniqueGames').textContent = stats.completed_games.toLocaleString();
1153
1744
  document.getElementById('allTotalSentences').textContent = stats.total_sentences.toLocaleString();
1154
1745
 
1155
1746
  // Update streak indicator
@@ -1234,4 +1825,279 @@ document.addEventListener('DOMContentLoaded', function () {
1234
1825
 
1235
1826
  // Global function to retry dashboard loading
1236
1827
  window.loadDashboardData = loadDashboardData;
1828
+
1829
+ // Description expand/collapse functionality
1830
+ const descriptionExpandBtn = document.getElementById('descriptionExpandBtn');
1831
+ if (descriptionExpandBtn) {
1832
+ descriptionExpandBtn.addEventListener('click', function() {
1833
+ const description = document.getElementById('gameDescription');
1834
+ const expandText = this.querySelector('.expand-text');
1835
+ const collapseText = this.querySelector('.collapse-text');
1836
+
1837
+ if (description.classList.contains('expanded')) {
1838
+ // Collapse
1839
+ description.classList.remove('expanded');
1840
+ expandText.style.display = 'inline';
1841
+ collapseText.style.display = 'none';
1842
+ } else {
1843
+ // Expand
1844
+ description.classList.add('expanded');
1845
+ expandText.style.display = 'none';
1846
+ collapseText.style.display = 'inline';
1847
+ }
1848
+ });
1849
+ }
1850
+
1851
+ // Game completion button handler
1852
+ const gameCompletionBtn = document.getElementById('gameCompletionBtn');
1853
+ if (gameCompletionBtn) {
1854
+ gameCompletionBtn.addEventListener('click', async function() {
1855
+ // Don't do anything if already completed
1856
+ if (this.disabled) return;
1857
+
1858
+ // Get the current game ID from the stats
1859
+ // We need to fetch current stats to get the game_id
1860
+ try {
1861
+ const response = await fetch('/api/stats');
1862
+ if (!response.ok) throw new Error('Failed to fetch stats');
1863
+
1864
+ const data = await response.json();
1865
+ const currentGameStats = data.currentGameStats;
1866
+
1867
+ if (!currentGameStats || !currentGameStats.game_name) {
1868
+ console.error('No current game found');
1869
+ return;
1870
+ }
1871
+
1872
+ // Find the game_id by looking up the game
1873
+ // We need to get the game_id from the games management API
1874
+ const gamesResponse = await fetch('/api/games-management');
1875
+ if (!gamesResponse.ok) throw new Error('Failed to fetch games');
1876
+
1877
+ const gamesData = await gamesResponse.json();
1878
+ const currentGame = gamesData.games.find(g =>
1879
+ g.title_original === currentGameStats.game_name ||
1880
+ g.title_original === currentGameStats.title_original
1881
+ );
1882
+
1883
+ if (!currentGame) {
1884
+ console.error('Could not find game ID for current game');
1885
+ return;
1886
+ }
1887
+
1888
+ // Confirm with user
1889
+ const confirmMsg = `Mark "${currentGame.title_original}" as completed?`;
1890
+ if (!confirm(confirmMsg)) return;
1891
+
1892
+ // Call the API to mark as complete
1893
+ const markCompleteResponse = await fetch(`/api/games/${currentGame.id}/mark-complete`, {
1894
+ method: 'POST',
1895
+ headers: {
1896
+ 'Content-Type': 'application/json'
1897
+ }
1898
+ });
1899
+
1900
+ if (!markCompleteResponse.ok) {
1901
+ const errorData = await markCompleteResponse.json();
1902
+ throw new Error(errorData.error || 'Failed to mark game as complete');
1903
+ }
1904
+
1905
+ const result = await markCompleteResponse.json();
1906
+ console.log('Game marked as complete:', result);
1907
+
1908
+ // Trigger confetti celebration!
1909
+ if (typeof confetti !== 'undefined') {
1910
+ // Fire confetti from multiple angles for a nice effect
1911
+ const duration = 3000; // 3 seconds
1912
+ const animationEnd = Date.now() + duration;
1913
+ const defaults = { startVelocity: 30, spread: 360, ticks: 60, zIndex: 9999 };
1914
+
1915
+ function randomInRange(min, max) {
1916
+ return Math.random() * (max - min) + min;
1917
+ }
1918
+
1919
+ const interval = setInterval(function() {
1920
+ const timeLeft = animationEnd - Date.now();
1921
+
1922
+ if (timeLeft <= 0) {
1923
+ return clearInterval(interval);
1924
+ }
1925
+
1926
+ const particleCount = 50 * (timeLeft / duration);
1927
+
1928
+ // Fire confetti from left side
1929
+ confetti({
1930
+ ...defaults,
1931
+ particleCount,
1932
+ origin: { x: randomInRange(0.1, 0.3), y: Math.random() - 0.2 }
1933
+ });
1934
+
1935
+ // Fire confetti from right side
1936
+ confetti({
1937
+ ...defaults,
1938
+ particleCount,
1939
+ origin: { x: randomInRange(0.7, 0.9), y: Math.random() - 0.2 }
1940
+ });
1941
+ }, 250);
1942
+ }
1943
+
1944
+ // Update button to completed state
1945
+ this.textContent = 'Completed ✓';
1946
+ this.disabled = true;
1947
+ this.classList.add('completed');
1948
+
1949
+ // Add completed class to card
1950
+ const currentGameCard = document.getElementById('currentGameCard');
1951
+ if (currentGameCard) {
1952
+ currentGameCard.classList.add('completed');
1953
+ }
1954
+
1955
+ // Optionally refresh the dashboard to reflect changes
1956
+ setTimeout(() => {
1957
+ loadDashboardData();
1958
+ }, 500);
1959
+
1960
+ } catch (error) {
1961
+ console.error('Error marking game as complete:', error);
1962
+ alert(`Failed to mark game as complete: ${error.message}`);
1963
+ }
1964
+ });
1965
+ }
1966
+
1967
+ // ExStatic Import Functionality
1968
+ const exstaticFileInput = document.getElementById('exstaticFile');
1969
+ const importExstaticBtn = document.getElementById('importExstaticBtn');
1970
+ const importProgress = document.getElementById('importProgress');
1971
+ const importProgressBar = document.getElementById('importProgressBar');
1972
+ const importProgressText = document.getElementById('importProgressText');
1973
+ const importStatus = document.getElementById('importStatus');
1974
+
1975
+ if (exstaticFileInput && importExstaticBtn) {
1976
+ // Enable/disable import button based on file selection
1977
+ exstaticFileInput.addEventListener('change', function(e) {
1978
+ const file = e.target.files[0];
1979
+ // Enable button whenever any file is selected
1980
+ if (file) {
1981
+ importExstaticBtn.disabled = false;
1982
+ importExstaticBtn.style.background = '#2980b9';
1983
+ importExstaticBtn.style.cursor = 'pointer';
1984
+ showImportStatus('', 'info', false);
1985
+ } else {
1986
+ importExstaticBtn.disabled = true;
1987
+ importExstaticBtn.style.background = '#666';
1988
+ importExstaticBtn.style.cursor = 'not-allowed';
1989
+ }
1990
+ });
1991
+
1992
+ // Handle import button click
1993
+ importExstaticBtn.addEventListener('click', function() {
1994
+ const file = exstaticFileInput.files[0];
1995
+ if (!file) {
1996
+ showImportStatus('Please select a CSV file first.', 'error', true);
1997
+ return;
1998
+ }
1999
+
2000
+ importExstaticData(file);
2001
+ });
2002
+ }
2003
+
2004
+ function showImportStatus(message, type, show) {
2005
+ if (!importStatus) return;
2006
+
2007
+ if (show && message) {
2008
+ importStatus.textContent = message;
2009
+ importStatus.style.display = 'block';
2010
+
2011
+ // Set appropriate styling based on type
2012
+ if (type === 'error') {
2013
+ importStatus.style.background = 'var(--danger-color)';
2014
+ importStatus.style.color = 'white';
2015
+ } else if (type === 'success') {
2016
+ importStatus.style.background = 'var(--success-color)';
2017
+ importStatus.style.color = 'white';
2018
+ } else if (type === 'info') {
2019
+ importStatus.style.background = 'var(--primary-color)';
2020
+ importStatus.style.color = 'white';
2021
+ } else {
2022
+ importStatus.style.background = 'var(--bg-tertiary)';
2023
+ importStatus.style.color = 'var(--text-primary)';
2024
+ }
2025
+ } else {
2026
+ importStatus.style.display = 'none';
2027
+ }
2028
+ }
2029
+
2030
+ function showImportProgress(show, percentage = 0) {
2031
+ if (!importProgress || !importProgressBar || !importProgressText) return;
2032
+
2033
+ if (show) {
2034
+ importProgress.style.display = 'block';
2035
+ importProgressBar.style.width = percentage + '%';
2036
+ importProgressText.textContent = Math.round(percentage) + '%';
2037
+ } else {
2038
+ importProgress.style.display = 'none';
2039
+ }
2040
+ }
2041
+
2042
+ async function importExstaticData(file) {
2043
+ try {
2044
+ // Disable import button and show progress
2045
+ importExstaticBtn.disabled = true;
2046
+ showImportProgress(true, 0);
2047
+ showImportStatus('Preparing import...', 'info', true);
2048
+
2049
+ // Create FormData and append the file
2050
+ const formData = new FormData();
2051
+ formData.append('file', file);
2052
+
2053
+ // Show upload progress
2054
+ showImportProgress(true, 25);
2055
+ showImportStatus('Uploading file...', 'info', true);
2056
+
2057
+ // Send file to backend
2058
+ const response = await fetch('/api/import-exstatic', {
2059
+ method: 'POST',
2060
+ body: formData
2061
+ });
2062
+
2063
+ showImportProgress(true, 75);
2064
+ showImportStatus('Processing data...', 'info', true);
2065
+
2066
+ const result = await response.json();
2067
+
2068
+ showImportProgress(true, 100);
2069
+
2070
+ if (response.ok) {
2071
+ // Success
2072
+ const message = `Successfully imported ${result.imported_count || 0} lines from ${result.games_count || 0} games.`;
2073
+ showImportStatus(message, 'success', true);
2074
+
2075
+ // Reset file input and button
2076
+ exstaticFileInput.value = '';
2077
+ importExstaticBtn.disabled = true;
2078
+
2079
+ // Hide progress after a delay
2080
+ setTimeout(() => {
2081
+ showImportProgress(false);
2082
+ // Optionally refresh the page to show new data
2083
+ if (result.imported_count > 0) {
2084
+ setTimeout(() => {
2085
+ window.location.reload();
2086
+ }, 2000);
2087
+ }
2088
+ }, 1500);
2089
+ } else {
2090
+ // Error
2091
+ showImportStatus(result.error || 'Import failed. Please try again.', 'error', true);
2092
+ showImportProgress(false);
2093
+ }
2094
+ } catch (error) {
2095
+ console.error('Import error:', error);
2096
+ showImportStatus('Import failed due to network error. Please try again.', 'error', true);
2097
+ showImportProgress(false);
2098
+ } finally {
2099
+ // Re-enable import button only if a file is still selected
2100
+ importExstaticBtn.disabled = !(exstaticFileInput && exstaticFileInput.files && exstaticFileInput.files.length > 0);
2101
+ }
2102
+ }
1237
2103
  });