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
@@ -0,0 +1,2104 @@
1
+ """
2
+ Statistics API Endpoints
3
+
4
+ This module contains the /api/stats endpoint and related statistics API routes.
5
+ Separated from database_api.py to improve code organization and maintainability.
6
+ """
7
+
8
+ import csv
9
+ import datetime
10
+ import io
11
+ import json
12
+ import time
13
+ from collections import defaultdict
14
+ from pathlib import Path
15
+
16
+ from flask import request, jsonify
17
+
18
+ from GameSentenceMiner.util.db import GameLinesTable
19
+ from GameSentenceMiner.util.stats_rollup_table import StatsRollupTable
20
+ from GameSentenceMiner.util.games_table import GamesTable
21
+ from GameSentenceMiner.util.configuration import logger, get_stats_config
22
+ from GameSentenceMiner.util.text_log import GameLine
23
+ from GameSentenceMiner.util.cron.daily_rollup import run_daily_rollup
24
+ from GameSentenceMiner.web.stats import (
25
+ calculate_kanji_frequency,
26
+ calculate_mining_heatmap_data,
27
+ calculate_reading_speed_heatmap_data,
28
+ calculate_total_chars_per_game,
29
+ calculate_reading_time_per_game,
30
+ calculate_reading_speed_per_game,
31
+ calculate_current_game_stats,
32
+ calculate_all_games_stats,
33
+ calculate_daily_reading_time,
34
+ calculate_time_based_streak,
35
+ calculate_actual_reading_time,
36
+ calculate_hourly_activity,
37
+ calculate_hourly_reading_speed,
38
+ calculate_peak_daily_stats,
39
+ calculate_peak_session_stats,
40
+ calculate_game_milestones,
41
+ build_game_display_name_mapping,
42
+ format_large_number,
43
+ format_time_human_readable,
44
+ )
45
+ from GameSentenceMiner.web.rollup_stats import (
46
+ aggregate_rollup_data,
47
+ calculate_live_stats_for_today,
48
+ combine_rollup_and_live_stats,
49
+ build_heatmap_from_rollup,
50
+ build_daily_chart_data_from_rollup,
51
+ calculate_day_of_week_averages_from_rollup,
52
+ calculate_difficulty_speed_from_rollup,
53
+ )
54
+
55
+
56
+ def register_stats_api_routes(app):
57
+ """Register statistics API routes with the Flask app."""
58
+
59
+ @app.route("/api/stats")
60
+ def api_stats():
61
+ """
62
+ Provides aggregated, cumulative stats for charting.
63
+ Accepts optional 'year' parameter to filter heatmap data.
64
+ Uses hybrid rollup + live approach for performance.
65
+ """
66
+ try:
67
+ # Performance timing
68
+ request_start_time = time.time()
69
+
70
+ # Get optional year filter parameter
71
+ filter_year = request.args.get("year", None)
72
+
73
+ # Get Start and End time as unix timestamp
74
+ start_timestamp = request.args.get("start", None)
75
+ end_timestamp = request.args.get("end", None)
76
+
77
+ # Convert timestamps to float if provided
78
+ start_timestamp = float(start_timestamp) if start_timestamp else None
79
+ end_timestamp = float(end_timestamp) if end_timestamp else None
80
+
81
+ # === HYBRID ROLLUP + LIVE APPROACH ===
82
+ # Convert timestamps to date strings for rollup queries
83
+ today = datetime.date.today()
84
+ today_str = today.strftime("%Y-%m-%d")
85
+
86
+ # Determine date range
87
+ if start_timestamp and end_timestamp:
88
+ start_date = datetime.date.fromtimestamp(start_timestamp)
89
+ end_date = datetime.date.fromtimestamp(end_timestamp)
90
+ start_date_str = start_date.strftime("%Y-%m-%d")
91
+ end_date_str = end_date.strftime("%Y-%m-%d")
92
+ else:
93
+ # Default: all history - get first date from rollup table
94
+ first_rollup_date = StatsRollupTable.get_first_date()
95
+
96
+ start_date_str = first_rollup_date if first_rollup_date else today_str
97
+ end_date_str = today_str
98
+
99
+ # Check if today is in the date range
100
+ today_in_range = (not end_date_str) or (end_date_str >= today_str)
101
+
102
+ # Query rollup data for historical dates (up to yesterday)
103
+ rollup_query_start = time.time()
104
+ rollup_stats = None
105
+ if start_date_str:
106
+ # Calculate yesterday
107
+ yesterday = today - datetime.timedelta(days=1)
108
+ yesterday_str = yesterday.strftime("%Y-%m-%d")
109
+
110
+ # Only query rollup if we have historical dates
111
+ if start_date_str <= yesterday_str:
112
+ rollup_end = (
113
+ min(end_date_str, yesterday_str)
114
+ if end_date_str
115
+ else yesterday_str
116
+ )
117
+
118
+ rollups = StatsRollupTable.get_date_range(
119
+ start_date_str, rollup_end
120
+ )
121
+
122
+ if rollups:
123
+ rollup_stats = aggregate_rollup_data(rollups)
124
+
125
+ # Calculate today's stats live if needed
126
+ live_stats_start = time.time()
127
+ live_stats = None
128
+ if today_in_range:
129
+ today_start = datetime.datetime.combine(
130
+ today, datetime.time.min
131
+ ).timestamp()
132
+ today_end = datetime.datetime.combine(
133
+ today, datetime.time.max
134
+ ).timestamp()
135
+ today_lines = GameLinesTable.get_lines_filtered_by_timestamp(
136
+ start=today_start, end=today_end, for_stats=True
137
+ )
138
+
139
+ if today_lines:
140
+ live_stats = calculate_live_stats_for_today(today_lines)
141
+ # Combine rollup and live stats
142
+ combined_stats = combine_rollup_and_live_stats(rollup_stats, live_stats)
143
+
144
+ # Build game mappings from GamesTable
145
+ # This replaces the expensive all_lines fetch that was used just for mapping
146
+ def build_game_mappings_from_games_table():
147
+ """
148
+ Build game_id and game_name mappings from GamesTable.
149
+ Much faster than scanning all game lines.
150
+
151
+ Returns:
152
+ tuple: (game_id_to_game_name, game_name_to_title, game_id_to_title)
153
+ """
154
+ all_games = GamesTable.all()
155
+
156
+ game_id_to_game_name = {}
157
+ game_name_to_title = {}
158
+ game_id_to_title = {}
159
+
160
+ for game in all_games:
161
+ # game_id -> obs_scene_name (game_name)
162
+ if game.id and game.obs_scene_name:
163
+ game_id_to_game_name[game.id] = game.obs_scene_name
164
+
165
+ # game_id -> title_original
166
+ if game.id and game.title_original:
167
+ game_id_to_title[game.id] = game.title_original
168
+
169
+ # game_name -> title_original (for display)
170
+ if game.obs_scene_name and game.title_original:
171
+ game_name_to_title[game.obs_scene_name] = game.title_original
172
+ elif game.obs_scene_name:
173
+ # Fallback: use obs_scene_name as title
174
+ game_name_to_title[game.obs_scene_name] = game.obs_scene_name
175
+
176
+ return game_id_to_game_name, game_name_to_title, game_id_to_title
177
+
178
+ # Build all mappings from GamesTable (FAST!)
179
+ game_id_to_game_name, game_name_to_display, game_id_to_title = (
180
+ build_game_mappings_from_games_table()
181
+ )
182
+
183
+ # Also extract titles from rollup data as fallback for games not in GamesTable
184
+ game_activity = combined_stats.get("game_activity_data", {})
185
+ for game_id, activity in game_activity.items():
186
+ title = activity.get("title", f"Game {game_id}")
187
+ if game_id not in game_id_to_title:
188
+ game_id_to_title[game_id] = title
189
+ logger.debug(
190
+ f"[TITLE_DEBUG] Using rollup title for game_id={game_id[:8]}..., title='{title}'"
191
+ )
192
+
193
+ # === PERFORMANCE OPTIMIZATION: Only fetch today's lines for live calculations ===
194
+ today_lines_for_charts = []
195
+ if today_in_range:
196
+ today_start = datetime.datetime.combine(
197
+ today, datetime.time.min
198
+ ).timestamp()
199
+ today_end = datetime.datetime.combine(
200
+ today, datetime.time.max
201
+ ).timestamp()
202
+ # IMPORTANT: Do NOT use for_stats=True here to ensure consistent character counting
203
+ # for_stats=True removes punctuation which causes discrepancies with SQL LENGTH()
204
+ today_lines_for_charts = GameLinesTable.get_lines_filtered_by_timestamp(
205
+ start=today_start, end=today_end, for_stats=True
206
+ )
207
+
208
+ cards_mined_last_30_days = {"labels": [], "totals": []}
209
+
210
+ last_rollup_date_str = StatsRollupTable.get_last_date()
211
+ if last_rollup_date_str:
212
+ cards_range_end = datetime.datetime.strptime(
213
+ last_rollup_date_str, "%Y-%m-%d"
214
+ ).date()
215
+
216
+ if end_date_str:
217
+ requested_end_date = datetime.datetime.strptime(
218
+ end_date_str, "%Y-%m-%d"
219
+ ).date()
220
+ if requested_end_date < cards_range_end:
221
+ cards_range_end = requested_end_date
222
+
223
+ requested_start_date = None
224
+ if start_date_str:
225
+ requested_start_date = datetime.datetime.strptime(
226
+ start_date_str, "%Y-%m-%d"
227
+ ).date()
228
+ if requested_start_date > cards_range_end:
229
+ cards_range_end = None
230
+
231
+ if cards_range_end:
232
+ cards_range_start = cards_range_end - datetime.timedelta(days=29)
233
+ if requested_start_date and cards_range_start < requested_start_date:
234
+ cards_range_start = requested_start_date
235
+
236
+ if cards_range_start <= cards_range_end:
237
+ cards_rollups = StatsRollupTable.get_date_range(
238
+ cards_range_start.strftime("%Y-%m-%d"),
239
+ cards_range_end.strftime("%Y-%m-%d"),
240
+ )
241
+ if cards_rollups:
242
+ cards_mined_last_30_days["labels"] = [
243
+ rollup.date for rollup in cards_rollups
244
+ ]
245
+ cards_mined_last_30_days["totals"] = [
246
+ rollup.anki_cards_created for rollup in cards_rollups
247
+ ]
248
+
249
+ # 2. Build daily_data from rollup records (FAST) + today's lines (SMALL)
250
+ # Structure: daily_data[date_str][display_name] = {'lines': N, 'chars': N}
251
+ daily_data = defaultdict(
252
+ lambda: defaultdict(lambda: {"lines": 0, "chars": 0})
253
+ )
254
+
255
+ # Process rollup data into daily_data (FAST - no database queries!)
256
+ if start_date_str:
257
+ yesterday = today - datetime.timedelta(days=1)
258
+ yesterday_str = yesterday.strftime("%Y-%m-%d")
259
+
260
+ if start_date_str <= yesterday_str:
261
+ rollup_end = (
262
+ min(end_date_str, yesterday_str)
263
+ if end_date_str
264
+ else yesterday_str
265
+ )
266
+
267
+ # Get rollup records for the date range
268
+ rollups = StatsRollupTable.get_date_range(
269
+ start_date_str, rollup_end
270
+ )
271
+
272
+ # Build daily_data directly from rollup records
273
+ for rollup in rollups:
274
+ date_str = rollup.date
275
+ if rollup.game_activity_data:
276
+ try:
277
+ # game_activity_data might already be a dict or a JSON string
278
+ if isinstance(rollup.game_activity_data, str):
279
+ game_data = json.loads(rollup.game_activity_data)
280
+ else:
281
+ game_data = rollup.game_activity_data
282
+
283
+ for game_id, activity in game_data.items():
284
+ # Trust the title from rollup data - it's already been resolved properly
285
+ # during the daily rollup process with proper fallback chain:
286
+ # 1. games_table.title_original
287
+ # 2. game_name (OBS scene name)
288
+ # 3. Shortened UUID as last resort
289
+ display_name = activity.get(
290
+ "title", f"Game {game_id[:8]}"
291
+ )
292
+
293
+ daily_data[date_str][display_name]["lines"] = (
294
+ activity.get("lines", 0)
295
+ )
296
+ daily_data[date_str][display_name]["chars"] = (
297
+ activity.get("chars", 0)
298
+ )
299
+ except (json.JSONDecodeError, KeyError, TypeError) as e:
300
+ logger.warning(
301
+ f"Error parsing rollup data for {date_str}: {e}"
302
+ )
303
+ continue
304
+
305
+ # Add today's lines to daily_data using our pre-built mapping
306
+ for line in today_lines_for_charts:
307
+ day_str = datetime.date.fromtimestamp(float(line.timestamp)).strftime(
308
+ "%Y-%m-%d"
309
+ )
310
+ game_name = line.game_name or "Unknown Game"
311
+ # Use pre-built mapping instead of querying GamesTable for each line
312
+ display_name = game_name_to_display.get(game_name, game_name)
313
+ daily_data[day_str][display_name]["lines"] += 1
314
+ daily_data[day_str][display_name]["chars"] += (
315
+ len(line.line_text) if line.line_text else 0
316
+ )
317
+
318
+ # GRACEFUL FALLBACK: If no daily_data from rollup, calculate from game_lines directly
319
+ if not daily_data:
320
+ logger.warning(f"No daily_data from rollup! Falling back to live calculation from game_lines table.")
321
+ logger.info("This usually happens after a version upgrade. The rollup table will be populated automatically.")
322
+
323
+ # Fetch all lines for the date range and calculate stats directly
324
+ if start_timestamp and end_timestamp:
325
+ fallback_lines = GameLinesTable.get_lines_filtered_by_timestamp(
326
+ start=start_timestamp, end=end_timestamp, for_stats=True
327
+ )
328
+
329
+ if fallback_lines:
330
+ logger.info(f"Fallback: Processing {len(fallback_lines)} lines directly from game_lines table")
331
+ for line in fallback_lines:
332
+ day_str = datetime.date.fromtimestamp(float(line.timestamp)).strftime("%Y-%m-%d")
333
+ game_name = line.game_name or "Unknown Game"
334
+ display_name = game_name_to_display.get(game_name, game_name)
335
+ daily_data[day_str][display_name]["lines"] += 1
336
+ daily_data[day_str][display_name]["chars"] += (
337
+ len(line.line_text) if line.line_text else 0
338
+ )
339
+
340
+ # If still no data after fallback, return empty response
341
+ if not daily_data:
342
+ logger.warning(f"No data found even after fallback. Date range: {start_date_str} to {end_date_str}")
343
+ return jsonify({"labels": [], "datasets": []})
344
+
345
+ # 3. Create cumulative datasets for Chart.js
346
+ sorted_days = sorted(daily_data.keys())
347
+ # Get all unique display names from daily_data
348
+ all_display_names = set()
349
+ for day_data in daily_data.values():
350
+ all_display_names.update(day_data.keys())
351
+ display_names = sorted(all_display_names)
352
+
353
+ # Keep track of the running total for each metric for each game
354
+ cumulative_totals = defaultdict(lambda: {"lines": 0, "chars": 0})
355
+
356
+ # Structure for final data: final_data[display_name][metric] = [day1_val, day2_val, ...]
357
+ final_data = defaultdict(lambda: defaultdict(list))
358
+
359
+ for day in sorted_days:
360
+ for display_name in display_names:
361
+ # Add the day's total to the cumulative total
362
+ cumulative_totals[display_name]["lines"] += daily_data[day][
363
+ display_name
364
+ ]["lines"]
365
+ cumulative_totals[display_name]["chars"] += daily_data[day][
366
+ display_name
367
+ ]["chars"]
368
+
369
+ # Append the new cumulative total to the list for that day
370
+ final_data[display_name]["lines"].append(
371
+ cumulative_totals[display_name]["lines"]
372
+ )
373
+ final_data[display_name]["chars"].append(
374
+ cumulative_totals[display_name]["chars"]
375
+ )
376
+
377
+ # 4. Format into Chart.js dataset structure
378
+ try:
379
+ datasets = []
380
+ # A simple color palette for the chart lines
381
+ colors = [
382
+ "#3498db",
383
+ "#e74c3c",
384
+ "#2ecc71",
385
+ "#f1c40f",
386
+ "#9b59b6",
387
+ "#1abc9c",
388
+ "#e67e22",
389
+ ]
390
+
391
+ for i, display_name in enumerate(display_names):
392
+ color = colors[i % len(colors)]
393
+
394
+ datasets.append(
395
+ {
396
+ "label": f"{display_name}",
397
+ "data": final_data[display_name]["lines"],
398
+ "borderColor": color,
399
+ "backgroundColor": f"{color}33", # Semi-transparent for fill
400
+ "fill": False,
401
+ "tension": 0.1,
402
+ "for": "Lines Received",
403
+ }
404
+ )
405
+ datasets.append(
406
+ {
407
+ "label": f"{display_name}",
408
+ "data": final_data[display_name]["chars"],
409
+ "borderColor": color,
410
+ "backgroundColor": f"{color}33",
411
+ "fill": False,
412
+ "tension": 0.1,
413
+ "hidden": True, # Hide by default to not clutter the chart
414
+ "for": "Characters Read",
415
+ }
416
+ )
417
+ except Exception as e:
418
+ logger.error(f"Error formatting Chart.js datasets: {e}")
419
+ return jsonify({"error": "Failed to format chart data"}), 500
420
+
421
+ # ========================================================================
422
+ # CHART DATA CALCULATION STRATEGY
423
+ # ========================================================================
424
+ # This section calculates data for various charts. Charts are categorized by
425
+ # whether they need today's live data or only historical rollup data:
426
+ #
427
+ # CHARTS USING LIVE DATA (combined_stats includes today):
428
+ # - Lines/Characters Over Time (cumulative charts)
429
+ # - Peak Statistics (if today sets new records)
430
+ # - Heatmaps (to show today's activity)
431
+ # - Kanji Grid (to include today's kanji)
432
+ # - Current Game Stats
433
+ # - Top 5 charts (if today qualifies for top rankings)
434
+ #
435
+ # CHARTS USING HISTORICAL DATA ONLY (rollup_stats, excludes today):
436
+ # - Per-Game Totals (chars, time, speed per game)
437
+ # - Day of Week Activity (pure historical patterns)
438
+ # - Average Hours by Day (pure historical averages)
439
+ # - Hourly Activity Pattern (historical average by hour)
440
+ # - Hourly Reading Speed (historical average by hour)
441
+ # - Reading Speed by Difficulty (historical averages)
442
+ # - Game Type Distribution (based on GamesTable, not activity)
443
+ #
444
+ # Rationale: Average/pattern charts should show stable historical trends
445
+ # without being skewed by today's incomplete data. Cumulative charts need
446
+ # today's data to show current progress. Per-game charts update only after
447
+ # the daily rollup runs, providing consistent snapshots of game progress.
448
+ # ========================================================================
449
+
450
+ # 5. Calculate additional chart data from combined_stats (no all_lines needed!)
451
+ try:
452
+ # Use kanji data from combined stats (already aggregated from rollup + today)
453
+ kanji_freq_dict = combined_stats.get("kanji_frequency_data", {})
454
+ if kanji_freq_dict:
455
+ # Convert to the format expected by frontend (with colors)
456
+ from GameSentenceMiner.web.stats import get_gradient_color
457
+
458
+ max_frequency = (
459
+ max(kanji_freq_dict.values()) if kanji_freq_dict else 0
460
+ )
461
+
462
+ # Sort kanji by frequency (most frequent first)
463
+ sorted_kanji = sorted(
464
+ kanji_freq_dict.items(), key=lambda x: x[1], reverse=True
465
+ )
466
+
467
+ kanji_data = []
468
+ for kanji, count in sorted_kanji:
469
+ color = get_gradient_color(count, max_frequency)
470
+ kanji_data.append(
471
+ {"kanji": kanji, "frequency": count, "color": color}
472
+ )
473
+
474
+ kanji_grid_data = {
475
+ "kanji_data": kanji_data,
476
+ "unique_count": len(sorted_kanji),
477
+ "max_frequency": max_frequency,
478
+ }
479
+ else:
480
+ # No kanji data available
481
+ kanji_grid_data = {
482
+ "kanji_data": [],
483
+ "unique_count": 0,
484
+ "max_frequency": 0,
485
+ }
486
+ except Exception as e:
487
+ logger.error(f"Error calculating kanji frequency: {e}")
488
+ kanji_grid_data = {
489
+ "kanji_data": [],
490
+ "unique_count": 0,
491
+ "max_frequency": 0,
492
+ }
493
+
494
+ try:
495
+ # Use rollup-based heatmap for historical data (FAST!)
496
+ if start_date_str:
497
+ yesterday = today - datetime.timedelta(days=1)
498
+ yesterday_str = yesterday.strftime("%Y-%m-%d")
499
+
500
+ if start_date_str <= yesterday_str:
501
+ rollup_end = (
502
+ min(end_date_str, yesterday_str)
503
+ if end_date_str
504
+ else yesterday_str
505
+ )
506
+ rollups_for_heatmap = StatsRollupTable.get_date_range(
507
+ start_date_str, rollup_end
508
+ )
509
+ heatmap_data = build_heatmap_from_rollup(
510
+ rollups_for_heatmap, filter_year
511
+ )
512
+
513
+ # Add today's data to heatmap if needed
514
+ if today_in_range and today_lines_for_charts:
515
+ from GameSentenceMiner.web.stats import (
516
+ calculate_heatmap_data,
517
+ )
518
+
519
+ today_heatmap = calculate_heatmap_data(
520
+ today_lines_for_charts, filter_year
521
+ )
522
+ # Merge today's data into heatmap
523
+ for year, dates in today_heatmap.items():
524
+ if year not in heatmap_data:
525
+ heatmap_data[year] = {}
526
+ for date, chars in dates.items():
527
+ heatmap_data[year][date] = (
528
+ heatmap_data[year].get(date, 0) + chars
529
+ )
530
+ else:
531
+ # Only today's data
532
+ from GameSentenceMiner.web.stats import calculate_heatmap_data
533
+
534
+ heatmap_data = calculate_heatmap_data(
535
+ today_lines_for_charts, filter_year
536
+ )
537
+ else:
538
+ # No date range specified, use today only
539
+ from GameSentenceMiner.web.stats import calculate_heatmap_data
540
+
541
+ heatmap_data = calculate_heatmap_data(
542
+ today_lines_for_charts, filter_year
543
+ )
544
+ except Exception as e:
545
+ logger.error(f"Error calculating heatmap data: {e}")
546
+ heatmap_data = {}
547
+
548
+ # Extract per-game stats from ROLLUP ONLY (no live data)
549
+ try:
550
+ # Build per-game stats from rollup game_activity_data only
551
+ game_activity_data = rollup_stats.get("game_activity_data", {}) if rollup_stats else {}
552
+
553
+ # Sort games by first appearance (use game_id order from rollup)
554
+ game_list = []
555
+ for game_id, activity in game_activity_data.items():
556
+ title = activity.get("title", f"Game {game_id}")
557
+ # Use title from our mapping if available
558
+ if game_id in game_id_to_title:
559
+ title = game_id_to_title[game_id]
560
+
561
+ game_list.append(
562
+ {
563
+ "game_id": game_id,
564
+ "title": title,
565
+ "chars": activity.get("chars", 0),
566
+ "time": activity.get("time", 0),
567
+ "lines": activity.get("lines", 0),
568
+ }
569
+ )
570
+
571
+ # Total chars per game
572
+ total_chars_data = {
573
+ "labels": [g["title"] for g in game_list if g["chars"] > 0],
574
+ "totals": [g["chars"] for g in game_list if g["chars"] > 0],
575
+ }
576
+
577
+ # Reading time per game (convert seconds to hours)
578
+ reading_time_data = {
579
+ "labels": [g["title"] for g in game_list if g["time"] > 0],
580
+ "totals": [
581
+ round(g["time"] / 3600, 2) for g in game_list if g["time"] > 0
582
+ ],
583
+ }
584
+
585
+ # Reading speed per game (chars/hour)
586
+ reading_speed_per_game_data = {"labels": [], "totals": []}
587
+ for g in game_list:
588
+ if g["time"] > 0 and g["chars"] > 0:
589
+ hours = g["time"] / 3600
590
+ speed = round(g["chars"] / hours, 0)
591
+ reading_speed_per_game_data["labels"].append(g["title"])
592
+ reading_speed_per_game_data["totals"].append(speed)
593
+
594
+ except Exception as e:
595
+ logger.error(
596
+ f"Error extracting per-game stats from rollup_stats: {e}"
597
+ )
598
+ total_chars_data = {"labels": [], "totals": []}
599
+ reading_time_data = {"labels": [], "totals": []}
600
+ reading_speed_per_game_data = {"labels": [], "totals": []}
601
+
602
+ # 6. Calculate dashboard statistics
603
+ try:
604
+ # For current game stats, we need to fetch only the current game's lines
605
+ # First, get the most recent line to determine current game
606
+ if today_lines_for_charts:
607
+ sorted_today = sorted(
608
+ today_lines_for_charts, key=lambda line: float(line.timestamp)
609
+ )
610
+ current_game_line = sorted_today[-1]
611
+ current_game_name = current_game_line.game_name or "Unknown Game"
612
+
613
+ # Fetch only lines for the current game (much faster than all_lines!)
614
+ current_game_lines = [
615
+ line
616
+ for line in today_lines_for_charts
617
+ if (line.game_name or "Unknown Game") == current_game_name
618
+ ]
619
+
620
+ # Fetch historical data for current game (EXCLUDING today to avoid double-counting)
621
+ # Calculate today's start timestamp to use as upper bound
622
+ today_start_ts = datetime.datetime.combine(
623
+ today, datetime.time.min
624
+ ).timestamp()
625
+
626
+ if start_timestamp and end_timestamp:
627
+ # If timestamps provided, filter by date range but exclude today
628
+ historical_current_game = GameLinesTable._db.fetchall(
629
+ f"SELECT * FROM {GameLinesTable._table} WHERE game_name=? AND timestamp >= ? AND timestamp < ?",
630
+ (current_game_name, start_timestamp, today_start_ts),
631
+ )
632
+ else:
633
+ # If no timestamps provided, fetch all historical data BEFORE today
634
+ historical_current_game = GameLinesTable._db.fetchall(
635
+ f"SELECT * FROM {GameLinesTable._table} WHERE game_name=? AND timestamp < ?",
636
+ (current_game_name, today_start_ts),
637
+ )
638
+
639
+ # Convert historical rows to GameLinesTable objects (without for_stats cleaning)
640
+ historical_lines = [
641
+ GameLinesTable.from_row(row, clean_columns=['line_text'])
642
+ for row in historical_current_game
643
+ ]
644
+
645
+ current_game_lines.extend(historical_lines)
646
+
647
+ current_game_stats = calculate_current_game_stats(
648
+ current_game_lines
649
+ )
650
+ else:
651
+ # No lines today - fetch the most recent game from all data
652
+
653
+ most_recent_line = GameLinesTable._db.fetchone(
654
+ f"SELECT * FROM {GameLinesTable._table} ORDER BY timestamp DESC LIMIT 1"
655
+ )
656
+
657
+ if most_recent_line:
658
+ most_recent_game_line = GameLinesTable.from_row(
659
+ most_recent_line
660
+ )
661
+ current_game_name = (
662
+ most_recent_game_line.game_name or "Unknown Game"
663
+ )
664
+
665
+ # Fetch all lines for this game
666
+ if start_timestamp and end_timestamp:
667
+ # If timestamps provided, filter by date range
668
+ current_game_lines_rows = GameLinesTable._db.fetchall(
669
+ f"SELECT * FROM {GameLinesTable._table} WHERE game_name=? AND timestamp >= ? AND timestamp <= ?",
670
+ (current_game_name, start_timestamp, end_timestamp),
671
+ )
672
+ else:
673
+ # If no timestamps provided, fetch all data
674
+ current_game_lines_rows = GameLinesTable._db.fetchall(
675
+ f"SELECT * FROM {GameLinesTable._table} WHERE game_name=?",
676
+ (current_game_name,),
677
+ )
678
+
679
+ current_game_lines = [
680
+ GameLinesTable.from_row(row)
681
+ for row in current_game_lines_rows
682
+ ]
683
+ current_game_stats = calculate_current_game_stats(
684
+ current_game_lines
685
+ )
686
+ else:
687
+ current_game_stats = {}
688
+ except Exception as e:
689
+ logger.error(f"Error calculating current game stats: {e}")
690
+ current_game_stats = {}
691
+
692
+ try:
693
+ # Count completed games from GamesTable (using completed boolean)
694
+ completed_games_count = len(GamesTable.get_all_completed())
695
+
696
+ # Build all_games_stats from combined_stats (no all_lines needed!)
697
+ all_games_stats = {
698
+ "total_characters": combined_stats.get("total_characters", 0),
699
+ "total_characters_formatted": format_large_number(
700
+ combined_stats.get("total_characters", 0)
701
+ ),
702
+ "total_sentences": combined_stats.get("total_lines", 0),
703
+ "total_time_hours": combined_stats.get(
704
+ "total_reading_time_seconds", 0
705
+ )
706
+ / 3600,
707
+ "total_time_formatted": format_time_human_readable(
708
+ combined_stats.get("total_reading_time_seconds", 0) / 3600
709
+ ),
710
+ "reading_speed": int(
711
+ combined_stats.get("average_reading_speed_chars_per_hour", 0)
712
+ ),
713
+ "reading_speed_formatted": format_large_number(
714
+ int(
715
+ combined_stats.get(
716
+ "average_reading_speed_chars_per_hour", 0
717
+ )
718
+ )
719
+ ),
720
+ "sessions": combined_stats.get("total_sessions", 0),
721
+ "completed_games": completed_games_count,
722
+ "current_streak": 0, # TODO: Calculate from rollup data
723
+ "avg_daily_time_hours": 0, # TODO: Calculate from rollup data
724
+ "avg_daily_time_formatted": "0h",
725
+ }
726
+
727
+ # Get first_date from rollup table
728
+ first_rollup_date = StatsRollupTable.get_first_date()
729
+
730
+ if first_rollup_date:
731
+ all_games_stats["first_date"] = first_rollup_date
732
+ else:
733
+ # Fallback to today if no rollup data
734
+ fallback_date = datetime.date.today().strftime("%Y-%m-%d")
735
+ all_games_stats["first_date"] = fallback_date
736
+
737
+ # Get last_date from today or end_timestamp
738
+ if end_timestamp:
739
+ all_games_stats["last_date"] = datetime.date.fromtimestamp(
740
+ end_timestamp
741
+ ).strftime("%Y-%m-%d")
742
+ else:
743
+ all_games_stats["last_date"] = datetime.date.today().strftime(
744
+ "%Y-%m-%d"
745
+ )
746
+
747
+ except Exception as e:
748
+ logger.error(f"Error calculating all games stats: {e}")
749
+ all_games_stats = {}
750
+
751
+ # 7. Build lightweight allLinesData from rollup records for heatmap "Avg Daily Time" calculation
752
+ # Frontend needs reading time data per day to calculate average daily reading time
753
+ all_lines_data = []
754
+ if start_date_str:
755
+ yesterday = today - datetime.timedelta(days=1)
756
+ yesterday_str = yesterday.strftime("%Y-%m-%d")
757
+ if start_date_str <= yesterday_str:
758
+ rollup_end = (
759
+ min(end_date_str, yesterday_str)
760
+ if end_date_str
761
+ else yesterday_str
762
+ )
763
+ rollups_for_lines = StatsRollupTable.get_date_range(
764
+ start_date_str, rollup_end
765
+ )
766
+ for rollup in rollups_for_lines:
767
+ # Convert date string to timestamp for frontend compatibility
768
+ date_obj = datetime.datetime.strptime(rollup.date, "%Y-%m-%d")
769
+ all_lines_data.append(
770
+ {
771
+ "timestamp": date_obj.timestamp(),
772
+ "date": rollup.date,
773
+ "reading_time_seconds": rollup.total_reading_time_seconds, # Add actual reading time
774
+ }
775
+ )
776
+
777
+ # Add today's lines if in range
778
+ if today_in_range and today_lines_for_charts:
779
+ for line in today_lines_for_charts:
780
+ all_lines_data.append(
781
+ {
782
+ "timestamp": float(line.timestamp),
783
+ "date": datetime.date.fromtimestamp(
784
+ float(line.timestamp)
785
+ ).strftime("%Y-%m-%d"),
786
+ }
787
+ )
788
+
789
+ # 8. Get hourly activity pattern from ROLLUP ONLY (no live data)
790
+ try:
791
+ # Convert dict to list format expected by frontend
792
+ hourly_dict = rollup_stats.get("hourly_activity_data", {}) if rollup_stats else {}
793
+ hourly_activity_data = [0] * 24
794
+ for hour_str, chars in hourly_dict.items():
795
+ try:
796
+ hour_int = int(hour_str)
797
+ if 0 <= hour_int < 24:
798
+ hourly_activity_data[hour_int] = chars
799
+ except (ValueError, TypeError):
800
+ logger.warning(
801
+ f"Invalid hour key in hourly_activity_data: {hour_str}"
802
+ )
803
+ except Exception as e:
804
+ logger.error(f"Error processing hourly activity: {e}")
805
+ hourly_activity_data = [0] * 24
806
+
807
+ # 8.5. Get hourly reading speed pattern from ROLLUP ONLY (no live data)
808
+ try:
809
+ # Convert dict to list format expected by frontend
810
+ speed_dict = rollup_stats.get("hourly_reading_speed_data", {}) if rollup_stats else {}
811
+ hourly_reading_speed_data = [0] * 24
812
+ for hour_str, speed in speed_dict.items():
813
+ try:
814
+ hour_int = int(hour_str)
815
+ if 0 <= hour_int < 24:
816
+ hourly_reading_speed_data[hour_int] = speed
817
+ except (ValueError, TypeError):
818
+ logger.warning(
819
+ f"Invalid hour key in hourly_reading_speed_data: {hour_str}"
820
+ )
821
+ except Exception as e:
822
+ logger.error(f"Error processing hourly reading speed: {e}")
823
+ hourly_reading_speed_data = [0] * 24
824
+
825
+ # 9. Calculate peak statistics from rollup data (actual daily peaks)
826
+ try:
827
+ # Calculate true daily peaks by finding max values across all rollup records
828
+ max_daily_chars = 0
829
+ max_daily_hours = 0.0
830
+
831
+ # Check rollup data for historical peaks
832
+ if rollup_stats and start_date_str:
833
+ yesterday = today - datetime.timedelta(days=1)
834
+ yesterday_str = yesterday.strftime("%Y-%m-%d")
835
+
836
+ if start_date_str <= yesterday_str:
837
+ rollup_end = (
838
+ min(end_date_str, yesterday_str)
839
+ if end_date_str
840
+ else yesterday_str
841
+ )
842
+ rollups_for_peaks = StatsRollupTable.get_date_range(
843
+ start_date_str, rollup_end
844
+ )
845
+
846
+ # Find maximum daily values across all rollup records
847
+ for rollup in rollups_for_peaks:
848
+ if rollup.total_characters > max_daily_chars:
849
+ max_daily_chars = rollup.total_characters
850
+
851
+ daily_hours = rollup.total_reading_time_seconds / 3600
852
+ if daily_hours > max_daily_hours:
853
+ max_daily_hours = daily_hours
854
+
855
+ # Check today's live data to see if it sets a new record
856
+ if live_stats:
857
+ today_chars = live_stats.get("total_characters", 0)
858
+ today_hours = live_stats.get("total_reading_time_seconds", 0) / 3600
859
+
860
+ if today_chars > max_daily_chars:
861
+ max_daily_chars = today_chars
862
+ if today_hours > max_daily_hours:
863
+ max_daily_hours = today_hours
864
+
865
+ peak_daily_stats = {
866
+ "max_daily_chars": max_daily_chars,
867
+ "max_daily_hours": max_daily_hours,
868
+ }
869
+
870
+ except Exception as e:
871
+ logger.error(f"Error calculating peak daily stats: {e}")
872
+ peak_daily_stats = {"max_daily_chars": 0, "max_daily_hours": 0.0}
873
+
874
+ try:
875
+ peak_session_stats = {
876
+ "longest_session_hours": combined_stats.get(
877
+ "longest_session_seconds", 0.0
878
+ )
879
+ / 3600,
880
+ "max_session_chars": combined_stats.get("max_chars_in_session", 0),
881
+ }
882
+ except Exception as e:
883
+ logger.error(f"Error calculating peak session stats: {e}")
884
+ peak_session_stats = {
885
+ "longest_session_hours": 0.0,
886
+ "max_session_chars": 0,
887
+ }
888
+
889
+ # 10. Calculate game milestones (already optimized - uses GamesTable, not all_lines)
890
+ try:
891
+ game_milestones = (
892
+ calculate_game_milestones()
893
+ ) # No all_lines parameter needed
894
+ except Exception as e:
895
+ logger.error(f"Error calculating game milestones: {e}")
896
+ game_milestones = None
897
+
898
+ # 11. Calculate reading speed heatmap data
899
+ try:
900
+ # Use rollup-based approach similar to regular heatmap
901
+ reading_speed_heatmap_data = {}
902
+ max_reading_speed = 0
903
+
904
+ if start_date_str:
905
+ yesterday = today - datetime.timedelta(days=1)
906
+ yesterday_str = yesterday.strftime("%Y-%m-%d")
907
+
908
+ if start_date_str <= yesterday_str:
909
+ rollup_end = (
910
+ min(end_date_str, yesterday_str)
911
+ if end_date_str
912
+ else yesterday_str
913
+ )
914
+ rollups_for_speed = StatsRollupTable.get_date_range(
915
+ start_date_str, rollup_end
916
+ )
917
+
918
+ # Build reading speed heatmap from rollup data
919
+ for rollup in rollups_for_speed:
920
+ if rollup.total_reading_time_seconds > 0 and rollup.total_characters > 0:
921
+ reading_time_hours = rollup.total_reading_time_seconds / 3600
922
+ speed = int(rollup.total_characters / reading_time_hours)
923
+
924
+ year = rollup.date.split("-")[0]
925
+ if year not in reading_speed_heatmap_data:
926
+ reading_speed_heatmap_data[year] = {}
927
+ reading_speed_heatmap_data[year][rollup.date] = speed
928
+ max_reading_speed = max(max_reading_speed, speed)
929
+
930
+ # Add today's data to reading speed heatmap if needed
931
+ if today_in_range and today_lines_for_charts:
932
+ today_speed_data, today_max_speed = calculate_reading_speed_heatmap_data(
933
+ today_lines_for_charts, filter_year
934
+ )
935
+ # Merge today's data
936
+ for year, dates in today_speed_data.items():
937
+ if year not in reading_speed_heatmap_data:
938
+ reading_speed_heatmap_data[year] = {}
939
+ for date, speed in dates.items():
940
+ reading_speed_heatmap_data[year][date] = speed
941
+ max_reading_speed = max(max_reading_speed, speed)
942
+ else:
943
+ # Only today's data
944
+ reading_speed_heatmap_data, max_reading_speed = calculate_reading_speed_heatmap_data(
945
+ today_lines_for_charts, filter_year
946
+ )
947
+ else:
948
+ # No date range specified, use today only
949
+ reading_speed_heatmap_data, max_reading_speed = calculate_reading_speed_heatmap_data(
950
+ today_lines_for_charts, filter_year
951
+ )
952
+ except Exception as e:
953
+ logger.error(f"Error calculating reading speed heatmap data: {e}")
954
+ reading_speed_heatmap_data = {}
955
+ max_reading_speed = 0
956
+
957
+ # 12. Calculate day of week activity data (HISTORICAL AVERAGES ONLY)
958
+ # NOTE: This chart shows pure historical patterns and should NOT include today's incomplete data.
959
+ # Today's data is already included in cumulative charts (Lines/Chars Over Time, Heatmaps, etc.)
960
+ try:
961
+ # Use pre-computed function from rollup_stats for historical averages
962
+ if start_date_str:
963
+ yesterday = today - datetime.timedelta(days=1)
964
+ yesterday_str = yesterday.strftime("%Y-%m-%d")
965
+
966
+ if start_date_str <= yesterday_str:
967
+ rollup_end = (
968
+ min(end_date_str, yesterday_str)
969
+ if end_date_str
970
+ else yesterday_str
971
+ )
972
+ rollups_for_dow = StatsRollupTable.get_date_range(
973
+ start_date_str, rollup_end
974
+ )
975
+
976
+ # PRE-COMPUTE from rollup data (historical averages only)
977
+ day_of_week_data = calculate_day_of_week_averages_from_rollup(rollups_for_dow)
978
+ else:
979
+ # Only today's data requested - return empty for historical averages
980
+ day_of_week_data = {
981
+ "chars": [0] * 7,
982
+ "hours": [0] * 7,
983
+ "counts": [0] * 7,
984
+ "avg_hours": [0] * 7
985
+ }
986
+ else:
987
+ day_of_week_data = {
988
+ "chars": [0] * 7,
989
+ "hours": [0] * 7,
990
+ "counts": [0] * 7,
991
+ "avg_hours": [0] * 7
992
+ }
993
+
994
+ # REMOVED: Do NOT add today's data to historical averages
995
+ # Today's incomplete data would skew the historical patterns shown in:
996
+ # - Day of Week Activity chart
997
+ # - Average Hours by Day chart
998
+
999
+ except Exception as e:
1000
+ logger.error(f"Error calculating day of week activity: {e}")
1001
+ day_of_week_data = {"chars": [0] * 7, "hours": [0] * 7, "counts": [0] * 7, "avg_hours": [0] * 7}
1002
+
1003
+ # 13. Calculate reading speed by difficulty data (ROLLUP ONLY - no live data)
1004
+ try:
1005
+ # Use pre-computed function from rollup_stats with rollup data only
1006
+ difficulty_speed_data = calculate_difficulty_speed_from_rollup(rollup_stats if rollup_stats else {})
1007
+ except Exception as e:
1008
+ logger.error(f"Error calculating reading speed by difficulty: {e}")
1009
+ difficulty_speed_data = {"labels": [], "speeds": []}
1010
+
1011
+ # 14. Calculate game type distribution data (only for games the user has played)
1012
+ try:
1013
+ game_type_data = {"labels": [], "counts": []}
1014
+
1015
+ # Get game IDs that have been played (from rollup stats)
1016
+ game_activity_data = rollup_stats.get("game_activity_data", {}) if rollup_stats else {}
1017
+ played_game_ids = set(game_activity_data.keys())
1018
+
1019
+ # Count types only for games that have been played
1020
+ type_counts = {}
1021
+
1022
+ for game_id in played_game_ids:
1023
+ game = GamesTable.get(game_id)
1024
+ if game and game.type:
1025
+ game_type = game.type
1026
+ type_counts[game_type] = type_counts.get(game_type, 0) + 1
1027
+
1028
+ # Sort by count descending
1029
+ sorted_types = sorted(type_counts.items(), key=lambda x: x[1], reverse=True)
1030
+
1031
+ for game_type, count in sorted_types:
1032
+ game_type_data["labels"].append(game_type)
1033
+ game_type_data["counts"].append(count)
1034
+
1035
+ except Exception as e:
1036
+ logger.error(f"Error calculating game type distribution: {e}")
1037
+ game_type_data = {"labels": [], "counts": []}
1038
+
1039
+
1040
+ # Log total request time
1041
+ total_time = time.time() - request_start_time
1042
+
1043
+ return jsonify(
1044
+ {
1045
+ "labels": sorted_days,
1046
+ "datasets": datasets,
1047
+ "cardsMinedLast30Days": cards_mined_last_30_days,
1048
+ "kanjiGridData": kanji_grid_data,
1049
+ "heatmapData": heatmap_data,
1050
+ "totalCharsPerGame": total_chars_data,
1051
+ "readingTimePerGame": reading_time_data,
1052
+ "readingSpeedPerGame": reading_speed_per_game_data,
1053
+ "currentGameStats": current_game_stats,
1054
+ "allGamesStats": all_games_stats,
1055
+ "allLinesData": all_lines_data,
1056
+ "hourlyActivityData": hourly_activity_data,
1057
+ "hourlyReadingSpeedData": hourly_reading_speed_data,
1058
+ "peakDailyStats": peak_daily_stats,
1059
+ "peakSessionStats": peak_session_stats,
1060
+ "gameMilestones": game_milestones,
1061
+ "readingSpeedHeatmapData": reading_speed_heatmap_data,
1062
+ "maxReadingSpeed": max_reading_speed,
1063
+ "dayOfWeekData": day_of_week_data,
1064
+ "difficultySpeedData": difficulty_speed_data,
1065
+ "gameTypeData": game_type_data,
1066
+ }
1067
+ )
1068
+
1069
+ except Exception as e:
1070
+ logger.error(f"Unexpected error in api_stats: {e}", exc_info=True)
1071
+ return jsonify({"error": "Failed to generate statistics"}), 500
1072
+
1073
+ @app.route("/api/mining_heatmap")
1074
+ def api_mining_heatmap():
1075
+ """
1076
+ Provides mining heatmap data showing daily mining activity.
1077
+ Counts lines where screenshot_in_anki OR audio_in_anki is not empty.
1078
+ Accepts optional 'start' and 'end' timestamp parameters for filtering.
1079
+ """
1080
+ try:
1081
+ # Get optional timestamp filter parameters
1082
+ start_timestamp = request.args.get("start", None)
1083
+ end_timestamp = request.args.get("end", None)
1084
+
1085
+ # Convert timestamps to float if provided
1086
+ start_timestamp = float(start_timestamp) if start_timestamp else None
1087
+ end_timestamp = float(end_timestamp) if end_timestamp else None
1088
+
1089
+ # Fetch lines filtered by timestamp
1090
+ all_lines = GameLinesTable.get_lines_filtered_by_timestamp(
1091
+ start=start_timestamp, end=end_timestamp
1092
+ )
1093
+
1094
+ if not all_lines:
1095
+ return jsonify({}), 200
1096
+
1097
+ # Calculate mining heatmap data
1098
+ try:
1099
+ heatmap_data = calculate_mining_heatmap_data(all_lines)
1100
+ except Exception as e:
1101
+ logger.error(f"Error calculating mining heatmap data: {e}")
1102
+ return jsonify({"error": "Failed to calculate mining heatmap"}), 500
1103
+
1104
+ return jsonify(heatmap_data), 200
1105
+
1106
+ except Exception as e:
1107
+ logger.error(f"Unexpected error in api_mining_heatmap: {e}", exc_info=True)
1108
+ return jsonify({"error": "Failed to generate mining heatmap"}), 500
1109
+
1110
+ @app.route("/api/goals-today", methods=["GET"])
1111
+ def api_goals_today():
1112
+ """
1113
+ Calculate daily requirements and current progress for today based on goal target dates.
1114
+ Returns what needs to be accomplished today to stay on track.
1115
+ Uses hybrid rollup + live approach for performance.
1116
+ """
1117
+ try:
1118
+ config = get_stats_config()
1119
+ today = datetime.date.today()
1120
+ today_str = today.strftime("%Y-%m-%d")
1121
+
1122
+ # === HYBRID ROLLUP + LIVE APPROACH ===
1123
+ # Get rollup data up to yesterday
1124
+ yesterday = today - datetime.timedelta(days=1)
1125
+ yesterday_str = yesterday.strftime("%Y-%m-%d")
1126
+
1127
+ # Get first date from rollup table
1128
+ first_rollup_date = StatsRollupTable.get_first_date()
1129
+ if not first_rollup_date:
1130
+ # No rollup data, return empty response
1131
+ return jsonify(
1132
+ {
1133
+ "hours": {"required": 0, "progress": 0, "has_target": False},
1134
+ "characters": {
1135
+ "required": 0,
1136
+ "progress": 0,
1137
+ "has_target": False,
1138
+ },
1139
+ "games": {"required": 0, "progress": 0, "has_target": False},
1140
+ }
1141
+ ), 200
1142
+
1143
+ # Query rollup data for all historical dates
1144
+ rollups = StatsRollupTable.get_date_range(first_rollup_date, yesterday_str)
1145
+ rollup_stats = aggregate_rollup_data(rollups) if rollups else None
1146
+
1147
+ # Get today's lines for live calculation
1148
+ today_start = datetime.datetime.combine(
1149
+ today, datetime.time.min
1150
+ ).timestamp()
1151
+ today_end = datetime.datetime.combine(today, datetime.time.max).timestamp()
1152
+ today_lines = GameLinesTable.get_lines_filtered_by_timestamp(
1153
+ start=today_start, end=today_end, for_stats=True
1154
+ )
1155
+
1156
+ # Calculate today's live stats
1157
+ live_stats = None
1158
+ if today_lines:
1159
+ live_stats = calculate_live_stats_for_today(today_lines)
1160
+
1161
+ # Combine rollup and live stats for total progress
1162
+ combined_stats = combine_rollup_and_live_stats(rollup_stats, live_stats)
1163
+
1164
+ # Extract totals from combined stats
1165
+ total_hours = combined_stats.get("total_reading_time_seconds", 0) / 3600
1166
+ total_characters = combined_stats.get("total_characters", 0)
1167
+ total_games = combined_stats.get("unique_games_played", 0)
1168
+
1169
+ # Calculate today's progress from live stats
1170
+ if live_stats:
1171
+ today_time_seconds = live_stats.get("total_reading_time_seconds", 0)
1172
+ today_hours = today_time_seconds / 3600
1173
+ today_characters = live_stats.get("total_characters", 0)
1174
+ else:
1175
+ today_hours = 0
1176
+ today_characters = 0
1177
+
1178
+ # Calculate today's cards mined (lines with audio_in_anki OR screenshot_in_anki)
1179
+ today_cards_mined = 0
1180
+ if today_lines:
1181
+ for line in today_lines:
1182
+ # Count if either audio_in_anki or screenshot_in_anki is not empty
1183
+ if (line.audio_in_anki and line.audio_in_anki.strip()) or \
1184
+ (line.screenshot_in_anki and line.screenshot_in_anki.strip()):
1185
+ today_cards_mined += 1
1186
+
1187
+ result = {}
1188
+
1189
+ # Calculate hours requirement
1190
+ if config.reading_hours_target_date:
1191
+ try:
1192
+ target_date = datetime.datetime.strptime(
1193
+ config.reading_hours_target_date, "%Y-%m-%d"
1194
+ ).date()
1195
+ days_remaining = (
1196
+ target_date - today
1197
+ ).days + 1 # +1 to include today
1198
+ if days_remaining > 0:
1199
+ hours_needed = max(0, config.reading_hours_target - total_hours)
1200
+ daily_hours_required = hours_needed / days_remaining
1201
+ result["hours"] = {
1202
+ "required": round(daily_hours_required, 2),
1203
+ "progress": round(today_hours, 2),
1204
+ "has_target": True,
1205
+ "target_date": config.reading_hours_target_date,
1206
+ "days_remaining": days_remaining,
1207
+ }
1208
+ else:
1209
+ result["hours"] = {
1210
+ "required": 0,
1211
+ "progress": round(today_hours, 2),
1212
+ "has_target": True,
1213
+ "expired": True,
1214
+ }
1215
+ except ValueError:
1216
+ result["hours"] = {
1217
+ "required": 0,
1218
+ "progress": round(today_hours, 2),
1219
+ "has_target": False,
1220
+ }
1221
+ else:
1222
+ result["hours"] = {
1223
+ "required": 0,
1224
+ "progress": round(today_hours, 2),
1225
+ "has_target": False,
1226
+ }
1227
+
1228
+ # Calculate characters requirement
1229
+ if config.character_count_target_date:
1230
+ try:
1231
+ target_date = datetime.datetime.strptime(
1232
+ config.character_count_target_date, "%Y-%m-%d"
1233
+ ).date()
1234
+ days_remaining = (target_date - today).days + 1
1235
+ if days_remaining > 0:
1236
+ chars_needed = max(
1237
+ 0, config.character_count_target - total_characters
1238
+ )
1239
+ daily_chars_required = int(chars_needed / days_remaining)
1240
+ result["characters"] = {
1241
+ "required": daily_chars_required,
1242
+ "progress": today_characters,
1243
+ "has_target": True,
1244
+ "target_date": config.character_count_target_date,
1245
+ "days_remaining": days_remaining,
1246
+ }
1247
+ else:
1248
+ result["characters"] = {
1249
+ "required": 0,
1250
+ "progress": today_characters,
1251
+ "has_target": True,
1252
+ "expired": True,
1253
+ }
1254
+ except ValueError:
1255
+ result["characters"] = {
1256
+ "required": 0,
1257
+ "progress": today_characters,
1258
+ "has_target": False,
1259
+ }
1260
+ else:
1261
+ result["characters"] = {
1262
+ "required": 0,
1263
+ "progress": today_characters,
1264
+ "has_target": False,
1265
+ }
1266
+
1267
+ # Calculate games requirement
1268
+ if config.games_target_date:
1269
+ try:
1270
+ target_date = datetime.datetime.strptime(
1271
+ config.games_target_date, "%Y-%m-%d"
1272
+ ).date()
1273
+ days_remaining = (target_date - today).days + 1
1274
+ if days_remaining > 0:
1275
+ games_needed = max(0, config.games_target - total_games)
1276
+ daily_games_required = games_needed / days_remaining
1277
+ result["games"] = {
1278
+ "required": round(daily_games_required, 2),
1279
+ "progress": total_games,
1280
+ "has_target": True,
1281
+ "target_date": config.games_target_date,
1282
+ "days_remaining": days_remaining,
1283
+ }
1284
+ else:
1285
+ result["games"] = {
1286
+ "required": 0,
1287
+ "progress": total_games,
1288
+ "has_target": True,
1289
+ "expired": True,
1290
+ }
1291
+ except ValueError:
1292
+ result["games"] = {
1293
+ "required": 0,
1294
+ "progress": total_games,
1295
+ "has_target": False,
1296
+ }
1297
+ else:
1298
+ result["games"] = {
1299
+ "required": 0,
1300
+ "progress": total_games,
1301
+ "has_target": False,
1302
+ }
1303
+
1304
+ # Calculate cards mined requirement (daily goal)
1305
+ cards_daily_target = getattr(config, 'cards_mined_daily_target', 10)
1306
+ if cards_daily_target > 0:
1307
+ result["cards"] = {
1308
+ "required": cards_daily_target,
1309
+ "progress": today_cards_mined,
1310
+ "has_target": True,
1311
+ }
1312
+ else:
1313
+ result["cards"] = {
1314
+ "required": 0,
1315
+ "progress": today_cards_mined,
1316
+ "has_target": False,
1317
+ }
1318
+
1319
+ return jsonify(result), 200
1320
+
1321
+ except Exception as e:
1322
+ logger.error(f"Error calculating goals today: {e}")
1323
+ return jsonify({"error": "Failed to calculate daily goals"}), 500
1324
+
1325
+ @app.route("/api/goals-projection", methods=["GET"])
1326
+ def api_goals_projection():
1327
+ """
1328
+ Calculate projections based on 30-day rolling average.
1329
+ Returns projected stats by target dates.
1330
+ Uses hybrid rollup + live approach for performance.
1331
+ """
1332
+ try:
1333
+ config = get_stats_config()
1334
+ today = datetime.date.today()
1335
+ today_str = today.strftime("%Y-%m-%d")
1336
+ thirty_days_ago = today - datetime.timedelta(days=30)
1337
+ thirty_days_ago_str = thirty_days_ago.strftime("%Y-%m-%d")
1338
+
1339
+ # === HYBRID ROLLUP + LIVE APPROACH ===
1340
+ # Get rollup data for last 30 days (up to yesterday)
1341
+ yesterday = today - datetime.timedelta(days=1)
1342
+ yesterday_str = yesterday.strftime("%Y-%m-%d")
1343
+
1344
+ # Query rollup data for last 30 days
1345
+ rollups_30d = StatsRollupTable.get_date_range(
1346
+ thirty_days_ago_str, yesterday_str
1347
+ )
1348
+
1349
+ # Get today's lines for live calculation
1350
+ today_start = datetime.datetime.combine(
1351
+ today, datetime.time.min
1352
+ ).timestamp()
1353
+ today_end = datetime.datetime.combine(today, datetime.time.max).timestamp()
1354
+ today_lines = GameLinesTable.get_lines_filtered_by_timestamp(
1355
+ start=today_start, end=today_end, for_stats=True
1356
+ )
1357
+
1358
+ # Calculate today's live stats
1359
+ live_stats_today = None
1360
+ if today_lines:
1361
+ live_stats_today = calculate_live_stats_for_today(today_lines)
1362
+
1363
+ # Calculate 30-day averages from rollup data
1364
+ if rollups_30d or live_stats_today:
1365
+ total_hours = 0
1366
+ total_chars = 0
1367
+ all_games = set()
1368
+
1369
+ # Sum up rollup data
1370
+ for rollup in rollups_30d:
1371
+ total_hours += rollup.total_reading_time_seconds / 3600
1372
+ total_chars += rollup.total_characters
1373
+ # Extract games from rollup
1374
+ if rollup.games_played_ids:
1375
+ try:
1376
+ games_ids = (
1377
+ json.loads(rollup.games_played_ids)
1378
+ if isinstance(rollup.games_played_ids, str)
1379
+ else rollup.games_played_ids
1380
+ )
1381
+ all_games.update(games_ids)
1382
+ except (json.JSONDecodeError, TypeError):
1383
+ pass
1384
+
1385
+ # Add today's stats
1386
+ if live_stats_today:
1387
+ total_hours += (
1388
+ live_stats_today.get("total_reading_time_seconds", 0) / 3600
1389
+ )
1390
+ total_chars += live_stats_today.get("total_characters", 0)
1391
+ today_games = live_stats_today.get("games_played_ids", [])
1392
+ all_games.update(today_games)
1393
+
1394
+ # Average over ALL 30 days (including days with 0 activity)
1395
+ avg_daily_hours = total_hours / 30
1396
+ avg_daily_chars = total_chars / 30
1397
+
1398
+ # Calculate average daily unique games
1399
+ # Count unique games per day from rollup data
1400
+ daily_game_counts = []
1401
+ for rollup in rollups_30d:
1402
+ if rollup.games_played_ids:
1403
+ try:
1404
+ games_ids = (
1405
+ json.loads(rollup.games_played_ids)
1406
+ if isinstance(rollup.games_played_ids, str)
1407
+ else rollup.games_played_ids
1408
+ )
1409
+ daily_game_counts.append(len(games_ids))
1410
+ except (json.JSONDecodeError, TypeError):
1411
+ daily_game_counts.append(0)
1412
+ else:
1413
+ daily_game_counts.append(0)
1414
+
1415
+ # Add today's unique games count
1416
+ if live_stats_today:
1417
+ today_games_count = len(
1418
+ live_stats_today.get("games_played_ids", [])
1419
+ )
1420
+ daily_game_counts.append(today_games_count)
1421
+
1422
+ # Pad with zeros for days without data (to get exactly 30 days)
1423
+ while len(daily_game_counts) < 30:
1424
+ daily_game_counts.append(0)
1425
+
1426
+ avg_daily_games = sum(daily_game_counts[:30]) / 30
1427
+ else:
1428
+ avg_daily_hours = 0
1429
+ avg_daily_chars = 0
1430
+ avg_daily_games = 0
1431
+
1432
+ # Calculate current totals from all rollup data + today
1433
+ first_rollup_date = StatsRollupTable.get_first_date()
1434
+ if not first_rollup_date:
1435
+ return jsonify(
1436
+ {
1437
+ "hours": {"projection": 0, "daily_average": 0},
1438
+ "characters": {"projection": 0, "daily_average": 0},
1439
+ "games": {"projection": 0, "daily_average": 0},
1440
+ }
1441
+ ), 200
1442
+
1443
+ # Get all rollup data for current totals
1444
+ all_rollups = StatsRollupTable.get_date_range(
1445
+ first_rollup_date, yesterday_str
1446
+ )
1447
+ rollup_stats_all = (
1448
+ aggregate_rollup_data(all_rollups) if all_rollups else None
1449
+ )
1450
+
1451
+ # Combine with today's live stats
1452
+ combined_stats_all = combine_rollup_and_live_stats(
1453
+ rollup_stats_all, live_stats_today
1454
+ )
1455
+
1456
+ # Extract current totals
1457
+ current_hours = (
1458
+ combined_stats_all.get("total_reading_time_seconds", 0) / 3600
1459
+ )
1460
+ current_chars = combined_stats_all.get("total_characters", 0)
1461
+ current_games = combined_stats_all.get("unique_games_played", 0)
1462
+
1463
+ result = {}
1464
+
1465
+ # Project hours by target date
1466
+ if config.reading_hours_target_date:
1467
+ try:
1468
+ target_date = datetime.datetime.strptime(
1469
+ config.reading_hours_target_date, "%Y-%m-%d"
1470
+ ).date()
1471
+ days_until_target = (target_date - today).days
1472
+ projected_hours = current_hours + (
1473
+ avg_daily_hours * days_until_target
1474
+ )
1475
+ result["hours"] = {
1476
+ "projection": round(projected_hours, 2),
1477
+ "daily_average": round(avg_daily_hours, 2),
1478
+ "target_date": config.reading_hours_target_date,
1479
+ "target": config.reading_hours_target,
1480
+ "current": round(current_hours, 2),
1481
+ }
1482
+ except ValueError:
1483
+ result["hours"] = {
1484
+ "projection": 0,
1485
+ "daily_average": round(avg_daily_hours, 2),
1486
+ }
1487
+ else:
1488
+ result["hours"] = {
1489
+ "projection": 0,
1490
+ "daily_average": round(avg_daily_hours, 2),
1491
+ }
1492
+
1493
+ # Project characters by target date
1494
+ if config.character_count_target_date:
1495
+ try:
1496
+ target_date = datetime.datetime.strptime(
1497
+ config.character_count_target_date, "%Y-%m-%d"
1498
+ ).date()
1499
+ days_until_target = (target_date - today).days
1500
+ projected_chars = int(
1501
+ current_chars + (avg_daily_chars * days_until_target)
1502
+ )
1503
+ result["characters"] = {
1504
+ "projection": projected_chars,
1505
+ "daily_average": int(avg_daily_chars),
1506
+ "target_date": config.character_count_target_date,
1507
+ "target": config.character_count_target,
1508
+ "current": current_chars,
1509
+ }
1510
+ except ValueError:
1511
+ result["characters"] = {
1512
+ "projection": 0,
1513
+ "daily_average": int(avg_daily_chars),
1514
+ }
1515
+ else:
1516
+ result["characters"] = {
1517
+ "projection": 0,
1518
+ "daily_average": int(avg_daily_chars),
1519
+ }
1520
+
1521
+ # Project games by target date
1522
+ if config.games_target_date:
1523
+ try:
1524
+ target_date = datetime.datetime.strptime(
1525
+ config.games_target_date, "%Y-%m-%d"
1526
+ ).date()
1527
+ days_until_target = (target_date - today).days
1528
+ projected_games = int(
1529
+ current_games + (avg_daily_games * days_until_target)
1530
+ )
1531
+ result["games"] = {
1532
+ "projection": projected_games,
1533
+ "daily_average": round(avg_daily_games, 2),
1534
+ "target_date": config.games_target_date,
1535
+ "target": config.games_target,
1536
+ "current": current_games,
1537
+ }
1538
+ except ValueError:
1539
+ result["games"] = {
1540
+ "projection": 0,
1541
+ "daily_average": round(avg_daily_games, 2),
1542
+ }
1543
+ else:
1544
+ result["games"] = {
1545
+ "projection": 0,
1546
+ "daily_average": round(avg_daily_games, 2),
1547
+ }
1548
+
1549
+ return jsonify(result), 200
1550
+
1551
+ except Exception as e:
1552
+ logger.error(f"Error calculating goal projections: {e}")
1553
+ return jsonify({"error": "Failed to calculate projections"}), 500
1554
+
1555
+ @app.route("/api/import-exstatic", methods=["POST"])
1556
+ def api_import_exstatic():
1557
+ """
1558
+ Import ExStatic CSV data into GSM database.
1559
+ Expected CSV format: uuid,given_identifier,name,line,time
1560
+ """
1561
+ try:
1562
+ # Check if file is provided
1563
+ if "file" not in request.files:
1564
+ return jsonify({"error": "No file provided"}), 400
1565
+
1566
+ file = request.files["file"]
1567
+ if file.filename == "":
1568
+ return jsonify({"error": "No file selected"}), 400
1569
+
1570
+ # Validate file type
1571
+ if not file.filename.lower().endswith(".csv"):
1572
+ return jsonify({"error": "File must be a CSV file"}), 400
1573
+
1574
+ # Read and parse CSV
1575
+ try:
1576
+ # Read file content as text with proper encoding handling
1577
+ file_content = file.read().decode("utf-8-sig") # Handle BOM if present
1578
+
1579
+ # First, get the header line manually to avoid issues with multi-line content
1580
+ lines = file_content.split("\n")
1581
+ if len(lines) == 1 and not lines[0].strip():
1582
+ return jsonify({"error": "Empty CSV file"}), 400
1583
+
1584
+ header_line = lines[0].strip()
1585
+
1586
+ # Parse headers manually
1587
+ header_reader = csv.reader([header_line])
1588
+ try:
1589
+ headers = next(header_reader)
1590
+ headers = [h.strip() for h in headers] # Clean whitespace
1591
+
1592
+ except StopIteration:
1593
+ return jsonify({"error": "Could not parse CSV headers"}), 400
1594
+
1595
+ # Validate headers
1596
+ expected_headers = {"uuid", "given_identifier", "name", "line", "time"}
1597
+ actual_headers = set(headers)
1598
+
1599
+ if not expected_headers.issubset(actual_headers):
1600
+ missing_headers = expected_headers - actual_headers
1601
+ # Check if this looks like a stats CSV instead of lines CSV
1602
+ if "client" in actual_headers and "chars_read" in actual_headers:
1603
+ return jsonify(
1604
+ {
1605
+ "error": "This appears to be an ExStatic stats CSV. Please upload the ExStatic lines CSV file instead. The lines CSV should contain columns: uuid, given_identifier, name, line, time"
1606
+ }
1607
+ ), 400
1608
+ else:
1609
+ return jsonify(
1610
+ {
1611
+ "error": f"Invalid CSV format. Missing required columns: {', '.join(missing_headers)}. Expected format: uuid, given_identifier, name, line, time. Found headers: {', '.join(actual_headers)}"
1612
+ }
1613
+ ), 400
1614
+
1615
+ # Now parse the full CSV with proper handling for multi-line fields
1616
+ file_io = io.StringIO(file_content)
1617
+ csv_reader = csv.DictReader(
1618
+ file_io, quoting=csv.QUOTE_MINIMAL, skipinitialspace=True
1619
+ )
1620
+
1621
+ # Process CSV rows
1622
+ games_set = set()
1623
+ errors = []
1624
+
1625
+ all_lines = GameLinesTable.all()
1626
+ existing_uuids = {line.id for line in all_lines}
1627
+ batch_size = 1000 # For logging progress
1628
+ batch_insert = []
1629
+ imported_count = 0
1630
+
1631
+ def get_line_hash(uuid: str, line_text: str) -> str:
1632
+ return uuid + "|" + line_text.strip()
1633
+
1634
+ for row_num, row in enumerate(csv_reader):
1635
+ try:
1636
+ # Extract and validate required fields
1637
+ game_uuid = row.get("uuid", "").strip()
1638
+ game_name = row.get("name", "").strip()
1639
+ line = row.get("line", "").strip()
1640
+ time_str = row.get("time", "").strip()
1641
+
1642
+ # Validate required fields
1643
+ if not game_uuid:
1644
+ errors.append(f"Row {row_num}: Missing UUID")
1645
+ continue
1646
+ if not game_name:
1647
+ errors.append(f"Row {row_num}: Missing name")
1648
+ continue
1649
+ if not line:
1650
+ errors.append(f"Row {row_num}: Missing line text")
1651
+ continue
1652
+ if not time_str:
1653
+ errors.append(f"Row {row_num}: Missing time")
1654
+ continue
1655
+
1656
+ line_hash = get_line_hash(game_uuid, line)
1657
+
1658
+ # Check if this line already exists in database
1659
+ if line_hash in existing_uuids:
1660
+ continue
1661
+
1662
+ # Convert time to timestamp
1663
+ try:
1664
+ timestamp = float(time_str)
1665
+ except ValueError:
1666
+ errors.append(
1667
+ f"Row {row_num}: Invalid time format: {time_str}"
1668
+ )
1669
+ continue
1670
+
1671
+ # Clean up line text (remove extra whitespace and newlines)
1672
+ line_text = line.strip()
1673
+
1674
+ # Create GameLinesTable entry
1675
+ # Convert timestamp float to datetime object
1676
+ dt = datetime.datetime.fromtimestamp(timestamp)
1677
+ batch_insert.append(
1678
+ GameLine(
1679
+ id=line_hash,
1680
+ text=line_text,
1681
+ scene=game_name,
1682
+ time=dt,
1683
+ prev=None,
1684
+ next=None,
1685
+ index=0,
1686
+ )
1687
+ )
1688
+
1689
+ existing_uuids.add(
1690
+ line_hash
1691
+ ) # Add to existing to prevent duplicates in same import
1692
+
1693
+ if len(batch_insert) >= batch_size:
1694
+ GameLinesTable.add_lines(batch_insert)
1695
+ imported_count += len(batch_insert)
1696
+ batch_insert = []
1697
+ games_set.add(game_name)
1698
+
1699
+ except Exception as e:
1700
+ logger.error(f"Error processing row {row_num}: {e}")
1701
+ errors.append(f"Row {row_num}: Error processing row - {str(e)}")
1702
+ continue
1703
+
1704
+ # Insert the rest of the batch
1705
+ if batch_insert:
1706
+ GameLinesTable.add_lines(batch_insert)
1707
+ imported_count += len(batch_insert)
1708
+ batch_insert = []
1709
+
1710
+ # # Import lines into database
1711
+ # imported_count = 0
1712
+ # for game_line in imported_lines:
1713
+ # try:
1714
+ # game_line.add()
1715
+ # imported_count += 1
1716
+ # except Exception as e:
1717
+ # logger.error(f"Failed to import line {game_line.id}: {e}")
1718
+ # errors.append(f"Failed to import line {game_line.id}: {str(e)}")
1719
+
1720
+ # Run daily rollup to update statistics with newly imported data
1721
+ logger.info("Running daily rollup after ExStatic import to update statistics...")
1722
+ try:
1723
+ rollup_result = run_daily_rollup()
1724
+ logger.info(f"Daily rollup completed: processed {rollup_result.get('processed', 0)} dates, overwritten {rollup_result.get('overwritten', 0)} dates")
1725
+ except Exception as rollup_error:
1726
+ logger.error(f"Error running daily rollup after import: {rollup_error}")
1727
+ # Don't fail the import if rollup fails - just log it
1728
+
1729
+ # Prepare response
1730
+ response_data = {
1731
+ "message": f"Successfully imported {imported_count} lines from {len(games_set)} games",
1732
+ "imported_count": imported_count,
1733
+ "games_count": len(games_set),
1734
+ "games": list(games_set),
1735
+ }
1736
+
1737
+ if errors:
1738
+ response_data["warnings"] = errors
1739
+ response_data["warning_count"] = len(errors)
1740
+
1741
+ return jsonify(response_data), 200
1742
+
1743
+ except csv.Error as e:
1744
+ return jsonify({"error": f"CSV parsing error: {str(e)}"}), 400
1745
+ except UnicodeDecodeError:
1746
+ return jsonify(
1747
+ {
1748
+ "error": "File encoding error. Please ensure the CSV is UTF-8 encoded."
1749
+ }
1750
+ ), 400
1751
+
1752
+ except Exception as e:
1753
+ logger.error(f"Error in ExStatic import: {e}")
1754
+ return jsonify({"error": f"Import failed: {str(e)}"}), 500
1755
+
1756
+ @app.route("/api/kanji-sorting-configs", methods=["GET"])
1757
+ def api_kanji_sorting_configs():
1758
+ """
1759
+ List available kanji sorting configuration JSON files.
1760
+ Returns metadata for each available sorting option.
1761
+ """
1762
+ try:
1763
+ # Get the kanji_grid directory path
1764
+ template_dir = (
1765
+ Path(__file__).parent / "templates" / "components" / "kanji_grid"
1766
+ )
1767
+
1768
+ if not template_dir.exists():
1769
+ logger.warning(f"Kanji grid directory does not exist: {template_dir}")
1770
+ return jsonify({"configs": []}), 200
1771
+
1772
+ configs = []
1773
+
1774
+ # Scan for JSON files in the directory
1775
+ for json_file in template_dir.glob("*.json"):
1776
+ try:
1777
+ with open(json_file, "r", encoding="utf-8") as f:
1778
+ data = json.load(f)
1779
+
1780
+ # Extract metadata from JSON
1781
+ configs.append(
1782
+ {
1783
+ "filename": json_file.name,
1784
+ "name": data.get("name", json_file.stem),
1785
+ "version": data.get("version", 1),
1786
+ "lang": data.get("lang", "ja"),
1787
+ "source": data.get("source", ""),
1788
+ "group_count": len(data.get("groups", [])),
1789
+ }
1790
+ )
1791
+ except json.JSONDecodeError as e:
1792
+ logger.warning(f"Failed to parse {json_file.name}: {e}")
1793
+ continue
1794
+ except Exception as e:
1795
+ logger.warning(f"Error reading {json_file.name}: {e}")
1796
+ continue
1797
+
1798
+ # Sort by name for consistency
1799
+ configs.sort(key=lambda x: x["name"])
1800
+
1801
+ return jsonify({"configs": configs}), 200
1802
+
1803
+ except Exception as e:
1804
+ logger.error(f"Error fetching kanji sorting configs: {e}")
1805
+ return jsonify({"error": "Failed to fetch sorting configurations"}), 500
1806
+
1807
+ @app.route("/api/kanji-sorting-config/<filename>", methods=["GET"])
1808
+ def api_kanji_sorting_config(filename):
1809
+ """
1810
+ Get a specific kanji sorting configuration file.
1811
+ Returns the full JSON configuration.
1812
+ """
1813
+ try:
1814
+ # Sanitize filename to prevent path traversal
1815
+ if ".." in filename or "/" in filename or "\\" in filename:
1816
+ return jsonify({"error": "Invalid filename"}), 400
1817
+
1818
+ if not filename.endswith(".json"):
1819
+ filename += ".json"
1820
+
1821
+ # Get the kanji_grid directory path
1822
+ template_dir = (
1823
+ Path(__file__).parent / "templates" / "components" / "kanji_grid"
1824
+ )
1825
+ config_file = template_dir / filename
1826
+
1827
+ if not config_file.exists() or not config_file.is_file():
1828
+ return jsonify({"error": "Configuration file not found"}), 404
1829
+
1830
+ # Read and return the JSON configuration
1831
+ with open(config_file, "r", encoding="utf-8") as f:
1832
+ config_data = json.load(f)
1833
+
1834
+ return jsonify(config_data), 200
1835
+
1836
+ except json.JSONDecodeError as e:
1837
+ logger.error(f"Failed to parse {filename}: {e}")
1838
+ return jsonify({"error": "Invalid JSON configuration"}), 500
1839
+ except Exception as e:
1840
+ logger.error(f"Error fetching config {filename}: {e}")
1841
+ return jsonify({"error": "Failed to fetch configuration"}), 500
1842
+
1843
+ @app.route("/api/daily-activity", methods=["GET"])
1844
+ def api_daily_activity():
1845
+ """
1846
+ Get daily activity data (time and characters) for the last 4 weeks or all time.
1847
+ Returns data from the rollup table ONLY (no live data).
1848
+ Uses historical data up to today (inclusive).
1849
+
1850
+ Query Parameters:
1851
+ - all_time: If 'true', returns all available data from first rollup date to today
1852
+ """
1853
+ try:
1854
+ # Check if all-time data is requested
1855
+ use_all_time = request.args.get('all_time', 'false').lower() == 'true'
1856
+
1857
+ today = datetime.date.today()
1858
+
1859
+ if use_all_time:
1860
+ # Get all data from first rollup date to today
1861
+ first_rollup_date = StatsRollupTable.get_first_date()
1862
+ if not first_rollup_date:
1863
+ return jsonify({
1864
+ "labels": [],
1865
+ "timeData": [],
1866
+ "charsData": [],
1867
+ "speedData": []
1868
+ }), 200
1869
+
1870
+ start_date = datetime.datetime.strptime(first_rollup_date, "%Y-%m-%d").date()
1871
+ else:
1872
+ # Get date range for last 4 weeks (28 days) - INCLUDING today
1873
+ start_date = today - datetime.timedelta(days=27) # 28 days of data
1874
+
1875
+ # Get rollup data for the date range (up to today, inclusive)
1876
+ rollups = StatsRollupTable.get_date_range(
1877
+ start_date.strftime("%Y-%m-%d"),
1878
+ today.strftime("%Y-%m-%d")
1879
+ )
1880
+
1881
+ # Build response data
1882
+ labels = []
1883
+ time_data = []
1884
+ chars_data = []
1885
+ speed_data = []
1886
+
1887
+ # Create a map of existing rollup data
1888
+ rollup_map = {rollup.date: rollup for rollup in rollups}
1889
+
1890
+ # Fill in all dates in the range (including days with no data)
1891
+ current_date = start_date
1892
+ while current_date <= today:
1893
+ date_str = current_date.strftime("%Y-%m-%d")
1894
+ labels.append(date_str)
1895
+
1896
+ if date_str in rollup_map:
1897
+ rollup = rollup_map[date_str]
1898
+ # Convert seconds to hours
1899
+ time_hours = rollup.total_reading_time_seconds / 3600
1900
+ time_data.append(round(time_hours, 2))
1901
+ chars_data.append(rollup.total_characters)
1902
+
1903
+ # Calculate reading speed (chars/hour)
1904
+ if rollup.total_reading_time_seconds > 0 and rollup.total_characters > 0:
1905
+ speed = int(rollup.total_characters / time_hours)
1906
+ speed_data.append(speed)
1907
+ else:
1908
+ speed_data.append(0)
1909
+ else:
1910
+ # No data for this day
1911
+ time_data.append(0)
1912
+ chars_data.append(0)
1913
+ speed_data.append(0)
1914
+
1915
+ current_date += datetime.timedelta(days=1)
1916
+
1917
+ return jsonify({
1918
+ "labels": labels,
1919
+ "timeData": time_data,
1920
+ "charsData": chars_data,
1921
+ "speedData": speed_data
1922
+ }), 200
1923
+
1924
+ except Exception as e:
1925
+ logger.error(f"Error fetching daily activity: {e}", exc_info=True)
1926
+ return jsonify({"error": "Failed to fetch daily activity"}), 500
1927
+
1928
+ @app.route("/api/today-stats", methods=["GET"])
1929
+ def api_today_stats():
1930
+ """
1931
+ Calculate and return today's statistics including sessions.
1932
+ Returns total characters, chars/hour for today, and all sessions with their stats.
1933
+ """
1934
+ try:
1935
+ # Get configuration
1936
+ config = get_stats_config()
1937
+ afk_timer_seconds = config.afk_timer_seconds
1938
+ session_gap_seconds = config.session_gap_seconds
1939
+ minimum_session_length = 0 # 5 minutes
1940
+
1941
+ # Get today's date range
1942
+ today = datetime.date.today()
1943
+ today_start = datetime.datetime.combine(
1944
+ today, datetime.time.min
1945
+ ).timestamp()
1946
+ today_end = datetime.datetime.combine(today, datetime.time.max).timestamp()
1947
+
1948
+ # Query all game lines for today
1949
+ today_lines = GameLinesTable.get_lines_filtered_by_timestamp(
1950
+ start=today_start, end=today_end, for_stats=True
1951
+ )
1952
+
1953
+ # If no lines today, return empty stats
1954
+ if not today_lines:
1955
+ return jsonify(
1956
+ {
1957
+ "todayTotalChars": 0,
1958
+ "todayCharsPerHour": 0,
1959
+ "todayTotalHours": 0,
1960
+ "todaySessions": 0,
1961
+ "sessions": [],
1962
+ }
1963
+ ), 200
1964
+
1965
+ # Sort lines by timestamp
1966
+ sorted_lines = sorted(today_lines, key=lambda line: float(line.timestamp))
1967
+
1968
+ # Calculate total characters
1969
+ total_chars = sum(
1970
+ len(line.line_text) if line.line_text else 0 for line in sorted_lines
1971
+ )
1972
+
1973
+ # Calculate total reading time using AFK timer logic
1974
+ total_seconds = 0
1975
+ timestamps = [float(line.timestamp) for line in sorted_lines]
1976
+
1977
+ if len(timestamps) >= 2:
1978
+ for i in range(1, len(timestamps)):
1979
+ gap = timestamps[i] - timestamps[i - 1]
1980
+ total_seconds += min(gap, afk_timer_seconds)
1981
+ elif len(timestamps) == 1:
1982
+ total_seconds = 1 # Minimal activity
1983
+
1984
+ total_hours = total_seconds / 3600
1985
+
1986
+ # Calculate chars/hour for today
1987
+ chars_per_hour = 0
1988
+ if total_chars > 0 and total_hours > 0:
1989
+ chars_per_hour = round(total_chars / total_hours)
1990
+
1991
+ # Detect sessions
1992
+ sessions = []
1993
+ current_session = None
1994
+ last_timestamp = None
1995
+ last_game_name = None
1996
+
1997
+ # Build a cache of game_name -> title_original and full metadata mappings for efficiency
1998
+ game_name_to_title = {}
1999
+ game_name_to_metadata = {}
2000
+ for line in sorted_lines:
2001
+ if line.game_name and line.game_name not in game_name_to_title:
2002
+ game_metadata = GamesTable.get_by_game_line(line)
2003
+ if game_metadata:
2004
+ if game_metadata.title_original:
2005
+ game_name_to_title[line.game_name] = game_metadata.title_original
2006
+ else:
2007
+ game_name_to_title[line.game_name] = line.game_name
2008
+
2009
+ # Store full metadata for this game
2010
+ game_name_to_metadata[line.game_name] = {
2011
+ "title_original": game_metadata.title_original or "",
2012
+ "title_romaji": game_metadata.title_romaji or "",
2013
+ "title_english": game_metadata.title_english or "",
2014
+ "type": game_metadata.type or "",
2015
+ "description": game_metadata.description or "",
2016
+ "image": game_metadata.image or "",
2017
+ "character_count": game_metadata.character_count or 0,
2018
+ "difficulty": game_metadata.difficulty,
2019
+ "links": game_metadata.links or [],
2020
+ "completed": game_metadata.completed or False,
2021
+ }
2022
+ else:
2023
+ game_name_to_title[line.game_name] = line.game_name
2024
+ game_name_to_metadata[line.game_name] = None
2025
+
2026
+ for line in sorted_lines:
2027
+ ts = float(line.timestamp)
2028
+ # Use title_original from games table instead of game_name from game_lines
2029
+ raw_game_name = line.game_name or "Unknown Game"
2030
+ game_name = game_name_to_title.get(raw_game_name, raw_game_name)
2031
+ chars = len(line.line_text) if line.line_text else 0
2032
+
2033
+ # Determine if new session: gap > session_gap OR game changed
2034
+ is_new_session = (
2035
+ last_timestamp is not None
2036
+ and ts - last_timestamp > session_gap_seconds
2037
+ ) or (last_game_name is not None and game_name != last_game_name)
2038
+
2039
+ if not current_session or is_new_session:
2040
+ # Finish previous session
2041
+ if current_session:
2042
+ # Calculate read speed for session
2043
+ if current_session["totalSeconds"] > 0:
2044
+ session_hours = current_session["totalSeconds"] / 3600
2045
+ current_session["charsPerHour"] = round(
2046
+ current_session["totalChars"] / session_hours
2047
+ )
2048
+ else:
2049
+ current_session["charsPerHour"] = 0
2050
+
2051
+ # Only add session if it meets minimum length requirement
2052
+ if current_session["totalSeconds"] >= minimum_session_length:
2053
+ sessions.append(current_session)
2054
+
2055
+ # Start new session with full game metadata
2056
+ game_metadata = game_name_to_metadata.get(raw_game_name)
2057
+ current_session = {
2058
+ "startTime": ts,
2059
+ "endTime": ts,
2060
+ "gameName": game_name,
2061
+ "totalChars": chars,
2062
+ "totalSeconds": 0,
2063
+ "charsPerHour": 0,
2064
+ "gameMetadata": game_metadata, # Add full game metadata
2065
+ }
2066
+ else:
2067
+ # Continue current session
2068
+ current_session["endTime"] = ts
2069
+ current_session["totalChars"] += chars
2070
+ if last_timestamp is not None:
2071
+ gap = ts - last_timestamp
2072
+ current_session["totalSeconds"] += min(gap, afk_timer_seconds)
2073
+
2074
+ last_timestamp = ts
2075
+ last_game_name = game_name
2076
+
2077
+ # Add the last session
2078
+ if current_session:
2079
+ if current_session["totalSeconds"] > 0:
2080
+ session_hours = current_session["totalSeconds"] / 3600
2081
+ current_session["charsPerHour"] = round(
2082
+ current_session["totalChars"] / session_hours
2083
+ )
2084
+ else:
2085
+ current_session["charsPerHour"] = 0
2086
+
2087
+ # Only add if meets minimum length
2088
+ if current_session["totalSeconds"] >= minimum_session_length:
2089
+ sessions.append(current_session)
2090
+
2091
+ # Return response
2092
+ return jsonify(
2093
+ {
2094
+ "todayTotalChars": total_chars,
2095
+ "todayCharsPerHour": chars_per_hour,
2096
+ "todayTotalHours": round(total_hours, 2),
2097
+ "todaySessions": len(sessions),
2098
+ "sessions": sessions,
2099
+ }
2100
+ ), 200
2101
+
2102
+ except Exception as e:
2103
+ logger.error(f"Error calculating today's stats: {e}", exc_info=True)
2104
+ return jsonify({"error": "Failed to calculate today's statistics"}), 500