GameSentenceMiner 2.17.1__py3-none-any.whl → 2.17.3__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 (28) hide show
  1. GameSentenceMiner/anki.py +25 -1
  2. GameSentenceMiner/config_gui.py +19 -1
  3. GameSentenceMiner/gsm.py +13 -18
  4. GameSentenceMiner/obs.py +4 -2
  5. GameSentenceMiner/ocr/owocr_helper.py +1 -1
  6. GameSentenceMiner/owocr/owocr/run.py +2 -2
  7. GameSentenceMiner/util/configuration.py +7 -5
  8. GameSentenceMiner/util/db.py +176 -8
  9. GameSentenceMiner/util/downloader/download_tools.py +57 -24
  10. GameSentenceMiner/util/get_overlay_coords.py +3 -0
  11. GameSentenceMiner/util/gsm_utils.py +0 -54
  12. GameSentenceMiner/web/database_api.py +12 -1
  13. GameSentenceMiner/web/gsm_websocket.py +1 -1
  14. GameSentenceMiner/web/static/css/shared.css +20 -0
  15. GameSentenceMiner/web/static/css/stats.css +496 -1
  16. GameSentenceMiner/web/static/js/anki_stats.js +87 -3
  17. GameSentenceMiner/web/static/js/shared.js +2 -49
  18. GameSentenceMiner/web/static/js/stats.js +274 -39
  19. GameSentenceMiner/web/templates/anki_stats.html +36 -0
  20. GameSentenceMiner/web/templates/index.html +11 -12
  21. GameSentenceMiner/web/templates/stats.html +35 -15
  22. GameSentenceMiner/web/texthooking_page.py +31 -8
  23. {gamesentenceminer-2.17.1.dist-info → gamesentenceminer-2.17.3.dist-info}/METADATA +1 -1
  24. {gamesentenceminer-2.17.1.dist-info → gamesentenceminer-2.17.3.dist-info}/RECORD +28 -28
  25. {gamesentenceminer-2.17.1.dist-info → gamesentenceminer-2.17.3.dist-info}/WHEEL +0 -0
  26. {gamesentenceminer-2.17.1.dist-info → gamesentenceminer-2.17.3.dist-info}/entry_points.txt +0 -0
  27. {gamesentenceminer-2.17.1.dist-info → gamesentenceminer-2.17.3.dist-info}/licenses/LICENSE +0 -0
  28. {gamesentenceminer-2.17.1.dist-info → gamesentenceminer-2.17.3.dist-info}/top_level.txt +0 -0
