GameSentenceMiner 2.19.16__py3-none-any.whl → 2.20.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of GameSentenceMiner might be problematic. Click here for more details.

Files changed (70) hide show
  1. GameSentenceMiner/__init__.py +39 -0
  2. GameSentenceMiner/anki.py +6 -3
  3. GameSentenceMiner/gametext.py +13 -2
  4. GameSentenceMiner/gsm.py +40 -3
  5. GameSentenceMiner/locales/en_us.json +4 -0
  6. GameSentenceMiner/locales/ja_jp.json +4 -0
  7. GameSentenceMiner/locales/zh_cn.json +4 -0
  8. GameSentenceMiner/obs.py +4 -1
  9. GameSentenceMiner/owocr/owocr/ocr.py +304 -134
  10. GameSentenceMiner/owocr/owocr/run.py +1 -1
  11. GameSentenceMiner/ui/anki_confirmation.py +4 -2
  12. GameSentenceMiner/ui/config_gui.py +12 -0
  13. GameSentenceMiner/util/configuration.py +6 -2
  14. GameSentenceMiner/util/cron/__init__.py +12 -0
  15. GameSentenceMiner/util/cron/daily_rollup.py +613 -0
  16. GameSentenceMiner/util/cron/jiten_update.py +397 -0
  17. GameSentenceMiner/util/cron/populate_games.py +154 -0
  18. GameSentenceMiner/util/cron/run_crons.py +148 -0
  19. GameSentenceMiner/util/cron/setup_populate_games_cron.py +118 -0
  20. GameSentenceMiner/util/cron_table.py +334 -0
  21. GameSentenceMiner/util/db.py +236 -49
  22. GameSentenceMiner/util/ffmpeg.py +23 -4
  23. GameSentenceMiner/util/games_table.py +340 -93
  24. GameSentenceMiner/util/jiten_api_client.py +188 -0
  25. GameSentenceMiner/util/stats_rollup_table.py +216 -0
  26. GameSentenceMiner/web/anki_api_endpoints.py +438 -220
  27. GameSentenceMiner/web/database_api.py +955 -1259
  28. GameSentenceMiner/web/jiten_database_api.py +1015 -0
  29. GameSentenceMiner/web/rollup_stats.py +672 -0
  30. GameSentenceMiner/web/static/css/dashboard-shared.css +75 -13
  31. GameSentenceMiner/web/static/css/overview.css +604 -47
  32. GameSentenceMiner/web/static/css/search.css +226 -0
  33. GameSentenceMiner/web/static/css/shared.css +762 -0
  34. GameSentenceMiner/web/static/css/stats.css +221 -0
  35. GameSentenceMiner/web/static/js/components/bar-chart.js +339 -0
  36. GameSentenceMiner/web/static/js/database-bulk-operations.js +320 -0
  37. GameSentenceMiner/web/static/js/database-game-data.js +390 -0
  38. GameSentenceMiner/web/static/js/database-game-operations.js +213 -0
  39. GameSentenceMiner/web/static/js/database-helpers.js +44 -0
  40. GameSentenceMiner/web/static/js/database-jiten-integration.js +750 -0
  41. GameSentenceMiner/web/static/js/database-popups.js +89 -0
  42. GameSentenceMiner/web/static/js/database-tabs.js +64 -0
  43. GameSentenceMiner/web/static/js/database-text-management.js +371 -0
  44. GameSentenceMiner/web/static/js/database.js +86 -718
  45. GameSentenceMiner/web/static/js/goals.js +79 -18
  46. GameSentenceMiner/web/static/js/heatmap.js +29 -23
  47. GameSentenceMiner/web/static/js/overview.js +1205 -339
  48. GameSentenceMiner/web/static/js/regex-patterns.js +100 -0
  49. GameSentenceMiner/web/static/js/search.js +215 -18
  50. GameSentenceMiner/web/static/js/shared.js +193 -39
  51. GameSentenceMiner/web/static/js/stats.js +1536 -179
  52. GameSentenceMiner/web/stats.py +1142 -269
  53. GameSentenceMiner/web/stats_api.py +2104 -0
  54. GameSentenceMiner/web/templates/anki_stats.html +4 -18
  55. GameSentenceMiner/web/templates/components/date-range.html +118 -3
  56. GameSentenceMiner/web/templates/components/html-head.html +40 -6
  57. GameSentenceMiner/web/templates/components/js-config.html +8 -8
  58. GameSentenceMiner/web/templates/components/regex-input.html +160 -0
  59. GameSentenceMiner/web/templates/database.html +564 -117
  60. GameSentenceMiner/web/templates/goals.html +41 -5
  61. GameSentenceMiner/web/templates/overview.html +159 -129
  62. GameSentenceMiner/web/templates/search.html +78 -9
  63. GameSentenceMiner/web/templates/stats.html +159 -5
  64. GameSentenceMiner/web/texthooking_page.py +280 -111
  65. {gamesentenceminer-2.19.16.dist-info → gamesentenceminer-2.20.0.dist-info}/METADATA +43 -2
  66. {gamesentenceminer-2.19.16.dist-info → gamesentenceminer-2.20.0.dist-info}/RECORD +70 -47
  67. {gamesentenceminer-2.19.16.dist-info → gamesentenceminer-2.20.0.dist-info}/WHEEL +0 -0
  68. {gamesentenceminer-2.19.16.dist-info → gamesentenceminer-2.20.0.dist-info}/entry_points.txt +0 -0
  69. {gamesentenceminer-2.19.16.dist-info → gamesentenceminer-2.20.0.dist-info}/licenses/LICENSE +0 -0
  70. {gamesentenceminer-2.19.16.dist-info → gamesentenceminer-2.20.0.dist-info}/top_level.txt +0 -0
