GameSentenceMiner 2.15.9__py3-none-any.whl → 2.15.10__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.
@@ -0,0 +1,582 @@
1
+ import datetime
2
+ from collections import defaultdict
3
+
4
+ from GameSentenceMiner.util.db import GameLinesTable
5
+ from GameSentenceMiner.util.configuration import logger, get_config
6
+
7
+
8
+ def is_kanji(char):
9
+ """Check if a character is a kanji (CJK Unified Ideographs)."""
10
+ # Validate input is a single character
11
+ if not isinstance(char, str) or len(char) != 1:
12
+ logger.warning(f"is_kanji() received invalid input: {repr(char)} (type: {type(char)}, length: {len(char) if isinstance(char, str) else 'N/A'})")
13
+ return False
14
+
15
+ try:
16
+ code_point = ord(char)
17
+ # CJK Unified Ideographs (most common kanji range)
18
+ # U+4E00-U+9FAF covers the main kanji characters
19
+ return 0x4E00 <= code_point <= 0x9FAF
20
+ except (TypeError, ValueError) as e:
21
+ logger.warning(f"is_kanji() failed to process character {repr(char)}: {e}")
22
+ return False
23
+
24
+ def interpolate_color(color1, color2, factor):
25
+ """Interpolate between two hex colors."""
26
+ # Convert hex to RGB
27
+ def hex_to_rgb(hex_color):
28
+ hex_color = hex_color.lstrip('#')
29
+ return tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4))
30
+
31
+ # Convert RGB to hex
32
+ def rgb_to_hex(rgb):
33
+ return f"#{int(rgb[0]):02x}{int(rgb[1]):02x}{int(rgb[2]):02x}"
34
+
35
+ rgb1 = hex_to_rgb(color1)
36
+ rgb2 = hex_to_rgb(color2)
37
+
38
+ # Interpolate each channel
39
+ rgb_result = tuple(
40
+ rgb1[i] + factor * (rgb2[i] - rgb1[i])
41
+ for i in range(3)
42
+ )
43
+
44
+ return rgb_to_hex(rgb_result)
45
+
46
+ def get_gradient_color(frequency, max_frequency):
47
+ """Get color from gradient based on frequency."""
48
+ if max_frequency == 0:
49
+ return "#ebedf0" # Default color for no encounters
50
+
51
+ # kanji with 500+ encounters should always get cyan color cause i think u should know them
52
+ if frequency > 500:
53
+ return "#2ee6e0"
54
+
55
+ # Normalize frequency to 0-1 range with square root transformation
56
+ # This creates a smoother, more visually pleasing gradient by spreading
57
+ # out the lower frequencies (since kanji frequency follows Zipf's law)
58
+ ratio = (frequency / max_frequency) ** 0.5
59
+
60
+ # Define gradient colors: least seen → most seen
61
+ # #e6342e (red) → #e6dc2e (yellow) → #3be62f (green) → #2ee6e0 (cyan)
62
+ colors = ["#e6342e", "#e6dc2e", "#3be62f", "#2ee6e0"]
63
+
64
+ if ratio == 0:
65
+ return "#ebedf0" # No encounters
66
+
67
+ # Scale ratio to fit the 3 gradient segments
68
+ scaled_ratio = ratio * (len(colors) - 1)
69
+ segment = int(scaled_ratio)
70
+ local_ratio = scaled_ratio - segment
71
+
72
+ # Clamp segment to valid range
73
+ if segment >= len(colors) - 1:
74
+ return colors[-1]
75
+
76
+ # Interpolate between adjacent colors
77
+ return interpolate_color(colors[segment], colors[segment + 1], local_ratio)
78
+
79
+ def calculate_kanji_frequency(all_lines):
80
+ """Calculate frequency of kanji characters across all lines with gradient coloring."""
81
+ kanji_count = defaultdict(int)
82
+
83
+ for line in all_lines:
84
+ if line.line_text:
85
+ # Ensure line_text is a string and handle any encoding issues
86
+ try:
87
+ line_text = str(line.line_text) if line.line_text else ""
88
+ for char in line_text:
89
+ if is_kanji(char):
90
+ kanji_count[char] += 1
91
+ except Exception as e:
92
+ logger.warning(f"Error processing line text for kanji frequency: {repr(line.line_text)}, error: {e}")
93
+ continue
94
+
95
+ if not kanji_count:
96
+ return {
97
+ "kanji_data": [],
98
+ "unique_count": 0
99
+ }
100
+
101
+ # Find max frequency for gradient calculation
102
+ max_frequency = max(kanji_count.values())
103
+
104
+ # Sort kanji by frequency (most frequent first)
105
+ sorted_kanji = sorted(kanji_count.items(), key=lambda x: x[1], reverse=True)
106
+
107
+ # Add gradient colors to each kanji
108
+ kanji_data = []
109
+ for kanji, count in sorted_kanji:
110
+ color = get_gradient_color(count, max_frequency)
111
+ kanji_data.append({
112
+ "kanji": kanji,
113
+ "frequency": count,
114
+ "color": color
115
+ })
116
+
117
+ return {
118
+ "kanji_data": kanji_data,
119
+ "unique_count": len(sorted_kanji),
120
+ "max_frequency": max_frequency
121
+ }
122
+
123
+ def calculate_heatmap_data(all_lines, filter_year=None):
124
+ """Calculate heatmap data for reading activity."""
125
+ heatmap_data = defaultdict(lambda: defaultdict(int))
126
+
127
+ for line in all_lines:
128
+ date_obj = datetime.date.fromtimestamp(float(line.timestamp))
129
+ year = str(date_obj.year)
130
+
131
+ # Filter by year if specified
132
+ if filter_year and year != filter_year:
133
+ continue
134
+
135
+ date_str = date_obj.strftime('%Y-%m-%d')
136
+ char_count = len(line.line_text) if line.line_text else 0
137
+ heatmap_data[year][date_str] += char_count
138
+
139
+ return dict(heatmap_data)
140
+
141
+
142
+ def calculate_total_chars_per_game(all_lines):
143
+ """Calculate total characters read per game."""
144
+ game_data = defaultdict(lambda: {'total_chars': 0, 'first_time': None})
145
+
146
+ for line in all_lines:
147
+ game = line.game_name or "Unknown Game"
148
+ timestamp = float(line.timestamp)
149
+ char_count = len(line.line_text) if line.line_text else 0
150
+
151
+ game_data[game]['total_chars'] += char_count
152
+
153
+ if game_data[game]['first_time'] is None:
154
+ game_data[game]['first_time'] = timestamp
155
+
156
+ # Sort by first appearance time and filter out games with no characters
157
+ char_data = []
158
+ for game, data in game_data.items():
159
+ if data['total_chars'] > 0:
160
+ char_data.append((game, data['total_chars'], data['first_time']))
161
+
162
+ # Sort by first appearance time
163
+ char_data.sort(key=lambda x: x[2])
164
+
165
+ return {
166
+ "labels": [item[0] for item in char_data],
167
+ "totals": [item[1] for item in char_data]
168
+ }
169
+
170
+ def calculate_reading_time_per_game(all_lines):
171
+ """Calculate total reading time per game in hours using AFK timer logic."""
172
+ game_data = defaultdict(lambda: {'timestamps': [], 'first_time': None})
173
+
174
+ for line in all_lines:
175
+ game = line.game_name or "Unknown Game"
176
+ timestamp = float(line.timestamp)
177
+
178
+ game_data[game]['timestamps'].append(timestamp)
179
+ if game_data[game]['first_time'] is None:
180
+ game_data[game]['first_time'] = timestamp
181
+
182
+ # Calculate actual reading time for each game
183
+ time_data = []
184
+ for game, data in game_data.items():
185
+ if len(data['timestamps']) >= 2:
186
+ # Use actual reading time calculation
187
+ reading_time_seconds = calculate_actual_reading_time(data['timestamps'])
188
+ hours = reading_time_seconds / 3600 # Convert to hours
189
+ if hours > 0:
190
+ time_data.append((game, hours, data['first_time']))
191
+
192
+ # Sort by first appearance time
193
+ time_data.sort(key=lambda x: x[2])
194
+
195
+ return {
196
+ "labels": [item[0] for item in time_data],
197
+ "totals": [round(item[1], 2) for item in time_data] # Round to 2 decimals for hours
198
+ }
199
+
200
+ def calculate_reading_speed_per_game(all_lines):
201
+ """Calculate average reading speed per game (chars/hour) using AFK timer logic."""
202
+ game_data = defaultdict(lambda: {'chars': 0, 'timestamps': [], 'first_time': None})
203
+
204
+ for line in all_lines:
205
+ game = line.game_name or "Unknown Game"
206
+ timestamp = float(line.timestamp)
207
+ char_count = len(line.line_text) if line.line_text else 0
208
+
209
+ game_data[game]['chars'] += char_count
210
+ game_data[game]['timestamps'].append(timestamp)
211
+
212
+ if game_data[game]['first_time'] is None:
213
+ game_data[game]['first_time'] = timestamp
214
+
215
+ # Calculate speeds using actual reading time
216
+ speed_data = []
217
+ for game, data in game_data.items():
218
+ if len(data['timestamps']) >= 2 and data['chars'] > 0:
219
+ # Use actual reading time calculation
220
+ reading_time_seconds = calculate_actual_reading_time(data['timestamps'])
221
+ hours = reading_time_seconds / 3600 # Convert to hours
222
+ if hours > 0:
223
+ speed = data['chars'] / hours
224
+ speed_data.append((game, speed, data['first_time']))
225
+
226
+ # Sort by first appearance time
227
+ speed_data.sort(key=lambda x: x[2])
228
+
229
+ return {
230
+ "labels": [item[0] for item in speed_data],
231
+ "totals": [round(item[1], 0) for item in speed_data] # Round to whole numbers for chars/hour
232
+ }
233
+
234
+ def generate_game_colors(game_count):
235
+ """Generate visually distinct colors for games using HSL color space."""
236
+ colors = []
237
+
238
+ # Predefined set of good colors for the first few games
239
+ predefined_colors = [
240
+ '#3498db', '#e74c3c', '#2ecc71', '#f1c40f', '#9b59b6',
241
+ '#1abc9c', '#e67e22', '#34495e', '#16a085', '#27ae60',
242
+ '#2980b9', '#8e44ad', '#d35400', '#c0392b', '#7f8c8d'
243
+ ]
244
+
245
+ # Use predefined colors first
246
+ for i in range(min(game_count, len(predefined_colors))):
247
+ colors.append(predefined_colors[i])
248
+
249
+ # Generate additional colors using HSL if needed
250
+ if game_count > len(predefined_colors):
251
+ remaining = game_count - len(predefined_colors)
252
+ for i in range(remaining):
253
+ # Distribute hue evenly across the color wheel
254
+ hue = (i * 360 / remaining) % 360
255
+ # Use varied saturation and lightness for visual distinction
256
+ saturation = 65 + (i % 3) * 10 # 65%, 75%, 85%
257
+ lightness = 45 + (i % 2) * 10 # 45%, 55%
258
+
259
+ # Convert HSL to hex
260
+ colors.append(f'hsl({hue:.0f}, {saturation}%, {lightness}%)')
261
+
262
+ return colors
263
+
264
+ def format_large_number(num):
265
+ """Format large numbers with appropriate units (K for thousands, M for millions)."""
266
+ if num >= 1000000:
267
+ return f"{num / 1000000:.1f}M"
268
+ elif num >= 1000:
269
+ return f"{num / 1000:.1f}K"
270
+ else:
271
+ return str(int(num))
272
+
273
+ def calculate_actual_reading_time(timestamps, afk_timer_seconds=None):
274
+ """
275
+ Calculate actual reading time using AFK timer logic.
276
+
277
+ Args:
278
+ timestamps: List of timestamps (as floats)
279
+ afk_timer_seconds: Maximum time between entries to count as active reading.
280
+ If None, uses config value. Defaults to 120 seconds (2 minutes).
281
+
282
+ Returns:
283
+ float: Actual reading time in seconds
284
+ """
285
+ if not timestamps or len(timestamps) < 2:
286
+ return 0.0
287
+
288
+ if afk_timer_seconds is None:
289
+ afk_timer_seconds = get_config().advanced.afk_timer_seconds
290
+
291
+ # Sort timestamps to ensure chronological order
292
+ sorted_timestamps = sorted(timestamps)
293
+ total_reading_time = 0.0
294
+
295
+ # Calculate time between consecutive entries
296
+ for i in range(1, len(sorted_timestamps)):
297
+ time_gap = sorted_timestamps[i] - sorted_timestamps[i-1]
298
+
299
+ # Cap the gap at AFK timer limit
300
+ if time_gap > afk_timer_seconds:
301
+ total_reading_time += afk_timer_seconds
302
+ else:
303
+ total_reading_time += time_gap
304
+
305
+ return total_reading_time
306
+
307
+ def calculate_daily_reading_time(lines):
308
+ """
309
+ Calculate actual reading time per day using AFK timer logic.
310
+
311
+ Args:
312
+ lines: List of game lines
313
+
314
+ Returns:
315
+ dict: Dictionary mapping date strings to reading time in hours
316
+ """
317
+ daily_timestamps = defaultdict(list)
318
+
319
+ # Group timestamps by day
320
+ for line in lines:
321
+ date_str = datetime.date.fromtimestamp(float(line.timestamp)).strftime('%Y-%m-%d')
322
+ daily_timestamps[date_str].append(float(line.timestamp))
323
+
324
+ # Calculate reading time for each day
325
+ daily_reading_time = {}
326
+ for date_str, timestamps in daily_timestamps.items():
327
+ if len(timestamps) >= 2:
328
+ reading_time_seconds = calculate_actual_reading_time(timestamps)
329
+ daily_reading_time[date_str] = reading_time_seconds / 3600 # Convert to hours
330
+ else:
331
+ daily_reading_time[date_str] = 0.0
332
+
333
+ return daily_reading_time
334
+
335
+ def calculate_time_based_streak(lines, streak_requirement_hours=None):
336
+ """
337
+ Calculate reading streak based on time requirements rather than daily activity.
338
+
339
+ Args:
340
+ lines: List of game lines
341
+ streak_requirement_hours: Minimum hours of reading per day to maintain streak.
342
+ If None, uses config value. Defaults to 1.0.
343
+
344
+ Returns:
345
+ int: Current streak in days
346
+ """
347
+ if streak_requirement_hours is None:
348
+ streak_requirement_hours = getattr(get_config().advanced, 'streak_requirement_hours', 1.0)
349
+
350
+ # Add debug logging
351
+ logger.debug(f"Calculating streak with requirement: {streak_requirement_hours} hours")
352
+ logger.debug(f"Processing {len(lines)} lines for streak calculation")
353
+
354
+ # Calculate daily reading time
355
+ daily_reading_time = calculate_daily_reading_time(lines)
356
+
357
+ if not daily_reading_time:
358
+ logger.debug("No daily reading time data available")
359
+ return 0
360
+
361
+ logger.debug(f"Daily reading time data: {dict(list(daily_reading_time.items())[:5])}") # Show first 5 days
362
+
363
+ # Check streak from today backwards
364
+ today = datetime.date.today()
365
+ current_streak = 0
366
+
367
+ check_date = today
368
+ consecutive_days_checked = 0
369
+ while consecutive_days_checked < 365: # Check max 365 days back
370
+ date_str = check_date.strftime('%Y-%m-%d')
371
+ reading_hours = daily_reading_time.get(date_str, 0.0)
372
+
373
+ logger.debug(f"Checking {date_str}: {reading_hours:.4f} hours vs requirement {streak_requirement_hours}")
374
+
375
+ if reading_hours >= streak_requirement_hours:
376
+ current_streak += 1
377
+ logger.debug(f"Day {date_str} qualifies for streak. Current streak: {current_streak}")
378
+ else:
379
+ logger.debug(f"Day {date_str} breaks streak. Reading hours {reading_hours:.4f} < requirement {streak_requirement_hours}")
380
+ break
381
+
382
+ check_date -= datetime.timedelta(days=1)
383
+ consecutive_days_checked += 1
384
+
385
+ logger.debug(f"Final calculated streak: {current_streak} days")
386
+ return current_streak
387
+
388
+
389
+ def format_time_human_readable(hours):
390
+ """Format time in human-readable format (hours and minutes)."""
391
+ if hours < 1:
392
+ minutes = int(hours * 60)
393
+ return f"{minutes}m"
394
+ elif hours < 24:
395
+ whole_hours = int(hours)
396
+ minutes = int((hours - whole_hours) * 60)
397
+ if minutes > 0:
398
+ return f"{whole_hours}h {minutes}m"
399
+ else:
400
+ return f"{whole_hours}h"
401
+ else:
402
+ days = int(hours / 24)
403
+ remaining_hours = int(hours % 24)
404
+ if remaining_hours > 0:
405
+ return f"{days}d {remaining_hours}h"
406
+ else:
407
+ return f"{days}d"
408
+
409
+ def calculate_current_game_stats(all_lines):
410
+ """Calculate statistics for the currently active game (most recent entry)."""
411
+ if not all_lines:
412
+ return None
413
+
414
+ # Sort lines by timestamp to find the most recent
415
+ sorted_lines = sorted(all_lines, key=lambda line: float(line.timestamp))
416
+
417
+ # Get the current game (game with most recent entry)
418
+ current_game_name = sorted_lines[-1].game_name or "Unknown Game"
419
+
420
+ # Filter lines for current game
421
+ current_game_lines = [line for line in all_lines if (line.game_name or "Unknown Game") == current_game_name]
422
+
423
+ if not current_game_lines:
424
+ return None
425
+
426
+ # Calculate basic statistics
427
+ total_characters = sum(len(line.line_text) if line.line_text else 0 for line in current_game_lines)
428
+ total_sentences = len(current_game_lines)
429
+
430
+ # Calculate actual reading time using AFK timer
431
+ timestamps = [float(line.timestamp) for line in current_game_lines]
432
+ min_timestamp = min(timestamps)
433
+ max_timestamp = max(timestamps)
434
+ total_time_seconds = calculate_actual_reading_time(timestamps)
435
+ total_time_hours = total_time_seconds / 3600
436
+
437
+ # Calculate reading speed (with edge case handling)
438
+ reading_speed = int(total_characters / total_time_hours) if total_time_hours > 0 else 0
439
+
440
+ # Calculate sessions (gaps of more than session_gap_seconds = new session)
441
+ sorted_timestamps = sorted(timestamps)
442
+ sessions = 1
443
+ for i in range(1, len(sorted_timestamps)):
444
+ time_gap = sorted_timestamps[i] - sorted_timestamps[i-1]
445
+ if time_gap > get_config().advanced.session_gap_seconds:
446
+ sessions += 1
447
+
448
+ # Calculate daily activity for progress trend
449
+ daily_activity = defaultdict(int)
450
+ for line in current_game_lines:
451
+ date_str = datetime.date.fromtimestamp(float(line.timestamp)).strftime('%Y-%m-%d')
452
+ daily_activity[date_str] += len(line.line_text) if line.line_text else 0
453
+
454
+ # Calculate monthly progress (last 30 days)
455
+ today = datetime.date.today()
456
+ monthly_chars = 0
457
+ for i in range(30):
458
+ date = today - datetime.timedelta(days=i)
459
+ date_str = date.strftime('%Y-%m-%d')
460
+ monthly_chars += daily_activity.get(date_str, 0)
461
+
462
+ # Calculate reading streak using time-based requirements
463
+ current_streak = calculate_time_based_streak(current_game_lines)
464
+
465
+ return {
466
+ 'game_name': current_game_name,
467
+ 'total_characters': total_characters,
468
+ 'total_characters_formatted': format_large_number(total_characters),
469
+ 'total_sentences': total_sentences,
470
+ 'total_time_hours': total_time_hours,
471
+ 'total_time_formatted': format_time_human_readable(total_time_hours),
472
+ 'reading_speed': reading_speed,
473
+ 'reading_speed_formatted': format_large_number(reading_speed),
474
+ 'sessions': sessions,
475
+ 'monthly_characters': monthly_chars,
476
+ 'monthly_characters_formatted': format_large_number(monthly_chars),
477
+ 'current_streak': current_streak,
478
+ 'first_date': datetime.date.fromtimestamp(min_timestamp).strftime('%Y-%m-%d'),
479
+ 'last_date': datetime.date.fromtimestamp(max_timestamp).strftime('%Y-%m-%d'),
480
+ 'daily_activity': dict(daily_activity)
481
+ }
482
+
483
+ def calculate_average_daily_reading_time(all_lines):
484
+ """
485
+ Calculate average reading time per day based only on days with reading activity.
486
+
487
+ Args:
488
+ all_lines: List of game lines
489
+
490
+ Returns:
491
+ float: Average reading time in hours per active day, 0 if no active days
492
+ """
493
+ if not all_lines:
494
+ return 0.0
495
+
496
+ # Calculate daily reading time using existing function
497
+ daily_reading_time = calculate_daily_reading_time(all_lines)
498
+
499
+ if not daily_reading_time:
500
+ return 0.0
501
+
502
+ # Count only days with reading activity > 0
503
+ active_days = [day_hours for day_hours in daily_reading_time.values() if day_hours > 0]
504
+
505
+ if not active_days:
506
+ return 0.0
507
+
508
+ # Calculate average: total hours / number of active days
509
+ total_hours = sum(active_days)
510
+ average_hours = total_hours / len(active_days)
511
+
512
+ return average_hours
513
+
514
+ def calculate_all_games_stats(all_lines):
515
+ """Calculate aggregate statistics for all games combined."""
516
+ if not all_lines:
517
+ return None
518
+
519
+ # Calculate basic statistics
520
+ total_characters = sum(len(line.line_text) if line.line_text else 0 for line in all_lines)
521
+ total_sentences = len(all_lines)
522
+
523
+ # Calculate actual reading time using AFK timer
524
+ timestamps = [float(line.timestamp) for line in all_lines]
525
+ min_timestamp = min(timestamps)
526
+ max_timestamp = max(timestamps)
527
+ total_time_seconds = calculate_actual_reading_time(timestamps)
528
+ total_time_hours = total_time_seconds / 3600
529
+
530
+ # Calculate reading speed (with edge case handling)
531
+ reading_speed = int(total_characters / total_time_hours) if total_time_hours > 0 else 0
532
+
533
+ # Calculate sessions across all games (gaps of more than 1 hour = new session)
534
+ sorted_timestamps = sorted(timestamps)
535
+ sessions = 1
536
+ for i in range(1, len(sorted_timestamps)):
537
+ time_gap = sorted_timestamps[i] - sorted_timestamps[i-1]
538
+ if time_gap > 3600: # 1 hour gap
539
+ sessions += 1
540
+
541
+ # Calculate daily activity for progress trend
542
+ daily_activity = defaultdict(int)
543
+ for line in all_lines:
544
+ date_str = datetime.date.fromtimestamp(float(line.timestamp)).strftime('%Y-%m-%d')
545
+ daily_activity[date_str] += len(line.line_text) if line.line_text else 0
546
+
547
+ # Calculate monthly progress (last 30 days)
548
+ today = datetime.date.today()
549
+ monthly_chars = 0
550
+ for i in range(30):
551
+ date = today - datetime.timedelta(days=i)
552
+ date_str = date.strftime('%Y-%m-%d')
553
+ monthly_chars += daily_activity.get(date_str, 0)
554
+
555
+ # Calculate reading streak using time-based requirements
556
+ current_streak = calculate_time_based_streak(all_lines)
557
+
558
+ # Calculate average daily reading time
559
+ avg_daily_time_hours = calculate_average_daily_reading_time(all_lines)
560
+
561
+ # Count unique games
562
+ unique_games = len(set(line.game_name or "Unknown Game" for line in all_lines))
563
+
564
+ return {
565
+ 'total_characters': total_characters,
566
+ 'total_characters_formatted': format_large_number(total_characters),
567
+ 'total_sentences': total_sentences,
568
+ 'total_time_hours': total_time_hours,
569
+ 'total_time_formatted': format_time_human_readable(total_time_hours),
570
+ 'reading_speed': reading_speed,
571
+ 'reading_speed_formatted': format_large_number(reading_speed),
572
+ 'sessions': sessions,
573
+ 'unique_games': unique_games,
574
+ 'monthly_characters': monthly_chars,
575
+ 'monthly_characters_formatted': format_large_number(monthly_chars),
576
+ 'current_streak': current_streak,
577
+ 'avg_daily_time_hours': avg_daily_time_hours,
578
+ 'avg_daily_time_formatted': format_time_human_readable(avg_daily_time_hours),
579
+ 'first_date': datetime.date.fromtimestamp(min_timestamp).strftime('%Y-%m-%d'),
580
+ 'last_date': datetime.date.fromtimestamp(max_timestamp).strftime('%Y-%m-%d'),
581
+ 'daily_activity': dict(daily_activity)
582
+ }