@@ -10,6 +10,8 @@ document.addEventListener('DOMContentLoaded', function () {
10
10
  const ankiTotalKanji = document.getElementById('ankiTotalKanji');
11
11
  const gsmTotalKanji = document.getElementById('gsmTotalKanji');
12
12
  const ankiCoverage = document.getElementById('ankiCoverage');
13
+ const fromDateInput = document.getElementById('fromDate');
14
+ const toDateInput = document.getElementById('toDate');
13
15
 
14
16
  console.log('Found DOM elements:', {
15
17
  loading, error, missingKanjiGrid, missingKanjiCount,
@@ -62,12 +64,18 @@ document.addEventListener('DOMContentLoaded', function () {
62
64
  renderKanjiGrid(data.missing_kanji);
63
65
  }
64
66
 
65
- async function loadStats() {
67
+ async function loadStats(start_timestamp = null, end_timestamp = null) {
66
68
  console.log('Loading Anki stats...');
67
69
  showLoading(true);
68
70
  showError(false);
69
71
  try {
70
- const resp = await fetch('/api/anki_stats');
72
+ // Build URL with optional query params
73
+ const params = new URLSearchParams();
74
+ if (start_timestamp) params.append('start_timestamp', start_timestamp);
75
+ if (end_timestamp) params.append('end_timestamp', end_timestamp);
76
+ const url = '/api/anki_stats' + (params.toString() ? `?${params.toString()}` : '');
77
+
78
+ const resp = await fetch(url);
71
79
  if (!resp.ok) throw new Error('Failed to load');
72
80
  const data = await resp.json();
73
81
  console.log('Received data:', data);
@@ -80,5 +88,81 @@ document.addEventListener('DOMContentLoaded', function () {
80
88
  }
81
89
  }
82
90
 
83
- loadStats();
91
+ function getUnixTimestampsInMilliseconds(startDate, endDate) {
92
+ // Parse the start date and create a Date object at the beginning of the day
93
+ const start = new Date(startDate + 'T00:00:00');
94
+ const startTimestamp = start.getTime();
95
+
96
+ // Parse the end date and create a Date object at the end of the day
97
+ const end = new Date(endDate + 'T23:59:59.999');
98
+ const endTimestamp = end.getTime();
99
+
100
+ return { startTimestamp, endTimestamp };
101
+ }
102
+
103
+ document.addEventListener("datesSetAnki", () => {
104
+ const fromDate = sessionStorage.getItem("fromDateAnki");
105
+ const toDate = sessionStorage.getItem("toDateAnki");
106
+ const { startTimestamp, endTimestamp } = getUnixTimestampsInMilliseconds(fromDate, toDate);
107
+
108
+ loadStats(startTimestamp, endTimestamp);
109
+ });
110
+
111
+ function initializeDates() {
112
+ const fromDateInput = document.getElementById('fromDate');
113
+ const toDateInput = document.getElementById('toDate');
114
+
115
+ const fromDate = sessionStorage.getItem("fromDateAnki");
116
+ const toDate = sessionStorage.getItem("toDateAnki");
117
+
118
+ if (!(fromDate && toDate)) {
119
+ fetch('/api/anki_earliest_date')
120
+ .then(response => response.json())
121
+ .then(response_json => {
122
+ // Get first date in ms from API
123
+ const firstDateinMs = response_json.earliest_card;
124
+ const firstDateObject = new Date(firstDateinMs);
125
+ fromDateInput.value = firstDateObject.toISOString().split('T')[0];
126
+
127
+ // Get today's date
128
+ const today = new Date();
129
+ toDateInput.value = today.toISOString().split("T")[0];
130
+
131
+ // Save in sessionStorage
132
+ sessionStorage.setItem("fromDateAnki", firstDateObject.toISOString().split('T')[0]);
133
+ sessionStorage.setItem("toDateAnki", today.toISOString().split("T")[0]);
134
+
135
+ document.dispatchEvent(new Event("datesSetAnki"));
136
+ });
137
+ } else {
138
+ // If values already in sessionStorage, set inputs from there
139
+ fromDateInput.value = fromDate;
140
+ toDateInput.value = toDate;
141
+ console.log("already in session storage, dispatching datesSetAnki")
142
+ document.dispatchEvent(new Event("datesSetAnki"));
143
+ }
144
+ }
145
+
146
+ function handleDateChange() {
147
+ const fromDateStr = fromDateInput.value;
148
+ const toDateStr = toDateInput.value;
149
+
150
+ sessionStorage.setItem("fromDateAnki", fromDateStr);
151
+ sessionStorage.setItem("toDateAnki", toDateStr);
152
+
153
+ // Validate date order
154
+ if (fromDateStr && toDateStr && new Date(fromDateStr) > new Date(toDateStr)) {
155
+ popup.classList.remove("hidden");
156
+ return;
157
+ }
158
+
159
+ const { startTimestamp, endTimestamp } = getUnixTimestampsInMilliseconds(fromDateStr, toDateStr);
160
+
161
+ loadStats(startTimestamp, endTimestamp)
162
+ }
163
+
164
+ fromDateInput.addEventListener("change", handleDateChange);
165
+ toDateInput.addEventListener("change", handleDateChange);
166
+
167
+ initializeDates();
84
168
  });
@@ -227,7 +227,6 @@ class SettingsManager {
227
227
  // Optional elements that may not exist on all pages
228
228
  this.afkTimerInput = document.getElementById('afkTimer');
229
229
  this.sessionGapInput = document.getElementById('sessionGap');
230
- this.heatmapYearSelect = document.getElementById('heatmapYear');
231
230
  this.streakRequirementInput = document.getElementById('streakRequirement');
232
231
  this.readingHoursTargetInput = document.getElementById('readingHoursTarget');
233
232
  this.characterCountTargetInput = document.getElementById('characterCountTarget');
@@ -263,27 +262,17 @@ class SettingsManager {
263
262
  // }
264
263
 
265
264
  // Clear messages when user starts typing
266
- [this.afkTimerInput, this.sessionGapInput, this.heatmapYearSelect, this.streakRequirementInput,
265
+ [this.afkTimerInput, this.sessionGapInput, this.streakRequirementInput,
267
266
  this.readingHoursTargetInput, this.characterCountTargetInput, this.gamesTargetInput]
268
267
  .filter(Boolean)
269
268
  .forEach(input => {
270
269
  input.addEventListener('input', () => this.clearMessages());
271
270
  });
272
-
273
- // Handle year selection change
274
- if (this.heatmapYearSelect) {
275
- this.heatmapYearSelect.addEventListener('change', (e) => {
276
- const selectedYear = e.target.value;
277
- localStorage.setItem('selectedHeatmapYear', selectedYear);
278
- this.refreshHeatmapData(selectedYear);
279
- });
280
- }
281
271
  }
282
272
 
283
273
  async openModal() {
284
274
  try {
285
275
  await this.loadCurrentSettings();
286
- await this.loadAvailableYears();
287
276
  this.showModal();
288
277
  } catch (error) {
289
278
  console.error('Error opening settings modal:', error);
@@ -336,48 +325,12 @@ class SettingsManager {
336
325
  if (this.gamesTargetInput) {
337
326
  this.gamesTargetInput.value = settings.games_target || 100;
338
327
  }
339
-
340
- // Load saved year preference
341
- const savedYear = localStorage.getItem('selectedHeatmapYear') || 'all';
342
- if (this.heatmapYearSelect) {
343
- this.heatmapYearSelect.value = savedYear;
344
- }
345
- }
346
-
347
- async loadAvailableYears() {
348
- if (!this.heatmapYearSelect) return;
349
-
350
- try {
351
- const response = await fetch('/api/stats');
352
- if (!response.ok) throw new Error('Failed to fetch stats');
353
-
354
- const data = await response.json();
355
- const availableYears = Object.keys(data.heatmapData || {}).sort().reverse();
356
-
357
- // Clear existing options except "All Years"
358
- this.heatmapYearSelect.innerHTML = '<option value="all">All Years</option>';
359
-
360
- // Add available years
361
- availableYears.forEach(year => {
362
- const option = document.createElement('option');
363
- option.value = year;
364
- option.textContent = year;
365
- this.heatmapYearSelect.appendChild(option);
366
- });
367
-
368
- // Restore saved selection
369
- const savedYear = localStorage.getItem('selectedHeatmapYear') || 'all';
370
- this.heatmapYearSelect.value = savedYear;
371
-
372
- } catch (error) {
373
- console.error('Error loading available years:', error);
374
- }
375
328
  }
376
329
 
377
330
  async refreshHeatmapData(selectedYear) {
378
331
  try {
379
332
  if (typeof loadStatsData === 'function') {
380
- await loadStatsData(selectedYear);
333
+ await loadStatsData(start_timestamp = null, end_timestamp = null);
381
334
  }
382
335
  } catch (error) {
383
336
  console.error('Error refreshing heatmap data:', error);
@@ -44,10 +44,20 @@ if (window.Chart) {
44
44
  // Dependencies: shared.js (provides utility functions like showElement, hideElement, escapeHtml)
45
45
 
46
46
  document.addEventListener('DOMContentLoaded', function () {
47
+
48
+ // Global object to store chart instances
49
+ window.myCharts = window.myCharts || {};
50
+
47
51
  // Helper function to create a chart to avoid repeating code
48
52
  function createChart(canvasId, datasets, chartTitle) {
49
53
  const ctx = document.getElementById(canvasId).getContext('2d');
50
- new Chart(ctx, {
54
+
55
+ // Destroy existing chart on this canvas if it exists
56
+ if (window.myCharts[canvasId]) {
57
+ window.myCharts[canvasId].destroy();
58
+ }
59
+
60
+ window.myCharts[canvasId] = new Chart(ctx, {
51
61
  type: 'line',
52
62
  data: {
53
63
  labels: datasets.labels,
@@ -93,6 +103,7 @@ document.addEventListener('DOMContentLoaded', function () {
93
103
  }
94
104
  }
95
105
  });
106
+ return window.myCharts[canvasId];
96
107
  }
97
108
 
98
109
  // Helper function to get week number of year (GitHub style - week starts on Sunday)
@@ -502,7 +513,12 @@ document.addEventListener('DOMContentLoaded', function () {
502
513
  return getFilteredChartData(originalData, hiddenBars, colors);
503
514
  }
504
515
 
505
- new Chart(ctx, {
516
+ // Destroy existing chart on this canvas if it exists
517
+ if (window.myCharts[canvasId]) {
518
+ window.myCharts[canvasId].destroy();
519
+ }
520
+
521
+ window.myCharts[canvasId] = new Chart(ctx, {
506
522
  type: 'bar',
507
523
  data: {
508
524
  labels: chartData.labels, // Each game as a separate label
@@ -603,6 +619,8 @@ document.addEventListener('DOMContentLoaded', function () {
603
619
  }
604
620
  }
605
621
  });
622
+
623
+ return window.myCharts[canvasId];
606
624
  }
607
625
 
608
626
  // Specialized function for charts with custom formatting (time/speed)
@@ -623,7 +641,13 @@ document.addEventListener('DOMContentLoaded', function () {
623
641
  return getFilteredChartData(originalData, hiddenBars, colors);
624
642
  }
625
643
 
626
- new Chart(ctx, {
644
+ // Destroy existing chart if it exists
645
+ if (window.myCharts[canvasId]) {
646
+ window.myCharts[canvasId].destroy();
647
+ }
648
+
649
+ // Create new chart and store globally
650
+ window.myCharts[canvasId] = new Chart(ctx, {
627
651
  type: 'bar',
628
652
  data: {
629
653
  labels: chartData.labels, // Each game as a separate label
@@ -724,6 +748,7 @@ document.addEventListener('DOMContentLoaded', function () {
724
748
  }
725
749
  }
726
750
  });
751
+ return window.myCharts[canvasId];
727
752
  }
728
753
 
729
754
  // Helper functions for formatting
@@ -753,9 +778,29 @@ document.addEventListener('DOMContentLoaded', function () {
753
778
  kanjiGridRenderer.render(kanjiData);
754
779
  }
755
780
 
781
+ function showNoDataPopup() {
782
+ document.getElementById("noDataPopup").classList.remove("hidden");
783
+ }
784
+
785
+ document.getElementById("closeNoDataPopup").addEventListener("click", () => {
786
+ document.getElementById("noDataPopup").classList.add("hidden");
787
+ });
788
+
756
789
  // Function to load stats data with optional year filter
757
- function loadStatsData(filterYear = null) {
758
- const url = filterYear && filterYear !== 'all' ? `/api/stats?year=${filterYear}` : '/api/stats';
790
+ function loadStatsData(start_timestamp = null, end_timestamp = null) {
791
+ let url = '/api/stats';
792
+ const params = new URLSearchParams();
793
+
794
+ if (start_timestamp && end_timestamp) {
795
+ // Only filter by timestamps
796
+ params.append('start', start_timestamp);
797
+ params.append('end', end_timestamp);
798
+ }
799
+
800
+ const queryString = params.toString();
801
+ if (queryString) {
802
+ url += `?${queryString}`;
803
+ }
759
804
 
760
805
  return fetch(url)
761
806
  .then(response => response.json())
@@ -770,6 +815,7 @@ document.addEventListener('DOMContentLoaded', function () {
770
815
 
771
816
  if (!data.labels || data.labels.length === 0) {
772
817
  console.log("No data to display.");
818
+ showNoDataPopup();
773
819
  return data;
774
820
  }
775
821
 
@@ -787,34 +833,31 @@ document.addEventListener('DOMContentLoaded', function () {
787
833
  // Remove the 'hidden' property so they appear on their own charts
788
834
  [...charsData.datasets].forEach(d => delete d.hidden);
789
835
 
790
- // Create the charts (only on initial load)
791
- if (!window.chartsInitialized) {
836
+ // Charts are re-created with the new data
792
837
  createChart('linesChart', linesData, 'Cumulative Lines Received');
793
838
  createChart('charsChart', charsData, 'Cumulative Characters Read');
794
839
 
795
- // Create reading chars quantity chart if data exists
796
- if (data.totalCharsPerGame) {
797
- createGameBarChart('readingCharsChart', data.totalCharsPerGame, 'Reading Chars Quantity', 'Characters Read');
798
- }
799
-
800
- // Create reading time quantity chart if data exists
801
- if (data.readingTimePerGame) {
802
- createGameBarChartWithCustomFormat('readingTimeChart', data.readingTimePerGame, 'Reading Time Quantity', 'Time (hours)', formatTime);
803
- }
840
+ // Create reading chars quantity chart if data exists
841
+ if (data.totalCharsPerGame) {
842
+ createGameBarChart('readingCharsChart', data.totalCharsPerGame, 'Reading Chars Quantity', 'Characters Read');
843
+ }
804
844
 
805
- // Create reading speed per game chart if data exists
806
- if (data.readingSpeedPerGame) {
807
- createGameBarChartWithCustomFormat('readingSpeedPerGameChart', data.readingSpeedPerGame, 'Reading Speed Improvement', 'Speed (chars/hour)', formatSpeed);
808
- }
845
+ // Create reading time quantity chart if data exists
846
+ if (data.readingTimePerGame) {
847
+ createGameBarChartWithCustomFormat('readingTimeChart', data.readingTimePerGame, 'Reading Time Quantity', 'Time (hours)', formatTime);
848
+ }
809
849
 
810
- // Create kanji grid if data exists
811
- if (data.kanjiGridData) {
812
- createKanjiGrid(data.kanjiGridData);
813
- }
850
+ // Create reading speed per game chart if data exists
851
+ if (data.readingSpeedPerGame) {
852
+ createGameBarChartWithCustomFormat('readingSpeedPerGameChart', data.readingSpeedPerGame, 'Reading Speed Improvement', 'Speed (chars/hour)', formatSpeed);
853
+ }
814
854
 
815
- window.chartsInitialized = true;
855
+ // Create kanji grid if data exists
856
+ if (data.kanjiGridData) {
857
+ createKanjiGrid(data.kanjiGridData);
816
858
  }
817
859
 
860
+
818
861
  // Always update heatmap
819
862
  if (data.heatmapData) {
820
863
  const container = document.getElementById('heatmapContainer');
@@ -822,11 +865,8 @@ document.addEventListener('DOMContentLoaded', function () {
822
865
  createHeatmap(data.heatmapData);
823
866
  }
824
867
 
825
- // Load dashboard data (only on initial load)
826
- if (!window.dashboardInitialized) {
827
- loadDashboardData(data);
828
- window.dashboardInitialized = true;
829
- }
868
+ // Load dashboard data
869
+ loadDashboardData(data, end_timestamp);
830
870
 
831
871
  // Load goal progress chart (always refresh)
832
872
  if (typeof loadGoalProgress === 'function') {
@@ -1117,9 +1157,100 @@ document.addEventListener('DOMContentLoaded', function () {
1117
1157
  }
1118
1158
  }
1119
1159
 
1120
- // Initial load with saved year preference
1121
- const savedYear = localStorage.getItem('selectedHeatmapYear') || window.statsConfig?.heatmapDisplayYear || 'all';
1122
- loadStatsData(savedYear);
1160
+ // ================================
1161
+ // Utility to convert date strings to Unix timestamps
1162
+ // Returns start of day for startDate and end of day for endDate
1163
+ // ================================
1164
+ function getUnixTimestamps(startDate, endDate) {
1165
+ const start = new Date(startDate + 'T00:00:00');
1166
+ const startTimestamp = Math.floor(start.getTime() / 1000); // convert ms to s
1167
+
1168
+ const end = new Date(endDate + 'T23:59:59.999');
1169
+ const endTimestamp = Math.floor(end.getTime() / 1000); // convert ms to s
1170
+
1171
+ return { startTimestamp, endTimestamp };
1172
+ }
1173
+
1174
+ // ================================
1175
+ // Initialize date inputs with sessionStorage or fetch initial values
1176
+ // Dispatches "datesSet" event once dates are set
1177
+ // ================================
1178
+ function initializeDates() {
1179
+ const fromDateInput = document.getElementById('fromDate');
1180
+ const toDateInput = document.getElementById('toDate');
1181
+
1182
+ const fromDate = sessionStorage.getItem("fromDate");
1183
+ const toDate = sessionStorage.getItem("toDate");
1184
+
1185
+ if (!(fromDate && toDate)) {
1186
+ fetch('/api/stats')
1187
+ .then(response => response.json())
1188
+ .then(response_json => {
1189
+ // Get first date from API
1190
+ const firstDate = response_json.allGamesStats.first_date;
1191
+ fromDateInput.value = firstDate;
1192
+
1193
+ // Get today's date
1194
+ const today = new Date();
1195
+ toDateInput.value = today.toISOString().split("T")[0];
1196
+
1197
+ // Save in sessionStorage
1198
+ sessionStorage.setItem("fromDate", firstDate);
1199
+ sessionStorage.setItem("toDate", today.toISOString().split("T")[0]);
1200
+
1201
+ document.dispatchEvent(new Event("datesSet"));
1202
+ });
1203
+ } else {
1204
+ // If values already in sessionStorage, set inputs from there
1205
+ fromDateInput.value = fromDate;
1206
+ toDateInput.value = toDate;
1207
+
1208
+ document.dispatchEvent(new Event("datesSet"));
1209
+ }
1210
+ }
1211
+
1212
+ const fromDateInput = document.getElementById('fromDate');
1213
+ const toDateInput = document.getElementById('toDate');
1214
+ const popup = document.getElementById('dateErrorPopup');
1215
+ const closePopupBtn = document.getElementById('closePopupBtn');
1216
+
1217
+ document.addEventListener("datesSet", () => {
1218
+ const fromDate = sessionStorage.getItem("fromDate");
1219
+ const toDate = sessionStorage.getItem("toDate");
1220
+ const { startTimestamp, endTimestamp } = getUnixTimestamps(fromDate, toDate);
1221
+
1222
+ loadStatsData(startTimestamp, endTimestamp);
1223
+ });
1224
+
1225
+
1226
+ function handleDateChange() {
1227
+ const fromDateStr = fromDateInput.value;
1228
+ const toDateStr = toDateInput.value;
1229
+
1230
+ sessionStorage.setItem("fromDate", fromDateStr);
1231
+ sessionStorage.setItem("toDate", toDateStr);
1232
+
1233
+ // Validate date order
1234
+ if (fromDateStr && toDateStr && new Date(fromDateStr) > new Date(toDateStr)) {
1235
+ popup.classList.remove("hidden");
1236
+ return;
1237
+ }
1238
+
1239
+ const { startTimestamp, endTimestamp } = getUnixTimestamps(fromDateStr, toDateStr);
1240
+
1241
+ loadStatsData(startTimestamp, endTimestamp);
1242
+ }
1243
+
1244
+ // Attach listeners to both date inputs
1245
+ fromDateInput.addEventListener("change", handleDateChange);
1246
+ toDateInput.addEventListener("change", handleDateChange);
1247
+
1248
+ initializeDates();
1249
+
1250
+ // Popup close button
1251
+ closePopupBtn.addEventListener("click", () => {
1252
+ popup.classList.add("hidden");
1253
+ });
1123
1254
 
1124
1255
  // Populate settings modal with global config values on load
1125
1256
  if (window.statsConfig) {
@@ -1129,9 +1260,6 @@ document.addEventListener('DOMContentLoaded', function () {
1129
1260
  const streakReqInput = document.getElementById('streakRequirement');
1130
1261
  if (streakReqInput) streakReqInput.value = window.statsConfig.streakRequirementHours || 1.0;
1131
1262
 
1132
- const heatmapYearSelect = document.getElementById('heatmapYear');
1133
- if (heatmapYearSelect) heatmapYearSelect.value = window.statsConfig.heatmapDisplayYear || 'all';
1134
-
1135
1263
  const hoursTargetInput = document.getElementById('readingHoursTarget');
1136
1264
  if (hoursTargetInput) hoursTargetInput.value = window.statsConfig.readingHoursTarget || 1500;
1137
1265
 
@@ -1191,7 +1319,7 @@ document.addEventListener('DOMContentLoaded', function () {
1191
1319
  window.loadGoalProgress = loadGoalProgress;
1192
1320
 
1193
1321
  // Dashboard functionality
1194
- function loadDashboardData(data = null) {
1322
+ function loadDashboardData(data = null, end_timestamp = null) {
1195
1323
  function updateTodayOverview(allLinesData) {
1196
1324
  // Get today's date string (YYYY-MM-DD)
1197
1325
  // Get today's date string (YYYY-MM-DD), timezone aware (local time)
@@ -1291,11 +1419,116 @@ document.addEventListener('DOMContentLoaded', function () {
1291
1419
  document.getElementById('todayCharsPerHour').textContent = charsPerHour;
1292
1420
  }
1293
1421
 
1422
+ function updateOverviewForEndDay(allLinesData, endTimestamp) {
1423
+ if (!endTimestamp) return;
1424
+
1425
+ const pad = n => n.toString().padStart(2, '0');
1426
+
1427
+ // Determine target date string (YYYY-MM-DD) from the end timestamp
1428
+ const endDateObj = new Date(endTimestamp * 1000);
1429
+ const targetDateStr = `${endDateObj.getFullYear()}-${pad(endDateObj.getMonth() + 1)}-${pad(endDateObj.getDate())}`;
1430
+ document.getElementById('todayDate').textContent = targetDateStr;
1431
+
1432
+ // Filter lines that fall on the target date
1433
+ const targetLines = (allLinesData || []).filter(line => {
1434
+ if (!line.timestamp) return false;
1435
+ const ts = parseFloat(line.timestamp);
1436
+ if (isNaN(ts)) return false;
1437
+ const dateObj = new Date(ts * 1000);
1438
+ const lineDate = `${dateObj.getFullYear()}-${pad(dateObj.getMonth() + 1)}-${pad(dateObj.getDate())}`;
1439
+ return lineDate === targetDateStr;
1440
+ });
1441
+
1442
+ // Calculate total characters
1443
+ const totalChars = targetLines.reduce((sum, line) => {
1444
+ const chars = Number(line.characters);
1445
+ return sum + (isNaN(chars) ? 0 : chars);
1446
+ }, 0);
1447
+
1448
+ // Determine session gap (from settings or default)
1449
+ let sessionGap = window.statsConfig?.sessionGapSeconds || 3600;
1450
+ const sessionGapInput = document.getElementById('sessionGap');
1451
+ if (sessionGapInput?.value) {
1452
+ const parsed = parseInt(sessionGapInput.value, 10);
1453
+ if (!isNaN(parsed) && parsed > 0) sessionGap = parsed;
1454
+ }
1455
+
1456
+ // Calculate sessions
1457
+ let sessions = 0;
1458
+ if (targetLines.length > 0 && targetLines[0].session_id !== undefined) {
1459
+ const sessionSet = new Set(targetLines.map(l => l.session_id));
1460
+ sessions = sessionSet.size;
1461
+ } else {
1462
+ const timestamps = targetLines
1463
+ .map(l => parseFloat(l.timestamp))
1464
+ .filter(ts => !isNaN(ts))
1465
+ .sort((a, b) => a - b);
1466
+ if (timestamps.length > 0) {
1467
+ sessions = 1;
1468
+ for (let i = 1; i < timestamps.length; i++) {
1469
+ if (timestamps[i] - timestamps[i - 1] > sessionGap) {
1470
+ sessions += 1;
1471
+ }
1472
+ }
1473
+ }
1474
+ }
1475
+
1476
+ // Calculate total reading time
1477
+ let totalSeconds = 0;
1478
+ const timestamps = targetLines
1479
+ .map(l => parseFloat(l.timestamp))
1480
+ .filter(ts => !isNaN(ts))
1481
+ .sort((a, b) => a - b);
1482
+
1483
+ let afkTimerSeconds = window.statsConfig?.afkTimerSeconds || 120;
1484
+ const afkTimerInput = document.getElementById('afkTimer');
1485
+ if (afkTimerInput?.value) {
1486
+ const parsed = parseInt(afkTimerInput.value, 10);
1487
+ if (!isNaN(parsed) && parsed > 0) afkTimerSeconds = parsed;
1488
+ }
1489
+
1490
+ if (timestamps.length >= 2) {
1491
+ for (let i = 1; i < timestamps.length; i++) {
1492
+ const gap = timestamps[i] - timestamps[i - 1];
1493
+ totalSeconds += Math.min(gap, afkTimerSeconds);
1494
+ }
1495
+ } else if (timestamps.length === 1) {
1496
+ totalSeconds = 1;
1497
+ }
1498
+
1499
+ let totalHours = totalSeconds / 3600;
1500
+
1501
+ // Calculate chars/hour
1502
+ let charsPerHour = '-';
1503
+ if (totalChars > 0) {
1504
+ if (totalHours <= 0) totalHours = 1/60; // Minimum 1 minute
1505
+ charsPerHour = Math.round(totalChars / totalHours).toLocaleString();
1506
+ }
1507
+
1508
+ // Format hours for display
1509
+ let hoursDisplay = '-';
1510
+ if (totalHours > 0) {
1511
+ const h = Math.floor(totalHours);
1512
+ const m = Math.round((totalHours - h) * 60);
1513
+ hoursDisplay = h > 0 ? `${h}h${m > 0 ? ' ' + m + 'm' : ''}` : `${m}m`;
1514
+ }
1515
+
1516
+ // Update DOM
1517
+ document.getElementById('todayTotalHours').textContent = hoursDisplay;
1518
+ document.getElementById('todayTotalChars').textContent = totalChars.toLocaleString();
1519
+ document.getElementById('todaySessions').textContent = sessions;
1520
+ document.getElementById('todayCharsPerHour').textContent = charsPerHour;
1521
+ }
1522
+
1294
1523
  if (data && data.currentGameStats && data.allGamesStats) {
1295
1524
  // Use existing data if available
1296
1525
  updateCurrentGameDashboard(data.currentGameStats);
1297
1526
  updateAllGamesDashboard(data.allGamesStats);
1298
- if (data.allLinesData) updateTodayOverview(data.allLinesData);
1527
+
1528
+ if (data.allLinesData) {
1529
+ end_timestamp == null ? updateTodayOverview(data.allLinesData) : updateOverviewForEndDay(data.allLinesData, end_timestamp)
1530
+ }
1531
+
1299
1532
  hideDashboardLoading();
1300
1533
  } else {
1301
1534
  // Fetch fresh data
@@ -1306,7 +1539,9 @@ document.addEventListener('DOMContentLoaded', function () {
1306
1539
  if (data.currentGameStats && data.allGamesStats) {
1307
1540
  updateCurrentGameDashboard(data.currentGameStats);
1308
1541
  updateAllGamesDashboard(data.allGamesStats);
1309
- if (data.allLinesData) updateTodayOverview(data.allLinesData);
1542
+ if (data.allLinesData) {
1543
+ end_timestamp == null ? updateTodayOverview(data.allLinesData) : updateOverviewForEndDay(data.allLinesData, end_timestamp)
1544
+ }
1310
1545
  } else {
1311
1546
  showDashboardError();
1312
1547
  }
@@ -34,6 +34,42 @@
34
34
  <!-- Include shared navigation -->
35
35
  {% include 'components/navigation.html' %}
36
36
 
37
+ <div class="dashboard-card date-range">
38
+ <div class="dashboard-card-header">
39
+ <h3 class="dashboard-card-title">
40
+ <span class="dashboard-card-icon">📅</span>
41
+ Date Range
42
+ </h3>
43
+ </div>
44
+
45
+ <div class="dashboard-date-range">
46
+ <div class="dashboard-date-item tooltip" data-tooltip="Select the start date for your stats">
47
+ <label for="fromDate">From</label>
48
+ <input type="date" id="fromDate" class="dashboard-date-input">
49
+ </div>
50
+ <div class="dashboard-date-item tooltip" data-tooltip="Select the end date for your stats">
51
+ <label for="toDate">To</label>
52
+ <input type="date" id="toDate" class="dashboard-date-input">
53
+ </div>
54
+ </div>
55
+ </div>
56
+
57
+
58
+ <div id="dateErrorPopup" class="dashboard-popup hidden">
59
+ <div class="dashboard-popup-content">
60
+ <div class="dashboard-popup-icon">⚠️</div>
61
+ <div class="dashboard-popup-message">"From" date cannot be later than "To" date.</div>
62
+ <button id="closePopupBtn" class="dashboard-popup-btn">OK</button>
63
+ </div>
64
+ </div>
65
+
66
+ <div id="noDataPopup" class="no-data-popup hidden">
67
+ <div class="no-data-content">
68
+ <p>No data found for the selected range.</p>
69
+ <button id="closeNoDataPopup">OK</button>
70
+ </div>
71
+ </div>
72
+
37
73
  <!-- Dashboard Statistics Sections -->
38
74
  <div class="dashboard-container">
39
75
  <!-- Missing High-Frequency Kanji Card -->