@@ -10,6 +10,29 @@
10
10
  border: 1px solid var(--border-color);
11
11
  }
12
12
 
13
+ .chart-toggle-btn {
14
+ background: var(--accent-color);
15
+ color: white;
16
+ border: none;
17
+ padding: 8px 16px;
18
+ border-radius: 6px;
19
+ cursor: pointer;
20
+ font-size: 14px;
21
+ font-weight: 600;
22
+ transition: all 0.3s ease;
23
+ white-space: nowrap;
24
+ }
25
+
26
+ .chart-toggle-btn:hover {
27
+ background: #0056b3;
28
+ transform: translateY(-1px);
29
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
30
+ }
31
+
32
+ .chart-toggle-btn:active {
33
+ transform: translateY(0);
34
+ }
35
+
13
36
  .heatmap-year {
14
37
  margin-bottom: 30px;
15
38
  }
@@ -443,6 +466,204 @@
443
466
  margin-bottom: 16px;
444
467
  }
445
468
 
469
+ /* ================================
470
+ Game Milestones Card Styles
471
+ ================================ */
472
+ .game-milestones {
473
+ margin-bottom: 24px;
474
+ }
475
+
476
+ .game-milestones-grid {
477
+ display: grid;
478
+ grid-template-columns: repeat(2, 1fr);
479
+ gap: 20px;
480
+ margin-top: 16px;
481
+ }
482
+
483
+ .milestone-game-card {
484
+ background: var(--bg-tertiary);
485
+ border-radius: 12px;
486
+ padding: 20px;
487
+ border: 1px solid var(--border-color);
488
+ transition: all 0.3s ease;
489
+ display: flex;
490
+ gap: 16px;
491
+ align-items: flex-start;
492
+ }
493
+
494
+ .milestone-game-card:hover {
495
+ background: var(--border-color);
496
+ transform: translateY(-2px);
497
+ box-shadow: 0 4px 12px var(--shadow-color);
498
+ }
499
+
500
+ .milestone-game-image-container {
501
+ flex-shrink: 0;
502
+ }
503
+
504
+ .milestone-game-image {
505
+ width: 120px;
506
+ height: 160px;
507
+ object-fit: cover;
508
+ border-radius: 8px;
509
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
510
+ border: 1px solid var(--border-color);
511
+ background: var(--bg-secondary);
512
+ }
513
+
514
+ .milestone-game-image.placeholder {
515
+ display: flex;
516
+ align-items: center;
517
+ justify-content: center;
518
+ font-size: 48px;
519
+ opacity: 0.3;
520
+ }
521
+
522
+ .milestone-game-info {
523
+ flex: 1;
524
+ display: flex;
525
+ flex-direction: column;
526
+ gap: 8px;
527
+ min-width: 0;
528
+ }
529
+
530
+ .milestone-label {
531
+ font-size: 11px;
532
+ font-weight: 700;
533
+ text-transform: uppercase;
534
+ letter-spacing: 0.8px;
535
+ color: var(--text-tertiary);
536
+ margin-bottom: 4px;
537
+ }
538
+
539
+ .milestone-label.oldest {
540
+ color: #e6dc2e;
541
+ }
542
+
543
+ .milestone-label.newest {
544
+ color: #2ee6e0;
545
+ }
546
+
547
+ .milestone-game-title {
548
+ font-size: 18px;
549
+ font-weight: 700;
550
+ color: var(--text-primary);
551
+ line-height: 1.3;
552
+ margin-bottom: 2px;
553
+ overflow: hidden;
554
+ text-overflow: ellipsis;
555
+ display: -webkit-box;
556
+ -webkit-line-clamp: 2;
557
+ -webkit-box-orient: vertical;
558
+ }
559
+
560
+ .milestone-game-subtitle {
561
+ font-size: 13px;
562
+ font-weight: 500;
563
+ color: var(--text-secondary);
564
+ font-style: italic;
565
+ margin-bottom: 8px;
566
+ }
567
+
568
+ .milestone-game-details {
569
+ display: flex;
570
+ flex-direction: column;
571
+ gap: 6px;
572
+ font-size: 12px;
573
+ color: var(--text-secondary);
574
+ }
575
+
576
+ .milestone-detail-row {
577
+ display: flex;
578
+ align-items: center;
579
+ gap: 6px;
580
+ }
581
+
582
+ .milestone-detail-icon {
583
+ font-size: 14px;
584
+ opacity: 0.7;
585
+ }
586
+
587
+ .milestone-detail-label {
588
+ font-weight: 600;
589
+ color: var(--text-tertiary);
590
+ min-width: 80px;
591
+ }
592
+
593
+ .milestone-detail-value {
594
+ color: var(--text-primary);
595
+ font-weight: 500;
596
+ }
597
+
598
+ .milestone-type-badge {
599
+ display: inline-block;
600
+ padding: 3px 10px;
601
+ background: var(--accent-color);
602
+ color: white;
603
+ border-radius: 12px;
604
+ font-size: 10px;
605
+ font-weight: 700;
606
+ text-transform: uppercase;
607
+ letter-spacing: 0.5px;
608
+ }
609
+
610
+ .milestone-no-data {
611
+ text-align: center;
612
+ padding: 40px 20px;
613
+ color: var(--text-tertiary);
614
+ font-style: italic;
615
+ grid-column: 1 / -1;
616
+ }
617
+
618
+ .milestone-no-data::before {
619
+ content: '📅';
620
+ display: block;
621
+ font-size: 48px;
622
+ margin-bottom: 12px;
623
+ opacity: 0.5;
624
+ }
625
+
626
+ /* Responsive Design for Game Milestones */
627
+ @media (max-width: 768px) {
628
+ .game-milestones-grid {
629
+ grid-template-columns: 1fr;
630
+ gap: 16px;
631
+ }
632
+
633
+ .milestone-game-card {
634
+ flex-direction: column;
635
+ align-items: center;
636
+ text-align: center;
637
+ }
638
+
639
+ .milestone-game-info {
640
+ align-items: center;
641
+ }
642
+
643
+ .milestone-game-details {
644
+ align-items: center;
645
+ }
646
+
647
+ .milestone-detail-row {
648
+ justify-content: center;
649
+ }
650
+ }
651
+
652
+ @media (max-width: 480px) {
653
+ .milestone-game-image {
654
+ width: 100px;
655
+ height: 133px;
656
+ }
657
+
658
+ .milestone-game-title {
659
+ font-size: 16px;
660
+ }
661
+
662
+ .milestone-game-subtitle {
663
+ font-size: 12px;
664
+ }
665
+ }
666
+
446
667
  /* Dashboard card, stats grid, progress section, streak indicator, and date range styles are now in dashboard-shared.css */
