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.
- GameSentenceMiner/__init__.py +39 -0
- GameSentenceMiner/anki.py +6 -3
- GameSentenceMiner/gametext.py +13 -2
- GameSentenceMiner/gsm.py +40 -3
- GameSentenceMiner/locales/en_us.json +4 -0
- GameSentenceMiner/locales/ja_jp.json +4 -0
- GameSentenceMiner/locales/zh_cn.json +4 -0
- GameSentenceMiner/obs.py +4 -1
- GameSentenceMiner/owocr/owocr/ocr.py +304 -134
- GameSentenceMiner/owocr/owocr/run.py +1 -1
- GameSentenceMiner/ui/anki_confirmation.py +4 -2
- GameSentenceMiner/ui/config_gui.py +12 -0
- GameSentenceMiner/util/configuration.py +6 -2
- GameSentenceMiner/util/cron/__init__.py +12 -0
- GameSentenceMiner/util/cron/daily_rollup.py +613 -0
- GameSentenceMiner/util/cron/jiten_update.py +397 -0
- GameSentenceMiner/util/cron/populate_games.py +154 -0
- GameSentenceMiner/util/cron/run_crons.py +148 -0
- GameSentenceMiner/util/cron/setup_populate_games_cron.py +118 -0
- GameSentenceMiner/util/cron_table.py +334 -0
- GameSentenceMiner/util/db.py +236 -49
- GameSentenceMiner/util/ffmpeg.py +23 -4
- GameSentenceMiner/util/games_table.py +340 -93
- GameSentenceMiner/util/jiten_api_client.py +188 -0
- GameSentenceMiner/util/stats_rollup_table.py +216 -0
- GameSentenceMiner/web/anki_api_endpoints.py +438 -220
- GameSentenceMiner/web/database_api.py +955 -1259
- GameSentenceMiner/web/jiten_database_api.py +1015 -0
- GameSentenceMiner/web/rollup_stats.py +672 -0
- GameSentenceMiner/web/static/css/dashboard-shared.css +75 -13
- GameSentenceMiner/web/static/css/overview.css +604 -47
- GameSentenceMiner/web/static/css/search.css +226 -0
- GameSentenceMiner/web/static/css/shared.css +762 -0
- GameSentenceMiner/web/static/css/stats.css +221 -0
- GameSentenceMiner/web/static/js/components/bar-chart.js +339 -0
- GameSentenceMiner/web/static/js/database-bulk-operations.js +320 -0
- GameSentenceMiner/web/static/js/database-game-data.js +390 -0
- GameSentenceMiner/web/static/js/database-game-operations.js +213 -0
- GameSentenceMiner/web/static/js/database-helpers.js +44 -0
- GameSentenceMiner/web/static/js/database-jiten-integration.js +750 -0
- GameSentenceMiner/web/static/js/database-popups.js +89 -0
- GameSentenceMiner/web/static/js/database-tabs.js +64 -0
- GameSentenceMiner/web/static/js/database-text-management.js +371 -0
- GameSentenceMiner/web/static/js/database.js +86 -718
- GameSentenceMiner/web/static/js/goals.js +79 -18
- GameSentenceMiner/web/static/js/heatmap.js +29 -23
- GameSentenceMiner/web/static/js/overview.js +1205 -339
- GameSentenceMiner/web/static/js/regex-patterns.js +100 -0
- GameSentenceMiner/web/static/js/search.js +215 -18
- GameSentenceMiner/web/static/js/shared.js +193 -39
- GameSentenceMiner/web/static/js/stats.js +1536 -179
- GameSentenceMiner/web/stats.py +1142 -269
- GameSentenceMiner/web/stats_api.py +2104 -0
- GameSentenceMiner/web/templates/anki_stats.html +4 -18
- GameSentenceMiner/web/templates/components/date-range.html +118 -3
- GameSentenceMiner/web/templates/components/html-head.html +40 -6
- GameSentenceMiner/web/templates/components/js-config.html +8 -8
- GameSentenceMiner/web/templates/components/regex-input.html +160 -0
- GameSentenceMiner/web/templates/database.html +564 -117
- GameSentenceMiner/web/templates/goals.html +41 -5
- GameSentenceMiner/web/templates/overview.html +159 -129
- GameSentenceMiner/web/templates/search.html +78 -9
- GameSentenceMiner/web/templates/stats.html +159 -5
- GameSentenceMiner/web/texthooking_page.py +280 -111
- {gamesentenceminer-2.19.16.dist-info → gamesentenceminer-2.20.0.dist-info}/METADATA +43 -2
- {gamesentenceminer-2.19.16.dist-info → gamesentenceminer-2.20.0.dist-info}/RECORD +70 -47
- {gamesentenceminer-2.19.16.dist-info → gamesentenceminer-2.20.0.dist-info}/WHEEL +0 -0
- {gamesentenceminer-2.19.16.dist-info → gamesentenceminer-2.20.0.dist-info}/entry_points.txt +0 -0
- {gamesentenceminer-2.19.16.dist-info → gamesentenceminer-2.20.0.dist-info}/licenses/LICENSE +0 -0
- {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
|