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
@@ -529,8 +529,1097 @@ document.addEventListener('DOMContentLoaded', function () {
529
529
 
530
530
  // Function to create hourly activity bar chart
531
531
  function createHourlyActivityChart(canvasId, hourlyData) {
532
+ if (!hourlyData || !Array.isArray(hourlyData)) return null;
533
+
534
+ // Create hour labels (0-23)
535
+ const hourLabels = [];
536
+ for (let i = 0; i < 24; i++) {
537
+ const hour12 = i === 0 ? 12 : i > 12 ? i - 12 : i;
538
+ const ampm = i < 12 ? 'AM' : 'PM';
539
+ hourLabels.push(`${hour12}${ampm}`);
540
+ }
541
+
542
+ const chart = new BarChartComponent(canvasId, {
543
+ title: 'Reading Activity by Hour of Day',
544
+ colorScheme: 'gradient',
545
+ yAxisLabel: 'Characters Read',
546
+ xAxisLabel: 'Hour of Day',
547
+ datasetLabel: 'Characters Read',
548
+ maxRotation: 0,
549
+ minRotation: 0,
550
+ tooltipFormatter: {
551
+ title: (context) => {
552
+ const hourIndex = context[0].dataIndex;
553
+ const hour24 = hourIndex;
554
+ const hour12 = hour24 === 0 ? 12 : hour24 > 12 ? hour24 - 12 : hour24;
555
+ const ampm = hour24 < 12 ? 'AM' : 'PM';
556
+ return `${hour12}:00 ${ampm} (${hour24}:00)`;
557
+ },
558
+ label: (context) => {
559
+ const activity = context.parsed.y;
560
+ if (activity === 0) {
561
+ return 'No reading activity';
562
+ }
563
+ return `Characters: ${activity.toLocaleString()}`;
564
+ },
565
+ afterLabel: (context) => {
566
+ const activity = context.parsed.y;
567
+ if (activity === 0) return '';
568
+
569
+ const total = hourlyData.reduce((sum, val) => sum + val, 0);
570
+ const percentage = total > 0 ? ((activity / total) * 100).toFixed(1) : '0.0';
571
+ return `${percentage}% of total activity`;
572
+ }
573
+ }
574
+ });
575
+
576
+ return chart.render(hourlyData, hourLabels);
577
+ }
578
+
579
+ // Function to create top 5 reading speed days horizontal bar chart
580
+ function createTopReadingSpeedDaysChart(canvasId, readingSpeedHeatmapData) {
581
+ const canvas = document.getElementById(canvasId);
582
+ if (!canvas || !readingSpeedHeatmapData) return null;
583
+
584
+ const ctx = canvas.getContext('2d');
585
+
586
+ // Destroy existing chart if it exists
587
+ if (window.myCharts[canvasId]) {
588
+ window.myCharts[canvasId].destroy();
589
+ }
590
+
591
+ // Extract all dates and speeds from heatmap data
592
+ const allDays = [];
593
+ for (const year in readingSpeedHeatmapData) {
594
+ for (const date in readingSpeedHeatmapData[year]) {
595
+ const speed = readingSpeedHeatmapData[year][date];
596
+ if (speed > 0) {
597
+ allDays.push({ date, speed });
598
+ }
599
+ }
600
+ }
601
+
602
+ // Sort by speed descending and take top 5
603
+ allDays.sort((a, b) => b.speed - a.speed);
604
+ const top5Days = allDays.slice(0, 5);
605
+
606
+ // If no data, show empty chart
607
+ if (top5Days.length === 0) {
608
+ return null;
609
+ }
610
+
611
+ // Prepare data for horizontal bar chart (reverse order so highest is on top)
612
+ const labels = top5Days.reverse().map(day => day.date);
613
+ const speeds = top5Days.map(day => day.speed);
614
+
615
+ // Generate gradient colors from blue (fastest) to teal (5th fastest) - performance theme
616
+ const colors = speeds.map((speed, index) => {
617
+ // Reverse index so top bar gets best color
618
+ const reverseIndex = speeds.length - 1 - index;
619
+ const hue = 200 - (reverseIndex * 15); // 200 (blue) to 155 (cyan)
620
+ return `hsla(${hue}, 70%, 50%, 0.8)`;
621
+ });
622
+
623
+ const borderColors = speeds.map((speed, index) => {
624
+ const reverseIndex = speeds.length - 1 - index;
625
+ const hue = 200 - (reverseIndex * 15);
626
+ return `hsla(${hue}, 70%, 40%, 1)`;
627
+ });
628
+
629
+ window.myCharts[canvasId] = new Chart(ctx, {
630
+ type: 'bar',
631
+ data: {
632
+ labels: labels,
633
+ datasets: [{
634
+ label: 'Reading Speed (chars/hour)',
635
+ data: speeds,
636
+ backgroundColor: colors,
637
+ borderColor: borderColors,
638
+ borderWidth: 2
639
+ }]
640
+ },
641
+ options: {
642
+ indexAxis: 'y', // This makes it horizontal
643
+ responsive: true,
644
+ plugins: {
645
+ legend: {
646
+ display: false
647
+ },
648
+ title: {
649
+ display: true,
650
+ text: 'Top 5 Fastest Reading Days',
651
+ color: getThemeTextColor(),
652
+ font: {
653
+ size: 16,
654
+ weight: 'bold'
655
+ },
656
+ padding: {
657
+ top: 10,
658
+ bottom: 20
659
+ }
660
+ },
661
+ tooltip: {
662
+ callbacks: {
663
+ title: function(context) {
664
+ return context[0].label;
665
+ },
666
+ label: function(context) {
667
+ const speed = context.parsed.x;
668
+ return `Speed: ${speed.toLocaleString()} chars/hour`;
669
+ }
670
+ },
671
+ backgroundColor: 'rgba(0, 0, 0, 0.9)',
672
+ titleColor: '#fff',
673
+ bodyColor: '#fff',
674
+ borderColor: 'rgba(255, 255, 255, 0.2)',
675
+ borderWidth: 1,
676
+ cornerRadius: 8,
677
+ displayColors: true
678
+ }
679
+ },
680
+ scales: {
681
+ x: {
682
+ beginAtZero: true,
683
+ title: {
684
+ display: true,
685
+ text: 'Reading Speed (chars/hour)',
686
+ color: getThemeTextColor()
687
+ },
688
+ ticks: {
689
+ color: getThemeTextColor(),
690
+ callback: function(value) {
691
+ return value.toLocaleString();
692
+ }
693
+ }
694
+ },
695
+ y: {
696
+ title: {
697
+ display: true,
698
+ text: 'Date',
699
+ color: getThemeTextColor()
700
+ },
701
+ ticks: {
702
+ color: getThemeTextColor()
703
+ }
704
+ }
705
+ },
706
+ animation: {
707
+ duration: 1000,
708
+ easing: 'easeOutQuart'
709
+ }
710
+ }
711
+ });
712
+
713
+ return window.myCharts[canvasId];
714
+ }
715
+
716
+ // Function to create day of week activity bar chart
717
+ function createDayOfWeekChart(canvasId, dayOfWeekData) {
718
+ const canvas = document.getElementById(canvasId);
719
+ if (!canvas || !dayOfWeekData) return null;
720
+
721
+ const ctx = canvas.getContext('2d');
722
+
723
+ // Destroy existing chart if it exists
724
+ if (window.myCharts[canvasId]) {
725
+ window.myCharts[canvasId].destroy();
726
+ }
727
+
728
+ // Day labels (Monday to Sunday)
729
+ const dayLabels = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
730
+
731
+ // Get data arrays
732
+ const charsData = dayOfWeekData.chars || [0, 0, 0, 0, 0, 0, 0];
733
+ const hoursData = dayOfWeekData.hours || [0, 0, 0, 0, 0, 0, 0];
734
+
735
+ // Generate colors for each day - cohesive blue-purple gradient
736
+ const colors = [
737
+ 'rgba(54, 162, 235, 0.8)', // Monday - Blue
738
+ 'rgba(75, 192, 192, 0.8)', // Tuesday - Teal
739
+ 'rgba(102, 187, 106, 0.8)', // Wednesday - Green
740
+ 'rgba(255, 167, 38, 0.8)', // Thursday - Orange
741
+ 'rgba(239, 83, 80, 0.8)', // Friday - Red
742
+ 'rgba(171, 71, 188, 0.8)', // Saturday - Purple
743
+ 'rgba(126, 87, 194, 0.8)' // Sunday - Deep Purple
744
+ ];
745
+
746
+ const borderColors = [
747
+ 'rgba(54, 162, 235, 1)',
748
+ 'rgba(75, 192, 192, 1)',
749
+ 'rgba(102, 187, 106, 1)',
750
+ 'rgba(255, 167, 38, 1)',
751
+ 'rgba(239, 83, 80, 1)',
752
+ 'rgba(171, 71, 188, 1)',
753
+ 'rgba(126, 87, 194, 1)'
754
+ ];
755
+
756
+ window.myCharts[canvasId] = new Chart(ctx, {
757
+ type: 'bar',
758
+ data: {
759
+ labels: dayLabels,
760
+ datasets: [{
761
+ label: 'Characters Read',
762
+ data: charsData,
763
+ backgroundColor: colors,
764
+ borderColor: borderColors,
765
+ borderWidth: 2,
766
+ yAxisID: 'y'
767
+ }, {
768
+ label: 'Hours Read',
769
+ data: hoursData,
770
+ backgroundColor: colors.map(c => c.replace('0.8', '0.4')),
771
+ borderColor: borderColors,
772
+ borderWidth: 2,
773
+ yAxisID: 'y1',
774
+ hidden: true
775
+ }]
776
+ },
777
+ options: {
778
+ responsive: true,
779
+ interaction: {
780
+ mode: 'index',
781
+ intersect: false
782
+ },
783
+ plugins: {
784
+ legend: {
785
+ position: 'top',
786
+ labels: {
787
+ color: getThemeTextColor()
788
+ }
789
+ },
790
+ title: {
791
+ display: true,
792
+ text: 'Reading Activity by Day of Week',
793
+ color: getThemeTextColor(),
794
+ font: {
795
+ size: 16,
796
+ weight: 'bold'
797
+ }
798
+ },
799
+ tooltip: {
800
+ callbacks: {
801
+ label: function(context) {
802
+ const label = context.dataset.label || '';
803
+ const value = context.parsed.y;
804
+ if (label === 'Characters Read') {
805
+ return `${label}: ${value.toLocaleString()} chars`;
806
+ } else {
807
+ return `${label}: ${value.toFixed(2)} hours`;
808
+ }
809
+ }
810
+ },
811
+ backgroundColor: 'rgba(0, 0, 0, 0.9)',
812
+ titleColor: '#fff',
813
+ bodyColor: '#fff'
814
+ }
815
+ },
816
+ scales: {
817
+ y: {
818
+ type: 'linear',
819
+ display: true,
820
+ position: 'left',
821
+ beginAtZero: true,
822
+ title: {
823
+ display: true,
824
+ text: 'Characters Read',
825
+ color: getThemeTextColor()
826
+ },
827
+ ticks: {
828
+ color: getThemeTextColor(),
829
+ callback: function(value) {
830
+ return value.toLocaleString();
831
+ }
832
+ }
833
+ },
834
+ y1: {
835
+ type: 'linear',
836
+ display: true,
837
+ position: 'right',
838
+ beginAtZero: true,
839
+ title: {
840
+ display: true,
841
+ text: 'Hours Read',
842
+ color: getThemeTextColor()
843
+ },
844
+ ticks: {
845
+ color: getThemeTextColor()
846
+ },
847
+ grid: {
848
+ drawOnChartArea: false
849
+ }
850
+ },
851
+ x: {
852
+ title: {
853
+ display: true,
854
+ text: 'Day of Week',
855
+ color: getThemeTextColor()
856
+ },
857
+ ticks: {
858
+ color: getThemeTextColor()
859
+ }
860
+ }
861
+ }
862
+ }
863
+ });
864
+
865
+ return window.myCharts[canvasId];
866
+ }
867
+
868
+ // Function to create average hours by day bar chart
869
+ function createAvgHoursByDayChart(canvasId, dayOfWeekData) {
870
+ if (!dayOfWeekData) return null;
871
+
872
+ const dayLabels = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
873
+ const hoursData = dayOfWeekData.avg_hours || [0, 0, 0, 0, 0, 0, 0];
874
+
875
+ const chart = new BarChartComponent(canvasId, {
876
+ title: 'Average Hours Read by Day of Week',
877
+ colorScheme: 'gradient',
878
+ yAxisLabel: 'Hours',
879
+ xAxisLabel: 'Day of Week',
880
+ datasetLabel: 'Hours Read',
881
+ maxRotation: 0,
882
+ minRotation: 0,
883
+ yAxisFormatter: (value) => value.toFixed(1),
884
+ tooltipFormatter: {
885
+ label: (context) => {
886
+ const hours = context.parsed.y;
887
+ if (hours === 0) {
888
+ return 'No reading activity';
889
+ }
890
+ return `Hours: ${hours.toFixed(2)}`;
891
+ },
892
+ afterLabel: (context) => {
893
+ const hours = context.parsed.y;
894
+ if (hours === 0) return '';
895
+
896
+ const nonZeroHours = hoursData.filter(h => h > 0);
897
+ if (nonZeroHours.length === 0) return '';
898
+
899
+ const avgHours = nonZeroHours.reduce((sum, h) => sum + h, 0) / nonZeroHours.length;
900
+ const comparison = hours > avgHours ? 'above' : hours < avgHours ? 'below' : 'at';
901
+ const percentage = avgHours > 0 ? Math.abs(((hours - avgHours) / avgHours) * 100).toFixed(1) : '0';
902
+
903
+ return `${percentage}% ${comparison} weekly average`;
904
+ }
905
+ }
906
+ });
907
+
908
+ return chart.render(hoursData, dayLabels);
909
+ }
910
+
911
+ // Function to create reading speed by difficulty bar chart
912
+ function createDifficultySpeedChart(canvasId, difficultySpeedData) {
913
+ const canvas = document.getElementById(canvasId);
914
+ const noDataEl = document.getElementById('difficultySpeedNoData');
915
+ if (!canvas) return null;
916
+
917
+ // Destroy existing chart if it exists
918
+ if (window.myCharts[canvasId]) {
919
+ window.myCharts[canvasId].destroy();
920
+ }
921
+
922
+ const labels = difficultySpeedData?.labels || [];
923
+ const speeds = difficultySpeedData?.speeds || [];
924
+
925
+ if (!difficultySpeedData || labels.length === 0) {
926
+ canvas.style.display = 'none';
927
+ if (noDataEl) {
928
+ noDataEl.style.display = 'block';
929
+ }
930
+ return null;
931
+ }
932
+
933
+ canvas.style.display = 'block';
934
+ if (noDataEl) {
935
+ noDataEl.style.display = 'none';
936
+ }
937
+
938
+ const ctx = canvas.getContext('2d');
939
+
940
+ // Generate gradient colors from blue (easy) to orange (hard) - difficulty theme
941
+ const colors = speeds.map((_, index) => {
942
+ const ratio = index / Math.max(labels.length - 1, 1);
943
+ const hue = 200 - (ratio * 170); // 200 (blue) to 30 (orange)
944
+ return `hsla(${hue}, 70%, 50%, 0.8)`;
945
+ });
946
+
947
+ const borderColors = speeds.map((_, index) => {
948
+ const ratio = index / Math.max(labels.length - 1, 1);
949
+ const hue = 200 - (ratio * 170);
950
+ return `hsla(${hue}, 70%, 40%, 1)`;
951
+ });
952
+
953
+ window.myCharts[canvasId] = new Chart(ctx, {
954
+ type: 'bar',
955
+ data: {
956
+ labels: labels,
957
+ datasets: [{
958
+ label: 'Average Reading Speed',
959
+ data: speeds,
960
+ backgroundColor: colors,
961
+ borderColor: borderColors,
962
+ borderWidth: 2
963
+ }]
964
+ },
965
+ options: {
966
+ responsive: true,
967
+ plugins: {
968
+ legend: {
969
+ display: false
970
+ },
971
+ title: {
972
+ display: true,
973
+ text: 'Average Reading Speed by Game Difficulty',
974
+ color: getThemeTextColor(),
975
+ font: {
976
+ size: 16,
977
+ weight: 'bold'
978
+ }
979
+ },
980
+ tooltip: {
981
+ callbacks: {
982
+ label: function(context) {
983
+ const speed = context.parsed.y;
984
+ return `Speed: ${speed.toLocaleString()} chars/hour`;
985
+ }
986
+ },
987
+ backgroundColor: 'rgba(0, 0, 0, 0.9)',
988
+ titleColor: '#fff',
989
+ bodyColor: '#fff'
990
+ }
991
+ },
992
+ scales: {
993
+ y: {
994
+ beginAtZero: true,
995
+ title: {
996
+ display: true,
997
+ text: 'Characters per Hour',
998
+ color: getThemeTextColor()
999
+ },
1000
+ ticks: {
1001
+ color: getThemeTextColor(),
1002
+ callback: function(value) {
1003
+ return value.toLocaleString();
1004
+ }
1005
+ }
1006
+ },
1007
+ x: {
1008
+ title: {
1009
+ display: true,
1010
+ text: 'Difficulty Level',
1011
+ color: getThemeTextColor()
1012
+ },
1013
+ ticks: {
1014
+ color: getThemeTextColor()
1015
+ }
1016
+ }
1017
+ }
1018
+ }
1019
+ });
1020
+
1021
+ return window.myCharts[canvasId];
1022
+ }
1023
+
1024
+ // Function to create game type distribution bar chart
1025
+ function createGameTypeChart(canvasId, gameTypeData) {
1026
+ const canvas = document.getElementById(canvasId);
1027
+ const noDataEl = document.getElementById('gameTypeNoData');
1028
+
1029
+ if (!canvas) return null;
1030
+
1031
+ if (!gameTypeData || !gameTypeData.labels || gameTypeData.labels.length === 0) {
1032
+ canvas.style.display = 'none';
1033
+ if (noDataEl) {
1034
+ noDataEl.style.display = 'block';
1035
+ }
1036
+ return null;
1037
+ }
1038
+
1039
+ canvas.style.display = 'block';
1040
+ if (noDataEl) {
1041
+ noDataEl.style.display = 'none';
1042
+ }
1043
+
1044
+ const chart = new BarChartComponent(canvasId, {
1045
+ title: 'Games by Type',
1046
+ colorScheme: 'gradient',
1047
+ yAxisLabel: 'Number of Games',
1048
+ xAxisLabel: 'Game Type',
1049
+ datasetLabel: 'Games',
1050
+ maxRotation: 45,
1051
+ minRotation: 45,
1052
+ tooltipFormatter: {
1053
+ label: (context) => {
1054
+ const count = context.parsed.y;
1055
+ const total = gameTypeData.counts.reduce((sum, val) => sum + val, 0);
1056
+ const percentage = total > 0 ? ((count / total) * 100).toFixed(1) : '0.0';
1057
+ return `Games: ${count} (${percentage}%)`;
1058
+ }
1059
+ }
1060
+ });
1061
+
1062
+ return chart.render(gameTypeData.counts, gameTypeData.labels);
1063
+ }
1064
+
1065
+
1066
+ function createCardsMinedChart(canvasId, chartData) {
1067
+ const canvas = document.getElementById(canvasId);
1068
+ const noDataEl = document.getElementById('cardsMinedNoData');
1069
+ if (!canvas) return null;
1070
+
1071
+ if (window.myCharts[canvasId]) {
1072
+ window.myCharts[canvasId].destroy();
1073
+ delete window.myCharts[canvasId];
1074
+ }
1075
+
1076
+ const hasLabels =
1077
+ chartData &&
1078
+ Array.isArray(chartData.labels) &&
1079
+ chartData.labels.length > 0;
1080
+
1081
+ const hasTotals =
1082
+ chartData &&
1083
+ Array.isArray(chartData.totals) &&
1084
+ chartData.totals.length > 0 &&
1085
+ chartData.labels.length === chartData.totals.length;
1086
+
1087
+ const hasNonZeroTotals = hasTotals
1088
+ ? chartData.totals.some((value) => Number(value) > 0)
1089
+ : false;
1090
+
1091
+ if (!hasLabels || !hasTotals || !hasNonZeroTotals) {
1092
+ canvas.style.display = 'none';
1093
+ if (noDataEl) {
1094
+ noDataEl.style.display = 'block';
1095
+ }
1096
+ return null;
1097
+ }
1098
+
1099
+ canvas.style.display = 'block';
1100
+ if (noDataEl) {
1101
+ noDataEl.style.display = 'none';
1102
+ }
1103
+
1104
+ const ctx = canvas.getContext('2d');
1105
+
1106
+ // Generate gradient colors based on values (more = greener)
1107
+ const maxVal = Math.max(...chartData.totals.filter(v => v > 0));
1108
+ const minVal = Math.min(...chartData.totals.filter(v => v > 0));
1109
+ const isDark = getCurrentTheme() === 'dark';
1110
+
1111
+ const barColors = chartData.totals.map(value => {
1112
+ if (value === 0) {
1113
+ return isDark ? 'rgba(100, 100, 100, 0.3)' : 'rgba(200, 200, 200, 0.3)';
1114
+ }
1115
+ const normalized = maxVal > minVal ? (value - minVal) / (maxVal - minVal) : 0.5;
1116
+ const hue = 30 + (normalized * 170); // 30 = orange, 200 = blue
1117
+ return `hsla(${hue}, 70%, 50%, 0.8)`;
1118
+ });
1119
+
1120
+ const borderColors = chartData.totals.map(value => {
1121
+ if (value === 0) {
1122
+ return isDark ? 'rgba(100, 100, 100, 0.6)' : 'rgba(200, 200, 200, 0.6)';
1123
+ }
1124
+ const normalized = maxVal > minVal ? (value - minVal) / (maxVal - minVal) : 0.5;
1125
+ const hue = 30 + (normalized * 170);
1126
+ return `hsla(${hue}, 70%, 40%, 1)`;
1127
+ });
1128
+
1129
+ // Format labels to show weekend indicator
1130
+ const formattedLabels = chartData.labels.map(dateStr => {
1131
+ const date = new Date(dateStr);
1132
+ const dayOfWeek = date.getDay();
1133
+ const isWeekend = dayOfWeek === 0 || dayOfWeek === 6;
1134
+ return isWeekend ? `${dateStr} 📅` : dateStr;
1135
+ });
1136
+
1137
+ window.myCharts[canvasId] = new Chart(ctx, {
1138
+ type: 'bar',
1139
+ data: {
1140
+ labels: formattedLabels,
1141
+ datasets: [
1142
+ {
1143
+ label: 'Cards Mined',
1144
+ data: chartData.totals,
1145
+ backgroundColor: barColors,
1146
+ borderColor: borderColors,
1147
+ borderWidth: 2,
1148
+ borderRadius: 4
1149
+ }
1150
+ ]
1151
+ },
1152
+ options: {
1153
+ responsive: true,
1154
+ plugins: {
1155
+ legend: {
1156
+ display: false
1157
+ },
1158
+ tooltip: {
1159
+ callbacks: {
1160
+ title: function (context) {
1161
+ const index = context[0].dataIndex;
1162
+ return chartData.labels[index];
1163
+ },
1164
+ label: function (context) {
1165
+ const value = context.parsed.y || 0;
1166
+ return `Cards: ${value.toLocaleString()}`;
1167
+ },
1168
+ afterLabel: function(context) {
1169
+ const index = context[0].dataIndex;
1170
+ const date = new Date(chartData.labels[index]);
1171
+ const dayOfWeek = date.getDay();
1172
+ const isWeekend = dayOfWeek === 0 || dayOfWeek === 6;
1173
+ return isWeekend ? '📅 Weekend' : '';
1174
+ }
1175
+ },
1176
+ backgroundColor: 'rgba(0, 0, 0, 0.85)',
1177
+ titleColor: '#fff',
1178
+ bodyColor: '#fff',
1179
+ borderColor: 'rgba(255, 255, 255, 0.2)',
1180
+ borderWidth: 1,
1181
+ cornerRadius: 6,
1182
+ displayColors: false
1183
+ }
1184
+ },
1185
+ scales: {
1186
+ y: {
1187
+ beginAtZero: true,
1188
+ title: {
1189
+ display: true,
1190
+ text: 'Cards Mined',
1191
+ color: getThemeTextColor()
1192
+ },
1193
+ ticks: {
1194
+ color: getThemeTextColor(),
1195
+ callback: function (value) {
1196
+ return value.toLocaleString();
1197
+ }
1198
+ },
1199
+ grid: {
1200
+ color:
1201
+ getCurrentTheme() === 'dark'
1202
+ ? 'rgba(255, 255, 255, 0.1)'
1203
+ : 'rgba(0, 0, 0, 0.1)'
1204
+ }
1205
+ },
1206
+ x: {
1207
+ title: {
1208
+ display: true,
1209
+ text: 'Date',
1210
+ color: getThemeTextColor()
1211
+ },
1212
+ ticks: {
1213
+ color: getThemeTextColor(),
1214
+ maxRotation: 45,
1215
+ minRotation: 45,
1216
+ callback: function (value) {
1217
+ return this.getLabelForValue(value);
1218
+ }
1219
+ },
1220
+ grid: {
1221
+ display: false
1222
+ }
1223
+ }
1224
+ }
1225
+ }
1226
+ });
1227
+
1228
+ return window.myCharts[canvasId];
1229
+ }
1230
+
1231
+ // Function to create top 5 character count days horizontal bar chart
1232
+ function createTopCharacterDaysChart(canvasId, heatmapData) {
1233
+ if (!heatmapData) return null;
1234
+
1235
+ // Extract all dates and character counts from heatmap data
1236
+ const allDays = [];
1237
+ for (const year in heatmapData) {
1238
+ for (const date in heatmapData[year]) {
1239
+ const chars = heatmapData[year][date];
1240
+ if (chars > 0) {
1241
+ allDays.push({ date, chars });
1242
+ }
1243
+ }
1244
+ }
1245
+
1246
+ // Sort by character count descending and take top 5
1247
+ allDays.sort((a, b) => b.chars - a.chars);
1248
+ const top5Days = allDays.slice(0, 5);
1249
+
1250
+ if (top5Days.length === 0) return null;
1251
+
1252
+ // Prepare data for horizontal bar chart (reverse order so highest is on top)
1253
+ const labels = top5Days.reverse().map(day => day.date);
1254
+ const charCounts = top5Days.map(day => day.chars);
1255
+
1256
+ const chart = new BarChartComponent(canvasId, {
1257
+ title: 'Top 5 Most Productive Reading Days',
1258
+ type: 'horizontal',
1259
+ colorScheme: 'performance',
1260
+ xAxisLabel: 'Characters Read',
1261
+ yAxisLabel: 'Date',
1262
+ datasetLabel: 'Characters Read',
1263
+ valueFormatter: (value) => `Characters: ${value.toLocaleString()}`
1264
+ });
1265
+
1266
+ return chart.render(charCounts, labels);
1267
+ }
1268
+
1269
+ // Function to create hourly reading speed bar chart
1270
+ function createHourlyReadingSpeedChart(canvasId, hourlySpeedData) {
1271
+ const canvas = document.getElementById(canvasId);
1272
+ if (!canvas || !hourlySpeedData || !Array.isArray(hourlySpeedData)) return null;
1273
+
1274
+ const ctx = canvas.getContext('2d');
1275
+
1276
+ // Destroy existing chart if it exists
1277
+ if (window.myCharts[canvasId]) {
1278
+ window.myCharts[canvasId].destroy();
1279
+ }
1280
+
1281
+ // Create hour labels (0-23)
1282
+ const hourLabels = [];
1283
+ for (let i = 0; i < 24; i++) {
1284
+ const hour12 = i === 0 ? 12 : i > 12 ? i - 12 : i;
1285
+ const ampm = i < 12 ? 'AM' : 'PM';
1286
+ hourLabels.push(`${hour12}${ampm}`);
1287
+ }
1288
+
1289
+ // Generate gradient colors for bars based on speed values
1290
+ const maxSpeed = Math.max(...hourlySpeedData.filter(speed => speed > 0));
1291
+ const minSpeed = Math.min(...hourlySpeedData.filter(speed => speed > 0));
1292
+
1293
+ const barColors = hourlySpeedData.map(speed => {
1294
+ if (speed === 0) {
1295
+ return getCurrentTheme() === 'dark' ? 'rgba(100, 100, 100, 0.3)' : 'rgba(200, 200, 200, 0.3)';
1296
+ }
1297
+
1298
+ // Create color gradient from orange (slow) to blue (fast) - performance theme
1299
+ const normalizedSpeed = maxSpeed > minSpeed ? (speed - minSpeed) / (maxSpeed - minSpeed) : 0.5;
1300
+ const hue = 30 + (normalizedSpeed * 170); // 30 = orange, 200 = blue
1301
+ return `hsla(${hue}, 70%, 50%, 0.8)`;
1302
+ });
1303
+
1304
+ const borderColors = hourlySpeedData.map(speed => {
1305
+ if (speed === 0) {
1306
+ return getCurrentTheme() === 'dark' ? 'rgba(100, 100, 100, 0.6)' : 'rgba(200, 200, 200, 0.6)';
1307
+ }
1308
+
1309
+ const normalizedSpeed = maxSpeed > minSpeed ? (speed - minSpeed) / (maxSpeed - minSpeed) : 0.5;
1310
+ const hue = 30 + (normalizedSpeed * 170);
1311
+ return `hsla(${hue}, 70%, 40%, 1)`;
1312
+ });
1313
+
1314
+ window.myCharts[canvasId] = new Chart(ctx, {
1315
+ type: 'bar',
1316
+ data: {
1317
+ labels: hourLabels,
1318
+ datasets: [{
1319
+ label: 'Average Reading Speed',
1320
+ data: hourlySpeedData,
1321
+ backgroundColor: barColors,
1322
+ borderColor: borderColors,
1323
+ borderWidth: 2
1324
+ }]
1325
+ },
1326
+ options: {
1327
+ responsive: true,
1328
+ plugins: {
1329
+ legend: {
1330
+ display: false // Hide legend for cleaner look
1331
+ },
1332
+ title: {
1333
+ display: true,
1334
+ text: 'Average Reading Speed by Hour',
1335
+ color: getThemeTextColor(),
1336
+ font: {
1337
+ size: 16,
1338
+ weight: 'bold'
1339
+ },
1340
+ padding: {
1341
+ top: 10,
1342
+ bottom: 20
1343
+ }
1344
+ },
1345
+ tooltip: {
1346
+ callbacks: {
1347
+ title: function(context) {
1348
+ const hourIndex = context[0].dataIndex;
1349
+ const hour24 = hourIndex;
1350
+ const hour12 = hour24 === 0 ? 12 : hour24 > 12 ? hour24 - 12 : hour24;
1351
+ const ampm = hour24 < 12 ? 'AM' : 'PM';
1352
+ return `${hour12}:00 ${ampm} (${hour24}:00)`;
1353
+ },
1354
+ label: function(context) {
1355
+ const speed = context.parsed.y;
1356
+ if (speed === 0) {
1357
+ return 'No reading activity';
1358
+ }
1359
+ return `Speed: ${speed.toLocaleString()} chars/hour`;
1360
+ },
1361
+ afterLabel: function(context) {
1362
+ const speed = context.parsed.y;
1363
+ if (speed === 0) return '';
1364
+
1365
+ const nonZeroSpeeds = hourlySpeedData.filter(s => s > 0);
1366
+ const avgSpeed = nonZeroSpeeds.reduce((sum, s) => sum + s, 0) / nonZeroSpeeds.length;
1367
+ const comparison = speed > avgSpeed ? 'above' : speed < avgSpeed ? 'below' : 'at';
1368
+ const percentage = avgSpeed > 0 ? Math.abs(((speed - avgSpeed) / avgSpeed) * 100).toFixed(1) : '0';
1369
+
1370
+ return `${percentage}% ${comparison} average`;
1371
+ }
1372
+ },
1373
+ backgroundColor: 'rgba(0, 0, 0, 0.9)',
1374
+ titleColor: '#fff',
1375
+ bodyColor: '#fff',
1376
+ borderColor: 'rgba(255, 255, 255, 0.2)',
1377
+ borderWidth: 1,
1378
+ cornerRadius: 8,
1379
+ displayColors: true
1380
+ }
1381
+ },
1382
+ scales: {
1383
+ y: {
1384
+ beginAtZero: true,
1385
+ title: {
1386
+ display: true,
1387
+ text: 'Characters per Hour',
1388
+ color: getThemeTextColor()
1389
+ },
1390
+ ticks: {
1391
+ color: getThemeTextColor(),
1392
+ callback: function(value) {
1393
+ return value.toLocaleString();
1394
+ }
1395
+ }
1396
+ },
1397
+ x: {
1398
+ title: {
1399
+ display: true,
1400
+ text: 'Hour of Day',
1401
+ color: getThemeTextColor()
1402
+ },
1403
+ ticks: {
1404
+ color: getThemeTextColor()
1405
+ }
1406
+ }
1407
+ },
1408
+ animation: {
1409
+ duration: 1000,
1410
+ easing: 'easeOutQuart'
1411
+ }
1412
+ }
1413
+ });
1414
+
1415
+ return window.myCharts[canvasId];
1416
+ }
1417
+
1418
+ // Initialize heatmap renderer with mining-specific configuration
1419
+ const miningHeatmapRenderer = new HeatmapRenderer({
1420
+ containerId: 'miningHeatmapContainer',
1421
+ metricName: 'sentences',
1422
+ metricLabel: 'sentences mined'
1423
+ });
1424
+
1425
+ // Function to create GitHub-style heatmap for mining activity using shared component
1426
+ function createMiningHeatmap(heatmapData) {
1427
+ miningHeatmapRenderer.render(heatmapData);
1428
+ }
1429
+
1430
+ // Initialize Kanji Grid Renderer (using shared component)
1431
+ const kanjiGridRenderer = new KanjiGridRenderer({
1432
+ containerSelector: '#kanjiGrid',
1433
+ counterSelector: '#kanjiCount',
1434
+ colorMode: 'backend',
1435
+ emptyMessage: 'No kanji data available'
1436
+ });
1437
+
1438
+ // Function to create daily time bar chart with weekend markers
1439
+ function createDailyTimeChart(canvasId, chartData, isAllTime = false) {
1440
+ const canvas = document.getElementById(canvasId);
1441
+ if (!canvas || !chartData) return null;
1442
+
1443
+ const ctx = canvas.getContext('2d');
1444
+
1445
+ // Destroy existing chart if it exists
1446
+ if (window.myCharts[canvasId]) {
1447
+ window.myCharts[canvasId].destroy();
1448
+ }
1449
+
1450
+ // Generate gradient colors based on values (more = greener)
1451
+ const maxVal = Math.max(...chartData.timeData.filter(v => v > 0));
1452
+ const minVal = Math.min(...chartData.timeData.filter(v => v > 0));
1453
+ const isDark = getCurrentTheme() === 'dark';
1454
+
1455
+ const barColors = chartData.timeData.map(value => {
1456
+ if (value === 0) {
1457
+ return isDark ? 'rgba(100, 100, 100, 0.3)' : 'rgba(200, 200, 200, 0.3)';
1458
+ }
1459
+ const normalized = maxVal > minVal ? (value - minVal) / (maxVal - minVal) : 0.5;
1460
+ const hue = 30 + (normalized * 170); // 30 = orange, 200 = blue
1461
+ return `hsla(${hue}, 70%, 50%, 0.8)`;
1462
+ });
1463
+
1464
+ const borderColors = chartData.timeData.map(value => {
1465
+ if (value === 0) {
1466
+ return isDark ? 'rgba(100, 100, 100, 0.6)' : 'rgba(200, 200, 200, 0.6)';
1467
+ }
1468
+ const normalized = maxVal > minVal ? (value - minVal) / (maxVal - minVal) : 0.5;
1469
+ const hue = 30 + (normalized * 170);
1470
+ return `hsla(${hue}, 70%, 40%, 1)`;
1471
+ });
1472
+
1473
+ // Format labels to show day of week with weekend indicator
1474
+ const formattedLabels = chartData.labels.map(dateStr => {
1475
+ const date = new Date(dateStr);
1476
+ const dayOfWeek = date.getDay();
1477
+ const isWeekend = dayOfWeek === 0 || dayOfWeek === 6;
1478
+ const dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
1479
+ const monthDay = date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
1480
+ return isWeekend ? `${dayNames[dayOfWeek]} ${monthDay} 📅` : `${dayNames[dayOfWeek]} ${monthDay}`;
1481
+ });
1482
+
1483
+ // Update the title element
1484
+ const titleElement = document.getElementById('dailyTimeChartTitle');
1485
+ if (titleElement) {
1486
+ titleElement.textContent = isAllTime ? '📊 Daily Reading Time (All Time)' : '📊 Daily Reading Time (Last 4 Weeks)';
1487
+ }
1488
+
1489
+ window.myCharts[canvasId] = new Chart(ctx, {
1490
+ type: 'bar',
1491
+ data: {
1492
+ labels: formattedLabels,
1493
+ datasets: [{
1494
+ label: 'Hours Read',
1495
+ data: chartData.timeData,
1496
+ backgroundColor: barColors,
1497
+ borderColor: borderColors,
1498
+ borderWidth: 2
1499
+ }]
1500
+ },
1501
+ options: {
1502
+ responsive: true,
1503
+ plugins: {
1504
+ legend: {
1505
+ display: false
1506
+ },
1507
+ title: {
1508
+ display: false
1509
+ },
1510
+ tooltip: {
1511
+ callbacks: {
1512
+ title: function(context) {
1513
+ const index = context[0].dataIndex;
1514
+ return chartData.labels[index];
1515
+ },
1516
+ label: function(context) {
1517
+ const hours = context.parsed.y;
1518
+ if (hours === 0) {
1519
+ return 'No reading activity';
1520
+ }
1521
+ const wholeHours = Math.floor(hours);
1522
+ const minutes = Math.round((hours - wholeHours) * 60);
1523
+ if (minutes > 0) {
1524
+ return `Time: ${wholeHours}h ${minutes}m`;
1525
+ } else {
1526
+ return `Time: ${wholeHours}h`;
1527
+ }
1528
+ },
1529
+ afterLabel: function(context) {
1530
+ const index = context[0].dataIndex;
1531
+ const date = new Date(chartData.labels[index]);
1532
+ const dayOfWeek = date.getDay();
1533
+ const isWeekend = dayOfWeek === 0 || dayOfWeek === 6;
1534
+ return isWeekend ? '📅 Weekend' : '';
1535
+ }
1536
+ },
1537
+ backgroundColor: 'rgba(0, 0, 0, 0.9)',
1538
+ titleColor: '#fff',
1539
+ bodyColor: '#fff',
1540
+ borderColor: 'rgba(255, 255, 255, 0.2)',
1541
+ borderWidth: 1,
1542
+ cornerRadius: 8,
1543
+ displayColors: true
1544
+ }
1545
+ },
1546
+ scales: {
1547
+ y: {
1548
+ beginAtZero: true,
1549
+ title: {
1550
+ display: true,
1551
+ text: 'Hours',
1552
+ color: getThemeTextColor()
1553
+ },
1554
+ ticks: {
1555
+ color: getThemeTextColor(),
1556
+ callback: function(value) {
1557
+ return value.toFixed(1);
1558
+ }
1559
+ }
1560
+ },
1561
+ x: {
1562
+ title: {
1563
+ display: true,
1564
+ text: 'Date',
1565
+ color: getThemeTextColor()
1566
+ },
1567
+ ticks: {
1568
+ color: getThemeTextColor(),
1569
+ maxRotation: 45,
1570
+ minRotation: 45
1571
+ }
1572
+ }
1573
+ },
1574
+ animation: {
1575
+ duration: 1000,
1576
+ easing: 'easeOutQuart'
1577
+ }
1578
+ }
1579
+ });
1580
+
1581
+ return window.myCharts[canvasId];
1582
+ }
1583
+
1584
+ // Function to create daily characters bar chart with gradient colors
1585
+ function createDailyCharsChart(canvasId, chartData, isAllTime = false) {
1586
+ if (!chartData) return null;
1587
+
1588
+ // Format labels to show day of week with weekend indicator
1589
+ const formattedLabels = chartData.labels.map(dateStr => {
1590
+ const date = new Date(dateStr);
1591
+ const dayOfWeek = date.getDay();
1592
+ const isWeekend = dayOfWeek === 0 || dayOfWeek === 6;
1593
+ const dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
1594
+ const monthDay = date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
1595
+ return isWeekend ? `${dayNames[dayOfWeek]} ${monthDay} 📅` : `${dayNames[dayOfWeek]} ${monthDay}`;
1596
+ });
1597
+
1598
+ // Generate gradient colors based on values (more = greener)
1599
+ const maxVal = Math.max(...chartData.charsData.filter(v => v > 0));
1600
+ const minVal = Math.min(...chartData.charsData.filter(v => v > 0));
1601
+ const isDark = getCurrentTheme() === 'dark';
1602
+
1603
+ const barColors = chartData.charsData.map(value => {
1604
+ if (value === 0) {
1605
+ return isDark ? 'rgba(100, 100, 100, 0.3)' : 'rgba(200, 200, 200, 0.3)';
1606
+ }
1607
+ const normalized = maxVal > minVal ? (value - minVal) / (maxVal - minVal) : 0.5;
1608
+ const hue = 30 + (normalized * 170); // 30 = orange, 200 = blue
1609
+ return `hsla(${hue}, 70%, 50%, 0.8)`;
1610
+ });
1611
+
1612
+ const borderColors = chartData.charsData.map(value => {
1613
+ if (value === 0) {
1614
+ return isDark ? 'rgba(100, 100, 100, 0.6)' : 'rgba(200, 200, 200, 0.6)';
1615
+ }
1616
+ const normalized = maxVal > minVal ? (value - minVal) / (maxVal - minVal) : 0.5;
1617
+ const hue = 30 + (normalized * 170);
1618
+ return `hsla(${hue}, 70%, 40%, 1)`;
1619
+ });
1620
+
532
1621
  const canvas = document.getElementById(canvasId);
533
- if (!canvas || !hourlyData || !Array.isArray(hourlyData)) return null;
1622
+ if (!canvas) return null;
534
1623
 
535
1624
  const ctx = canvas.getContext('2d');
536
1625
 
@@ -539,46 +1628,19 @@ document.addEventListener('DOMContentLoaded', function () {
539
1628
  window.myCharts[canvasId].destroy();
540
1629
  }
541
1630
 
542
- // Create hour labels (0-23)
543
- const hourLabels = [];
544
- for (let i = 0; i < 24; i++) {
545
- const hour12 = i === 0 ? 12 : i > 12 ? i - 12 : i;
546
- const ampm = i < 12 ? 'AM' : 'PM';
547
- hourLabels.push(`${hour12}${ampm}`);
1631
+ // Update the title element
1632
+ const titleElement = document.getElementById('dailyCharsChartTitle');
1633
+ if (titleElement) {
1634
+ titleElement.textContent = isAllTime ? '📚 Daily Characters Read (All Time)' : '📚 Daily Characters Read (Last 4 Weeks)';
548
1635
  }
549
1636
 
550
- // Generate gradient colors for bars based on activity values
551
- const maxActivity = Math.max(...hourlyData.filter(activity => activity > 0));
552
- const minActivity = Math.min(...hourlyData.filter(activity => activity > 0));
553
-
554
- const barColors = hourlyData.map(activity => {
555
- if (activity === 0) {
556
- return getCurrentTheme() === 'dark' ? 'rgba(100, 100, 100, 0.3)' : 'rgba(200, 200, 200, 0.3)';
557
- }
558
-
559
- // Create color gradient from red (low activity) to green (high activity)
560
- const normalizedActivity = maxActivity > minActivity ? (activity - minActivity) / (maxActivity - minActivity) : 0.5;
561
- const hue = normalizedActivity * 120; // 0 = red, 120 = green
562
- return `hsla(${hue}, 70%, 50%, 0.8)`;
563
- });
564
-
565
- const borderColors = hourlyData.map(activity => {
566
- if (activity === 0) {
567
- return getCurrentTheme() === 'dark' ? 'rgba(100, 100, 100, 0.6)' : 'rgba(200, 200, 200, 0.6)';
568
- }
569
-
570
- const normalizedActivity = maxActivity > minActivity ? (activity - minActivity) / (maxActivity - minActivity) : 0.5;
571
- const hue = normalizedActivity * 120;
572
- return `hsla(${hue}, 70%, 40%, 1)`;
573
- });
574
-
575
1637
  window.myCharts[canvasId] = new Chart(ctx, {
576
1638
  type: 'bar',
577
1639
  data: {
578
- labels: hourLabels,
1640
+ labels: formattedLabels,
579
1641
  datasets: [{
580
1642
  label: 'Characters Read',
581
- data: hourlyData,
1643
+ data: chartData.charsData,
582
1644
  backgroundColor: barColors,
583
1645
  borderColor: borderColors,
584
1646
  borderWidth: 2
@@ -588,44 +1650,30 @@ document.addEventListener('DOMContentLoaded', function () {
588
1650
  responsive: true,
589
1651
  plugins: {
590
1652
  legend: {
591
- display: false // Hide legend for cleaner look
1653
+ display: false
592
1654
  },
593
1655
  title: {
594
- display: true,
595
- text: 'Reading Activity by Hour of Day',
596
- color: getThemeTextColor(),
597
- font: {
598
- size: 16,
599
- weight: 'bold'
600
- },
601
- padding: {
602
- top: 10,
603
- bottom: 20
604
- }
1656
+ display: false
605
1657
  },
606
1658
  tooltip: {
607
1659
  callbacks: {
608
1660
  title: function(context) {
609
- const hourIndex = context[0].dataIndex;
610
- const hour24 = hourIndex;
611
- const hour12 = hour24 === 0 ? 12 : hour24 > 12 ? hour24 - 12 : hour24;
612
- const ampm = hour24 < 12 ? 'AM' : 'PM';
613
- return `${hour12}:00 ${ampm} (${hour24}:00)`;
1661
+ const index = context[0].dataIndex;
1662
+ return chartData.labels[index];
614
1663
  },
615
1664
  label: function(context) {
616
- const activity = context.parsed.y;
617
- if (activity === 0) {
1665
+ const chars = context.parsed.y;
1666
+ if (chars === 0) {
618
1667
  return 'No reading activity';
619
1668
  }
620
- return `Characters: ${activity.toLocaleString()}`;
1669
+ return `Characters: ${chars.toLocaleString()}`;
621
1670
  },
622
1671
  afterLabel: function(context) {
623
- const activity = context.parsed.y;
624
- if (activity === 0) return '';
625
-
626
- const total = hourlyData.reduce((sum, val) => sum + val, 0);
627
- const percentage = total > 0 ? ((activity / total) * 100).toFixed(1) : '0.0';
628
- return `${percentage}% of total activity`;
1672
+ const index = context[0].dataIndex;
1673
+ const date = new Date(chartData.labels[index]);
1674
+ const dayOfWeek = date.getDay();
1675
+ const isWeekend = dayOfWeek === 0 || dayOfWeek === 6;
1676
+ return isWeekend ? '📅 Weekend' : '';
629
1677
  }
630
1678
  },
631
1679
  backgroundColor: 'rgba(0, 0, 0, 0.9)',
@@ -642,7 +1690,7 @@ document.addEventListener('DOMContentLoaded', function () {
642
1690
  beginAtZero: true,
643
1691
  title: {
644
1692
  display: true,
645
- text: 'Characters Read',
1693
+ text: 'Characters',
646
1694
  color: getThemeTextColor()
647
1695
  },
648
1696
  ticks: {
@@ -655,11 +1703,13 @@ document.addEventListener('DOMContentLoaded', function () {
655
1703
  x: {
656
1704
  title: {
657
1705
  display: true,
658
- text: 'Hour of Day',
1706
+ text: 'Date',
659
1707
  color: getThemeTextColor()
660
1708
  },
661
1709
  ticks: {
662
- color: getThemeTextColor()
1710
+ color: getThemeTextColor(),
1711
+ maxRotation: 45,
1712
+ minRotation: 45
663
1713
  }
664
1714
  }
665
1715
  },
@@ -673,10 +1723,10 @@ document.addEventListener('DOMContentLoaded', function () {
673
1723
  return window.myCharts[canvasId];
674
1724
  }
675
1725
 
676
- // Function to create hourly reading speed bar chart
677
- function createHourlyReadingSpeedChart(canvasId, hourlySpeedData) {
1726
+ // Function to create daily reading speed line chart
1727
+ function createDailySpeedChart(canvasId, chartData, isAllTime = false) {
678
1728
  const canvas = document.getElementById(canvasId);
679
- if (!canvas || !hourlySpeedData || !Array.isArray(hourlySpeedData)) return null;
1729
+ if (!canvas || !chartData) return null;
680
1730
 
681
1731
  const ctx = canvas.getContext('2d');
682
1732
 
@@ -685,96 +1735,80 @@ document.addEventListener('DOMContentLoaded', function () {
685
1735
  window.myCharts[canvasId].destroy();
686
1736
  }
687
1737
 
688
- // Create hour labels (0-23)
689
- const hourLabels = [];
690
- for (let i = 0; i < 24; i++) {
691
- const hour12 = i === 0 ? 12 : i > 12 ? i - 12 : i;
692
- const ampm = i < 12 ? 'AM' : 'PM';
693
- hourLabels.push(`${hour12}${ampm}`);
694
- }
695
-
696
- // Generate gradient colors for bars based on speed values
697
- const maxSpeed = Math.max(...hourlySpeedData.filter(speed => speed > 0));
698
- const minSpeed = Math.min(...hourlySpeedData.filter(speed => speed > 0));
1738
+ // Filter out days with no data for cleaner line chart
1739
+ const filteredLabels = [];
1740
+ const filteredSpeedData = [];
1741
+ const filteredOriginalLabels = [];
699
1742
 
700
- const barColors = hourlySpeedData.map(speed => {
701
- if (speed === 0) {
702
- return getCurrentTheme() === 'dark' ? 'rgba(100, 100, 100, 0.3)' : 'rgba(200, 200, 200, 0.3)';
1743
+ chartData.labels.forEach((dateStr, index) => {
1744
+ if (chartData.speedData[index] > 0) {
1745
+ const date = new Date(dateStr);
1746
+ const dayOfWeek = date.getDay();
1747
+ const dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
1748
+ const monthDay = date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
1749
+ filteredLabels.push(`${dayNames[dayOfWeek]} ${monthDay}`);
1750
+ filteredSpeedData.push(chartData.speedData[index]);
1751
+ filteredOriginalLabels.push(dateStr);
703
1752
  }
704
-
705
- // Create color gradient from red (slow) to green (fast)
706
- const normalizedSpeed = maxSpeed > minSpeed ? (speed - minSpeed) / (maxSpeed - minSpeed) : 0.5;
707
- const hue = normalizedSpeed * 120; // 0 = red, 120 = green
708
- return `hsla(${hue}, 70%, 50%, 0.8)`;
709
1753
  });
710
1754
 
711
- const borderColors = hourlySpeedData.map(speed => {
712
- if (speed === 0) {
713
- return getCurrentTheme() === 'dark' ? 'rgba(100, 100, 100, 0.6)' : 'rgba(200, 200, 200, 0.6)';
714
- }
715
-
716
- const normalizedSpeed = maxSpeed > minSpeed ? (speed - minSpeed) / (maxSpeed - minSpeed) : 0.5;
717
- const hue = normalizedSpeed * 120;
718
- return `hsla(${hue}, 70%, 40%, 1)`;
1755
+ // Generate point colors based on weekend - consistent with other daily charts
1756
+ const pointColors = filteredOriginalLabels.map(dateStr => {
1757
+ const date = new Date(dateStr);
1758
+ const dayOfWeek = date.getDay();
1759
+ const isWeekend = dayOfWeek === 0 || dayOfWeek === 6;
1760
+ return isWeekend ? 'rgba(171, 71, 188, 1)' : 'rgba(54, 162, 235, 1)';
719
1761
  });
720
-
1762
+
1763
+ // Update the title element
1764
+ const titleElement = document.getElementById('dailySpeedChartTitle');
1765
+ if (titleElement) {
1766
+ titleElement.textContent = isAllTime ? '⚡ Daily Reading Speed (All Time)' : '⚡ Daily Reading Speed (Last 4 Weeks)';
1767
+ }
1768
+
721
1769
  window.myCharts[canvasId] = new Chart(ctx, {
722
- type: 'bar',
1770
+ type: 'line',
723
1771
  data: {
724
- labels: hourLabels,
1772
+ labels: filteredLabels,
725
1773
  datasets: [{
726
- label: 'Average Reading Speed',
727
- data: hourlySpeedData,
728
- backgroundColor: barColors,
729
- borderColor: borderColors,
730
- borderWidth: 2
1774
+ label: 'Reading Speed (chars/hour)',
1775
+ data: filteredSpeedData,
1776
+ borderColor: 'rgba(54, 162, 235, 1)',
1777
+ backgroundColor: 'rgba(54, 162, 235, 0.1)',
1778
+ pointBackgroundColor: pointColors,
1779
+ pointBorderColor: pointColors,
1780
+ pointRadius: 5,
1781
+ pointHoverRadius: 7,
1782
+ borderWidth: 2,
1783
+ tension: 0.3,
1784
+ fill: true
731
1785
  }]
732
1786
  },
733
1787
  options: {
734
1788
  responsive: true,
735
1789
  plugins: {
736
1790
  legend: {
737
- display: false // Hide legend for cleaner look
1791
+ display: false
738
1792
  },
739
1793
  title: {
740
- display: true,
741
- text: 'Average Reading Speed by Hour',
742
- color: getThemeTextColor(),
743
- font: {
744
- size: 16,
745
- weight: 'bold'
746
- },
747
- padding: {
748
- top: 10,
749
- bottom: 20
750
- }
1794
+ display: false
751
1795
  },
752
1796
  tooltip: {
753
1797
  callbacks: {
754
1798
  title: function(context) {
755
- const hourIndex = context[0].dataIndex;
756
- const hour24 = hourIndex;
757
- const hour12 = hour24 === 0 ? 12 : hour24 > 12 ? hour24 - 12 : hour24;
758
- const ampm = hour24 < 12 ? 'AM' : 'PM';
759
- return `${hour12}:00 ${ampm} (${hour24}:00)`;
1799
+ const index = context[0].dataIndex;
1800
+ return filteredOriginalLabels[index];
760
1801
  },
761
1802
  label: function(context) {
762
1803
  const speed = context.parsed.y;
763
- if (speed === 0) {
764
- return 'No reading activity';
765
- }
766
1804
  return `Speed: ${speed.toLocaleString()} chars/hour`;
767
1805
  },
768
1806
  afterLabel: function(context) {
769
- const speed = context.parsed.y;
770
- if (speed === 0) return '';
771
-
772
- const nonZeroSpeeds = hourlySpeedData.filter(s => s > 0);
773
- const avgSpeed = nonZeroSpeeds.reduce((sum, s) => sum + s, 0) / nonZeroSpeeds.length;
774
- const comparison = speed > avgSpeed ? 'above' : speed < avgSpeed ? 'below' : 'at';
775
- const percentage = avgSpeed > 0 ? Math.abs(((speed - avgSpeed) / avgSpeed) * 100).toFixed(1) : '0';
776
-
777
- return `${percentage}% ${comparison} average`;
1807
+ const index = context[0].dataIndex;
1808
+ const date = new Date(filteredOriginalLabels[index]);
1809
+ const dayOfWeek = date.getDay();
1810
+ const isWeekend = dayOfWeek === 0 || dayOfWeek === 6;
1811
+ return isWeekend ? '📅 Weekend' : '';
778
1812
  }
779
1813
  },
780
1814
  backgroundColor: 'rgba(0, 0, 0, 0.9)',
@@ -804,11 +1838,13 @@ document.addEventListener('DOMContentLoaded', function () {
804
1838
  x: {
805
1839
  title: {
806
1840
  display: true,
807
- text: 'Hour of Day',
1841
+ text: 'Date',
808
1842
  color: getThemeTextColor()
809
1843
  },
810
1844
  ticks: {
811
- color: getThemeTextColor()
1845
+ color: getThemeTextColor(),
1846
+ maxRotation: 45,
1847
+ minRotation: 45
812
1848
  }
813
1849
  }
814
1850
  },
@@ -822,19 +1858,151 @@ document.addEventListener('DOMContentLoaded', function () {
822
1858
  return window.myCharts[canvasId];
823
1859
  }
824
1860
 
825
- // Initialize Kanji Grid Renderer (using shared component)
826
- const kanjiGridRenderer = new KanjiGridRenderer({
827
- containerSelector: '#kanjiGrid',
828
- counterSelector: '#kanjiCount',
829
- colorMode: 'backend',
830
- emptyMessage: 'No kanji data available'
831
- });
832
-
833
1861
  // Function to create kanji grid (now using shared renderer)
834
1862
  function createKanjiGrid(kanjiData) {
835
1863
  kanjiGridRenderer.render(kanjiData);
836
1864
  }
837
1865
 
1866
+ // Function to update game milestones display
1867
+ function updateGameMilestones(gameMilestones) {
1868
+ const oldestCard = document.getElementById('oldestGameCard');
1869
+ const newestCard = document.getElementById('newestGameCard');
1870
+ const noDataMsg = document.getElementById('milestonesNoData');
1871
+
1872
+ if (!gameMilestones || (!gameMilestones.oldest_game && !gameMilestones.newest_game)) {
1873
+ // No milestone data available
1874
+ if (oldestCard) oldestCard.style.display = 'none';
1875
+ if (newestCard) newestCard.style.display = 'none';
1876
+ if (noDataMsg) noDataMsg.style.display = 'block';
1877
+ return;
1878
+ }
1879
+
1880
+ // Hide no data message
1881
+ if (noDataMsg) noDataMsg.style.display = 'none';
1882
+
1883
+ // Update oldest game card
1884
+ if (gameMilestones.oldest_game && oldestCard) {
1885
+ const game = gameMilestones.oldest_game;
1886
+
1887
+ // Update image with proper base64 handling
1888
+ const imageEl = document.getElementById('oldestGameImage');
1889
+ if (game.image && game.image.trim()) {
1890
+ let imageSrc = game.image.trim();
1891
+
1892
+ // Check if it's a base64 image or URL
1893
+ if (imageSrc.startsWith('data:image')) {
1894
+ console.log('[DEBUG] Setting base64 image with data URI for oldest game');
1895
+ imageEl.src = imageSrc;
1896
+ imageEl.style.display = 'block';
1897
+ } else if (imageSrc.startsWith('http')) {
1898
+ console.log('[DEBUG] Setting URL image for oldest game:', imageSrc);
1899
+ imageEl.src = imageSrc;
1900
+ imageEl.style.display = 'block';
1901
+ } else if (imageSrc.startsWith('/9j/') || imageSrc.startsWith('iVBOR')) {
1902
+ // Raw base64 data without data URI prefix - add it
1903
+ // /9j/ is JPEG, iVBOR is PNG
1904
+ const mimeType = imageSrc.startsWith('/9j/') ? 'image/jpeg' : 'image/png';
1905
+ imageSrc = `data:${mimeType};base64,${imageSrc}`;
1906
+ console.log('[DEBUG] Added data URI prefix to raw base64 data for oldest game');
1907
+ imageEl.src = imageSrc;
1908
+ imageEl.style.display = 'block';
1909
+ } else {
1910
+ // Invalid image format, use placeholder
1911
+ console.log('[DEBUG] Invalid image format for oldest game, using placeholder');
1912
+ imageEl.parentElement.innerHTML = '<div class="milestone-game-image placeholder">🎮</div>';
1913
+ }
1914
+
1915
+ imageEl.onerror = function() {
1916
+ this.style.display = 'none';
1917
+ this.parentElement.innerHTML = '<div class="milestone-game-image placeholder">🎮</div>';
1918
+ };
1919
+ } else {
1920
+ console.log('[DEBUG] No image data for oldest game, using placeholder');
1921
+ imageEl.parentElement.innerHTML = '<div class="milestone-game-image placeholder">🎮</div>';
1922
+ }
1923
+
1924
+ // Update title
1925
+ document.getElementById('oldestGameTitle').textContent = game.title_original || 'Unknown Game';
1926
+
1927
+ // Update subtitle (romaji or english)
1928
+ const subtitle = game.title_romaji || game.title_english || '';
1929
+ const subtitleEl = document.getElementById('oldestGameSubtitle');
1930
+ if (subtitle) {
1931
+ subtitleEl.textContent = subtitle;
1932
+ subtitleEl.style.display = 'block';
1933
+ } else {
1934
+ subtitleEl.style.display = 'none';
1935
+ }
1936
+
1937
+ // Update release date
1938
+ document.getElementById('oldestGameReleaseYear').textContent = game.release_date || 'Unknown';
1939
+
1940
+
1941
+ oldestCard.style.display = 'flex';
1942
+ }
1943
+
1944
+ // Update newest game card
1945
+ if (gameMilestones.newest_game && newestCard) {
1946
+ const game = gameMilestones.newest_game;
1947
+
1948
+ // Update image with proper base64 handling
1949
+ const imageEl = document.getElementById('newestGameImage');
1950
+ if (game.image && game.image.trim()) {
1951
+ let imageSrc = game.image.trim();
1952
+
1953
+ // Check if it's a base64 image or URL
1954
+ if (imageSrc.startsWith('data:image')) {
1955
+ console.log('[DEBUG] Setting base64 image with data URI for newest game');
1956
+ imageEl.src = imageSrc;
1957
+ imageEl.style.display = 'block';
1958
+ } else if (imageSrc.startsWith('http')) {
1959
+ console.log('[DEBUG] Setting URL image for newest game:', imageSrc);
1960
+ imageEl.src = imageSrc;
1961
+ imageEl.style.display = 'block';
1962
+ } else if (imageSrc.startsWith('/9j/') || imageSrc.startsWith('iVBOR')) {
1963
+ // Raw base64 data without data URI prefix - add it
1964
+ // /9j/ is JPEG, iVBOR is PNG
1965
+ const mimeType = imageSrc.startsWith('/9j/') ? 'image/jpeg' : 'image/png';
1966
+ imageSrc = `data:${mimeType};base64,${imageSrc}`;
1967
+ console.log('[DEBUG] Added data URI prefix to raw base64 data for newest game');
1968
+ imageEl.src = imageSrc;
1969
+ imageEl.style.display = 'block';
1970
+ } else {
1971
+ // Invalid image format, use placeholder
1972
+ console.log('[DEBUG] Invalid image format for newest game, using placeholder');
1973
+ imageEl.parentElement.innerHTML = '<div class="milestone-game-image placeholder">🎮</div>';
1974
+ }
1975
+
1976
+ imageEl.onerror = function() {
1977
+ this.style.display = 'none';
1978
+ this.parentElement.innerHTML = '<div class="milestone-game-image placeholder">🎮</div>';
1979
+ };
1980
+ } else {
1981
+ console.log('[DEBUG] No image data for newest game, using placeholder');
1982
+ imageEl.parentElement.innerHTML = '<div class="milestone-game-image placeholder">🎮</div>';
1983
+ }
1984
+
1985
+ // Update title
1986
+ document.getElementById('newestGameTitle').textContent = game.title_original || 'Unknown Game';
1987
+
1988
+ // Update subtitle (romaji or english)
1989
+ const subtitle = game.title_romaji || game.title_english || '';
1990
+ const subtitleEl = document.getElementById('newestGameSubtitle');
1991
+ if (subtitle) {
1992
+ subtitleEl.textContent = subtitle;
1993
+ subtitleEl.style.display = 'block';
1994
+ } else {
1995
+ subtitleEl.style.display = 'none';
1996
+ }
1997
+
1998
+ // Update release date
1999
+ document.getElementById('newestGameReleaseYear').textContent = game.release_date || 'Unknown';
2000
+
2001
+
2002
+ newestCard.style.display = 'flex';
2003
+ }
2004
+ }
2005
+
838
2006
  // Function to update peak statistics display
839
2007
  function updatePeakStatistics(peakDailyStats, peakSessionStats) {
840
2008
  // Helper function to format large numbers
@@ -908,6 +2076,105 @@ document.addEventListener('DOMContentLoaded', function () {
908
2076
  });
909
2077
  }
910
2078
 
2079
+ // Cache for filtered datasets to avoid re-filtering
2080
+ let cachedFilteredDatasets = null;
2081
+
2082
+ // Function to get or create filtered datasets
2083
+ function getFilteredDatasets(data) {
2084
+ // Return cached version if available and data hasn't changed
2085
+ if (cachedFilteredDatasets && cachedFilteredDatasets.sourceData === data.datasets) {
2086
+ return cachedFilteredDatasets;
2087
+ }
2088
+
2089
+ // Filter datasets for each chart
2090
+ const linesData = {
2091
+ labels: data.labels,
2092
+ datasets: data.datasets.filter(d => d.for === "Lines Received")
2093
+ };
2094
+
2095
+ const charsData = {
2096
+ labels: data.labels,
2097
+ datasets: data.datasets.filter(d => d.for === 'Characters Read')
2098
+ };
2099
+
2100
+ // Remove the 'hidden' property so they appear on their own charts
2101
+ [...charsData.datasets].forEach(d => delete d.hidden);
2102
+
2103
+ // Cache the result
2104
+ cachedFilteredDatasets = {
2105
+ sourceData: data.datasets,
2106
+ linesData: linesData,
2107
+ charsData: charsData
2108
+ };
2109
+
2110
+ return cachedFilteredDatasets;
2111
+ }
2112
+
2113
+ // Function to load and render daily activity charts
2114
+ async function loadDailyActivityCharts(useAllTimeData = false) {
2115
+ try {
2116
+ let url = '/api/daily-activity';
2117
+ if (useAllTimeData) {
2118
+ url += '?all_time=true';
2119
+ }
2120
+
2121
+ const response = await fetch(url);
2122
+ if (!response.ok) {
2123
+ throw new Error('Failed to load daily activity data');
2124
+ }
2125
+
2126
+ const data = await response.json();
2127
+
2128
+ // Create the charts with the isAllTime flag
2129
+ if (data.labels && data.labels.length > 0) {
2130
+ createDailyTimeChart('dailyTimeChart', data, useAllTimeData);
2131
+ createDailyCharsChart('dailyCharsChart', data, useAllTimeData);
2132
+ createDailySpeedChart('dailySpeedChart', data, useAllTimeData);
2133
+ } else {
2134
+ console.log('No daily activity data available');
2135
+ }
2136
+ } catch (error) {
2137
+ console.error('Error loading daily activity charts:', error);
2138
+ }
2139
+ }
2140
+
2141
+ // Function to load mining heatmap data
2142
+ async function loadMiningHeatmap(start_timestamp = null, end_timestamp = null) {
2143
+ try {
2144
+ let url = '/api/mining_heatmap';
2145
+ const params = new URLSearchParams();
2146
+
2147
+ if (start_timestamp && end_timestamp) {
2148
+ params.append('start', start_timestamp);
2149
+ params.append('end', end_timestamp);
2150
+ }
2151
+
2152
+ const queryString = params.toString();
2153
+ if (queryString) {
2154
+ url += `?${queryString}`;
2155
+ }
2156
+
2157
+ const resp = await fetch(url);
2158
+ if (!resp.ok) throw new Error('Failed to load mining heatmap');
2159
+ const data = await resp.json();
2160
+
2161
+ if (data && Object.keys(data).length > 0) {
2162
+ createMiningHeatmap(data);
2163
+ } else {
2164
+ const container = document.getElementById('miningHeatmapContainer');
2165
+ if (container) {
2166
+ container.innerHTML = '<p style="text-align: center; color: var(--text-tertiary); padding: 20px;">No mining data available for the selected date range.</p>';
2167
+ }
2168
+ }
2169
+ } catch (e) {
2170
+ console.error('Failed to load mining heatmap:', e);
2171
+ const container = document.getElementById('miningHeatmapContainer');
2172
+ if (container) {
2173
+ container.innerHTML = '<p style="text-align: center; color: var(--text-tertiary); padding: 20px;">Failed to load mining heatmap.</p>';
2174
+ }
2175
+ }
2176
+ }
2177
+
911
2178
  // Function to load stats data with optional year filter
912
2179
  function loadStatsData(start_timestamp = null, end_timestamp = null) {
913
2180
  let url = '/api/stats';
@@ -924,6 +2191,9 @@ document.addEventListener('DOMContentLoaded', function () {
924
2191
  url += `?${queryString}`;
925
2192
  }
926
2193
 
2194
+ // Load mining heatmap separately
2195
+ loadMiningHeatmap(start_timestamp, end_timestamp);
2196
+
927
2197
  return fetch(url)
928
2198
  .then(response => response.json())
929
2199
  .then(data => {
@@ -940,19 +2210,10 @@ document.addEventListener('DOMContentLoaded', function () {
940
2210
  return data;
941
2211
  }
942
2212
 
943
- // Filter datasets for each chart
944
- const linesData = {
945
- labels: data.labels,
946
- datasets: data.datasets.filter(d => d.for === "Lines Received")
947
- };
948
-
949
- const charsData = {
950
- labels: data.labels,
951
- datasets: data.datasets.filter(d => d.for === 'Characters Read')
952
- };
953
-
954
- // Remove the 'hidden' property so they appear on their own charts
955
- [...charsData.datasets].forEach(d => delete d.hidden);
2213
+ // Get filtered datasets (cached if possible)
2214
+ const filtered = getFilteredDatasets(data);
2215
+ const linesData = filtered.linesData;
2216
+ const charsData = filtered.charsData;
956
2217
 
957
2218
  // Charts are re-created with the new data
958
2219
  createChart('linesChart', linesData, 'Cumulative Lines Received');
@@ -960,12 +2221,12 @@ document.addEventListener('DOMContentLoaded', function () {
960
2221
 
961
2222
  // Create reading chars quantity chart if data exists (with trendline)
962
2223
  if (data.totalCharsPerGame) {
963
- createGameBarChart('readingCharsChart', data.totalCharsPerGame, 'Reading Chars Quantity', 'Characters Read', true);
2224
+ createGameBarChart('readingCharsChart', data.totalCharsPerGame, 'Reading Chars Quantity', 'Characters Read', false);
964
2225
  }
965
2226
 
966
2227
  // Create reading time quantity chart if data exists (with trendline)
967
2228
  if (data.readingTimePerGame) {
968
- createGameBarChartWithCustomFormat('readingTimeChart', data.readingTimePerGame, 'Reading Time Quantity', 'Time (hours)', formatTime, true);
2229
+ createGameBarChartWithCustomFormat('readingTimeChart', data.readingTimePerGame, 'Reading Time Quantity', 'Time (hours)', formatTime, false);
969
2230
  }
970
2231
 
971
2232
  // Create reading speed per game chart if data exists (with trendline)
@@ -983,6 +2244,53 @@ document.addEventListener('DOMContentLoaded', function () {
983
2244
  createHourlyReadingSpeedChart('hourlyReadingSpeedChart', data.hourlyReadingSpeedData);
984
2245
  }
985
2246
 
2247
+ // Create top 5 reading speed days chart if data exists
2248
+ if (data.readingSpeedHeatmapData) {
2249
+ createTopReadingSpeedDaysChart('topReadingSpeedDaysChart', data.readingSpeedHeatmapData);
2250
+ }
2251
+
2252
+ // Create top 5 character count days chart if data exists
2253
+ if (data.heatmapData) {
2254
+ createTopCharacterDaysChart('topCharacterDaysChart', data.heatmapData);
2255
+ }
2256
+
2257
+ // Create day of week activity chart if data exists
2258
+ if (data.dayOfWeekData) {
2259
+ createDayOfWeekChart('dayOfWeekChart', data.dayOfWeekData);
2260
+ }
2261
+
2262
+ // Create average hours by day chart if data exists
2263
+ if (data.dayOfWeekData) {
2264
+ createAvgHoursByDayChart('avgHoursByDayChart', data.dayOfWeekData);
2265
+ }
2266
+
2267
+ // Create difficulty speed chart if data exists
2268
+ if (data.difficultySpeedData) {
2269
+ createDifficultySpeedChart('difficultySpeedChart', data.difficultySpeedData);
2270
+ }
2271
+
2272
+ // Create game type chart if data exists
2273
+ if (data.gameTypeData) {
2274
+ createGameTypeChart('gameTypeChart', data.gameTypeData);
2275
+ }
2276
+
2277
+ createCardsMinedChart('cardsMinedChart', data.cardsMinedLast30Days || null);
2278
+
2279
+ // Create mining heatmap if data exists
2280
+ if (data.miningHeatmapData) {
2281
+ if (Object.keys(data.miningHeatmapData).length > 0) {
2282
+ createMiningHeatmap(data.miningHeatmapData);
2283
+ } else {
2284
+ const container = document.getElementById('miningHeatmapContainer');
2285
+ if (container) {
2286
+ container.innerHTML = '<p style="text-align: center; color: var(--text-tertiary); padding: 20px;">No mining data available for the selected date range.</p>';
2287
+ }
2288
+ }
2289
+ }
2290
+
2291
+ // Load and create daily activity charts
2292
+ loadDailyActivityCharts();
2293
+
986
2294
  // Create kanji grid if data exists
987
2295
  if (data.kanjiGridData) {
988
2296
  createKanjiGrid(data.kanjiGridData);
@@ -993,6 +2301,11 @@ document.addEventListener('DOMContentLoaded', function () {
993
2301
  updatePeakStatistics(data.peakDailyStats, data.peakSessionStats);
994
2302
  }
995
2303
 
2304
+ // Update game milestones if data exists
2305
+ if (data.gameMilestones) {
2306
+ updateGameMilestones(data.gameMilestones);
2307
+ }
2308
+
996
2309
  return data;
997
2310
  })
998
2311
  .catch(error => {
@@ -1016,7 +2329,7 @@ document.addEventListener('DOMContentLoaded', function () {
1016
2329
  }
1017
2330
 
1018
2331
  // ================================
1019
- // Initialize date inputs with sessionStorage or fetch initial values
2332
+ // Initialize date inputs with sessionStorage or use config values
1020
2333
  // Dispatches "datesSet" event once dates are set
1021
2334
  // ================================
1022
2335
  function initializeDates() {
@@ -1026,27 +2339,26 @@ document.addEventListener('DOMContentLoaded', function () {
1026
2339
  if (!fromDateInput || !toDateInput) return; // Null check
1027
2340
 
1028
2341
  const fromDate = sessionStorage.getItem("fromDate");
1029
- const toDate = sessionStorage.getItem("toDate");
2342
+ const toDate = sessionStorage.getItem("toDate");
1030
2343
 
1031
2344
  if (!(fromDate && toDate)) {
1032
- fetch('/api/stats')
1033
- .then(response => response.json())
1034
- .then(response_json => {
1035
- // Get first date from API
1036
- const firstDate = response_json.allGamesStats.first_date;
1037
- fromDateInput.value = firstDate;
1038
-
1039
- // Get today's date
1040
- const today = new Date();
1041
- const toDate = today.toLocaleDateString('en-CA');
1042
- toDateInput.value = toDate;
1043
-
1044
- // Save in sessionStorage
1045
- sessionStorage.setItem("fromDate", firstDate);
1046
- sessionStorage.setItem("toDate", toDate);
1047
-
1048
- document.dispatchEvent(new Event("datesSet"));
1049
- });
2345
+ // Use first_date from statsConfig if available (avoids extra API call)
2346
+ const firstDate = window.statsConfig && window.statsConfig.firstDate
2347
+ ? window.statsConfig.firstDate
2348
+ : new Date().toLocaleDateString('en-CA'); // Fallback to today
2349
+
2350
+ fromDateInput.value = firstDate;
2351
+
2352
+ // Get today's date
2353
+ const today = new Date();
2354
+ const todayStr = today.toLocaleDateString('en-CA');
2355
+ toDateInput.value = todayStr;
2356
+
2357
+ // Save in sessionStorage
2358
+ sessionStorage.setItem("fromDate", firstDate);
2359
+ sessionStorage.setItem("toDate", todayStr);
2360
+
2361
+ document.dispatchEvent(new Event("datesSet"));
1050
2362
  } else {
1051
2363
  // If values already in sessionStorage, set inputs from there
1052
2364
  fromDateInput.value = fromDate;
@@ -1130,6 +2442,53 @@ document.addEventListener('DOMContentLoaded', function () {
1130
2442
 
1131
2443
  // Make functions globally available
1132
2444
  window.loadStatsData = loadStatsData;
2445
+
2446
+ // Add toggle button functionality for all three daily charts
2447
+ const toggleTimeDataBtn = document.getElementById('toggleTimeDataBtn');
2448
+ const toggleCharsDataBtn = document.getElementById('toggleCharsDataBtn');
2449
+ const toggleSpeedDataBtn = document.getElementById('toggleSpeedDataBtn');
2450
+
2451
+ // Helper function to handle toggle button clicks
2452
+ function setupToggleButton(button) {
2453
+ if (!button) return;
2454
+
2455
+ button.addEventListener('click', function() {
2456
+ const currentMode = this.getAttribute('data-mode');
2457
+
2458
+ if (currentMode === '30days') {
2459
+ // Switch to all-time data
2460
+ this.setAttribute('data-mode', 'alltime');
2461
+ this.textContent = 'View 30 days data';
2462
+ loadDailyActivityCharts(true);
2463
+
2464
+ // Update all other buttons to match
2465
+ [toggleTimeDataBtn, toggleCharsDataBtn, toggleSpeedDataBtn].forEach(btn => {
2466
+ if (btn && btn !== this) {
2467
+ btn.setAttribute('data-mode', 'alltime');
2468
+ btn.textContent = 'View 30 days data';
2469
+ }
2470
+ });
2471
+ } else {
2472
+ // Switch back to 30 days data
2473
+ this.setAttribute('data-mode', '30days');
2474
+ this.textContent = 'View all time data';
2475
+ loadDailyActivityCharts(false);
2476
+
2477
+ // Update all other buttons to match
2478
+ [toggleTimeDataBtn, toggleCharsDataBtn, toggleSpeedDataBtn].forEach(btn => {
2479
+ if (btn && btn !== this) {
2480
+ btn.setAttribute('data-mode', '30days');
2481
+ btn.textContent = 'View all time data';
2482
+ }
2483
+ });
2484
+ }
2485
+ });
2486
+ }
2487
+
2488
+ // Setup all toggle buttons
2489
+ setupToggleButton(toggleTimeDataBtn);
2490
+ setupToggleButton(toggleCharsDataBtn);
2491
+ setupToggleButton(toggleSpeedDataBtn);
1133
2492
 
1134
2493
  // ExStatic Import Functionality
1135
2494
  const exstaticFileInput = document.getElementById('exstaticFile');
@@ -1143,7 +2502,8 @@ document.addEventListener('DOMContentLoaded', function () {
1143
2502
  // Enable/disable import button based on file selection
1144
2503
  exstaticFileInput.addEventListener('change', function(e) {
1145
2504
  const file = e.target.files[0];
1146
- if (file && file.type === 'text/csv' && file.name.toLowerCase().endsWith('.csv')) {
2505
+ // Enable button whenever any file is selected
2506
+ if (file) {
1147
2507
  importExstaticBtn.disabled = false;
1148
2508
  importExstaticBtn.style.background = '#2980b9';
1149
2509
  importExstaticBtn.style.cursor = 'pointer';
@@ -1152,9 +2512,6 @@ document.addEventListener('DOMContentLoaded', function () {
1152
2512
  importExstaticBtn.disabled = true;
1153
2513
  importExstaticBtn.style.background = '#666';
1154
2514
  importExstaticBtn.style.cursor = 'not-allowed';
1155
- if (file) {
1156
- showImportStatus('Please select a valid CSV file.', 'error', true);
1157
- }
1158
2515
  }
1159
2516
  });
1160
2517