447
668
 
448
669
  /* ================================
@@ -0,0 +1,339 @@
1
+ /**
2
+ * Unified Bar Chart Component for GSM Statistics
3
+ * Provides a consistent interface for creating bar charts with the app's color system
4
+ */
5
+
6
+ class BarChartComponent {
7
+ constructor(canvasId, options = {}) {
8
+ this.canvasId = canvasId;
9
+ this.canvas = document.getElementById(canvasId);
10
+ this.chart = null;
11
+
12
+ // Default options
13
+ this.options = {
14
+ title: options.title || '',
15
+ type: options.type || 'vertical', // 'vertical' or 'horizontal'
16
+ yAxisLabel: options.yAxisLabel || '',
17
+ xAxisLabel: options.xAxisLabel || '',
18
+ colorScheme: options.colorScheme || 'gradient', // 'gradient', 'fixed', 'weekendHighlight', 'performance'
19
+ showLegend: options.showLegend !== undefined ? options.showLegend : false,
20
+ tooltipFormatter: options.tooltipFormatter || null,
21
+ valueFormatter: options.valueFormatter || null,
22
+ ...options
23
+ };
24
+ }
25
+
26
+ /**
27
+ * Generate colors based on the specified color scheme
28
+ */
29
+ generateColors(data, scheme = 'gradient') {
30
+ const length = data.length;
31
+ const colors = [];
32
+ const borderColors = [];
33
+ const isDark = getCurrentTheme() === 'dark';
34
+
35
+ switch (scheme) {
36
+ case 'gradient':
37
+ // Orange to blue gradient based on normalized values (performance theme)
38
+ const maxVal = Math.max(...data.filter(v => v > 0));
39
+ const minVal = Math.min(...data.filter(v => v > 0));
40
+
41
+ data.forEach(value => {
42
+ if (value === 0) {
43
+ colors.push(isDark ? 'rgba(100, 100, 100, 0.3)' : 'rgba(200, 200, 200, 0.3)');
44
+ borderColors.push(isDark ? 'rgba(100, 100, 100, 0.6)' : 'rgba(200, 200, 200, 0.6)');
45
+ } else {
46
+ const normalized = maxVal > minVal ? (value - minVal) / (maxVal - minVal) : 0.5;
47
+ const hue = 30 + (normalized * 170); // 30 = orange, 200 = blue
48
+ colors.push(`hsla(${hue}, 70%, 50%, 0.8)`);
49
+ borderColors.push(`hsla(${hue}, 70%, 40%, 1)`);
50
+ }
51
+ });
52
+ break;
53
+
54
+ case 'reverseGradient':
55
+ // Blue to orange gradient (for difficulty, etc.)
56
+ data.forEach((_, index) => {
57
+ const ratio = index / Math.max(length - 1, 1);
58
+ const hue = 200 - (ratio * 170); // 200 (blue) to 30 (orange)
59
+ colors.push(`hsla(${hue}, 70%, 50%, 0.8)`);
60
+ borderColors.push(`hsla(${hue}, 70%, 40%, 1)`);
61
+ });
62
+ break;
63
+
64
+ case 'fixed':
65
+ // Fixed color scheme for day of week - cohesive gradient
66
+ const fixedColors = [
67
+ 'rgba(54, 162, 235, 0.8)', // Monday - Blue
68
+ 'rgba(75, 192, 192, 0.8)', // Tuesday - Teal
69
+ 'rgba(102, 187, 106, 0.8)', // Wednesday - Green
70
+ 'rgba(255, 167, 38, 0.8)', // Thursday - Orange
71
+ 'rgba(239, 83, 80, 0.8)', // Friday - Red
72
+ 'rgba(171, 71, 188, 0.8)', // Saturday - Purple
73
+ 'rgba(126, 87, 194, 0.8)' // Sunday - Deep Purple
74
+ ];
75
+ const fixedBorders = [
76
+ 'rgba(54, 162, 235, 1)',
77
+ 'rgba(75, 192, 192, 1)',
78
+ 'rgba(102, 187, 106, 1)',
79
+ 'rgba(255, 167, 38, 1)',
80
+ 'rgba(239, 83, 80, 1)',
81
+ 'rgba(171, 71, 188, 1)',
82
+ 'rgba(126, 87, 194, 1)'
83
+ ];
84
+ colors.push(...fixedColors.slice(0, length));
85
+ borderColors.push(...fixedBorders.slice(0, length));
86
+ break;
87
+
88
+ case 'weekendHighlight':
89
+ // Highlight weekends with different colors
90
+ this.options.labels.forEach((dateStr, index) => {
91
+ const date = new Date(dateStr);
92
+ const dayOfWeek = date.getDay();
93
+ const isWeekend = dayOfWeek === 0 || dayOfWeek === 6;
94
+
95
+ if (data[index] === 0) {
96
+ colors.push(isDark ? 'rgba(100, 100, 100, 0.3)' : 'rgba(200, 200, 200, 0.3)');
97
+ borderColors.push(isDark ? 'rgba(100, 100, 100, 0.6)' : 'rgba(200, 200, 200, 0.6)');
98
+ } else if (isWeekend) {
99
+ colors.push(this.options.weekendColor || 'rgba(171, 71, 188, 0.8)');
100
+ borderColors.push(this.options.weekendBorderColor || 'rgba(171, 71, 188, 1)');
101
+ } else {
102
+ colors.push(this.options.weekdayColor || 'rgba(54, 162, 235, 0.8)');
103
+ borderColors.push(this.options.weekdayBorderColor || 'rgba(54, 162, 235, 1)');
104
+ }
105
+ });
106
+ break;
107
+
108
+ case 'performance':
109
+ // Blue gradient for top performance charts
110
+ data.forEach((_, index) => {
111
+ const reverseIndex = length - 1 - index;
112
+ const hue = 200 - (reverseIndex * 15); // 200 (blue) to 155 (cyan)
113
+ colors.push(`hsla(${hue}, 70%, 50%, 0.8)`);
114
+ borderColors.push(`hsla(${hue}, 70%, 40%, 1)`);
115
+ });
116
+ break;
117
+
118
+ case 'single':
119
+ // Single primary color
120
+ const primaryColor = isDark ? '#4e9fff' : '#2980b9';
121
+ data.forEach(() => {
122
+ colors.push(`${primaryColor}CC`);
123
+ borderColors.push(primaryColor);
124
+ });
125
+ break;
126
+
127
+ default:
128
+ // Default to gradient
129
+ return this.generateColors(data, 'gradient');
130
+ }
131
+
132
+ return { colors, borderColors };
133
+ }
134
+
135
+ /**
136
+ * Create the bar chart
137
+ */
138
+ render(data, labels) {
139
+ if (!this.canvas) {
140
+ console.error(`Canvas with id '${this.canvasId}' not found`);
141
+ return null;
142
+ }
143
+
144
+ // Destroy existing chart
145
+ if (this.chart) {
146
+ this.chart.destroy();
147
+ }
148
+
149
+ const ctx = this.canvas.getContext('2d');
150
+ const { colors, borderColors } = this.generateColors(data, this.options.colorScheme);
151
+
152
+ const chartConfig = {
153
+ type: 'bar',
154
+ data: {
155
+ labels: labels,
156
+ datasets: [{
157
+ label: this.options.datasetLabel || this.options.title,
158
+ data: data,
159
+ backgroundColor: colors,
160
+ borderColor: borderColors,
161
+ borderWidth: 2,
162
+ borderRadius: this.options.borderRadius || 4
163
+ }]
164
+ },
165
+ options: {
166
+ indexAxis: this.options.type === 'horizontal' ? 'y' : 'x',
167
+ responsive: true,
168
+ plugins: {
169
+ legend: {
170
+ display: this.options.showLegend,
171
+ labels: {
172
+ color: getThemeTextColor()
173
+ }
174
+ },
175
+ title: {
176
+ display: !!this.options.title,
177
+ text: this.options.title,
178
+ color: getThemeTextColor(),
179
+ font: {
180
+ size: 16,
181
+ weight: 'bold'
182
+ },
183
+ padding: {
184
+ top: 10,
185
+ bottom: 20
186
+ }
187
+ },
188
+ tooltip: {
189
+ callbacks: {
190
+ title: (context) => {
191
+ if (this.options.tooltipFormatter?.title) {
192
+ return this.options.tooltipFormatter.title(context);
193
+ }
194
+ // Get the actual label from the dataset
195
+ const index = context[0].dataIndex;
196
+ return labels[index];
197
+ },
198
+ label: (context) => {
199
+ if (this.options.tooltipFormatter?.label) {
200
+ return this.options.tooltipFormatter.label(context);
201
+ }
202
+ // For horizontal charts, value is in parsed.x; for vertical, it's in parsed.y
203
+ const value = chartConfig.options.indexAxis === 'y' ? context.parsed.x : context.parsed.y;
204
+ if (this.options.valueFormatter) {
205
+ return this.options.valueFormatter(value);
206
+ }
207
+ return `${this.options.datasetLabel || 'Value'}: ${value.toLocaleString()}`;
208
+ },
209
+ afterLabel: (context) => {
210
+ if (this.options.tooltipFormatter?.afterLabel) {
211
+ return this.options.tooltipFormatter.afterLabel(context);
212
+ }
213
+ return '';
214
+ }
215
+ },
216
+ backgroundColor: 'rgba(0, 0, 0, 0.9)',
217
+ titleColor: '#fff',
218
+ bodyColor: '#fff',
219
+ borderColor: 'rgba(255, 255, 255, 0.2)',
220
+ borderWidth: 1,
221
+ cornerRadius: 8,
222
+ displayColors: true
223
+ }
224
+ },
225
+ scales: {
226
+ y: {
227
+ beginAtZero: true,
228
+ title: {
229
+ display: !!this.options.yAxisLabel,
230
+ text: this.options.yAxisLabel,
231
+ color: getThemeTextColor()
232
+ },
233
+ ticks: {
234
+ color: getThemeTextColor(),
235
+ callback: function(value) {
236
+ // For horizontal charts, Y-axis shows labels (dates, etc.)
237
+ if (chartConfig.options.indexAxis === 'y') {
238
+ return this.getLabelForValue(value);
239
+ }
240
+ // For vertical charts, Y-axis shows numeric values
241
+ if (chartConfig.options.yAxisFormatter) {
242
+ return chartConfig.options.yAxisFormatter(value);
243
+ }
244
+ return value.toLocaleString();
245
+ }
246
+ },
247
+ grid: {
248
+ color: getCurrentTheme() === 'dark'
249
+ ? 'rgba(255, 255, 255, 0.1)'
250
+ : 'rgba(0, 0, 0, 0.1)'
251
+ }
252
+ },
253
+ x: {
254
+ title: {
255
+ display: !!this.options.xAxisLabel,
256
+ text: this.options.xAxisLabel,
257
+ color: getThemeTextColor()
258
+ },
259
+ ticks: {
260
+ color: getThemeTextColor(),
261
+ maxRotation: this.options.maxRotation || 45,
262
+ minRotation: this.options.minRotation || 45,
263
+ callback: function(value) {
264
+ // For horizontal charts, X-axis shows numeric values
265
+ if (chartConfig.options.indexAxis === 'y') {
266
+ return value.toLocaleString();
267
+ }
268
+ // For vertical charts, X-axis shows labels
269
+ return this.getLabelForValue(value);
270
+ }
271
+ },
272
+ grid: {
273
+ display: this.options.type === 'horizontal'
274
+ }
275
+ }
276
+ },
277
+ animation: {
278
+ duration: 1000,
279
+ easing: 'easeOutQuart'
280
+ }
281
+ }
282
+ };
283
+
284
+ this.chart = new Chart(ctx, chartConfig);
285
+
286
+ // Store in global charts object
287
+ if (window.myCharts) {
288
+ window.myCharts[this.canvasId] = this.chart;
289
+ }
290
+
291
+ return this.chart;
292
+ }
293
+
294
+ /**
295
+ * Update chart data
296
+ */
297
+ update(data, labels) {
298
+ if (!this.chart) {
299
+ return this.render(data, labels);
300
+ }
301
+
302
+ const { colors, borderColors } = this.generateColors(data, this.options.colorScheme);
303
+
304
+ this.chart.data.labels = labels;
305
+ this.chart.data.datasets[0].data = data;
306
+ this.chart.data.datasets[0].backgroundColor = colors;
307
+ this.chart.data.datasets[0].borderColor = borderColors;
308
+ this.chart.update();
309
+ }
310
+
311
+ /**
312
+ * Destroy the chart
313
+ */
314
+ destroy() {
315
+ if (this.chart) {
316
+ this.chart.destroy();
317
+ this.chart = null;
318
+ }
319
+ }
320
+ }
321
+
322
+ // Helper function to get theme text color (should match stats.js)
323
+ function getThemeTextColor() {
324
+ const theme = getCurrentTheme();
325
+ return theme === 'dark' ? '#fff' : '#222';
326
+ }
327
+
328
+ // Helper function to get current theme (should match stats.js)
329
+ function getCurrentTheme() {
330
+ const dataTheme = document.documentElement.getAttribute('data-theme');
331
+ if (dataTheme === 'dark' || dataTheme === 'light') {
332
+ return dataTheme;
333
+ }
334
+
335
+ if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
336
+ return 'dark';
337
+ }
338
+ return 'light';
339
+ }