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