GameSentenceMiner 2.18.14__py3-none-any.whl → 2.18.16__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 (36) hide show
  1. GameSentenceMiner/anki.py +8 -53
  2. GameSentenceMiner/obs.py +1 -2
  3. GameSentenceMiner/ui/anki_confirmation.py +16 -2
  4. GameSentenceMiner/util/db.py +11 -7
  5. GameSentenceMiner/util/games_table.py +320 -0
  6. GameSentenceMiner/vad.py +3 -3
  7. GameSentenceMiner/web/anki_api_endpoints.py +506 -0
  8. GameSentenceMiner/web/database_api.py +239 -117
  9. GameSentenceMiner/web/static/css/loading-skeleton.css +41 -0
  10. GameSentenceMiner/web/static/css/search.css +54 -0
  11. GameSentenceMiner/web/static/css/stats.css +76 -0
  12. GameSentenceMiner/web/static/js/anki_stats.js +304 -50
  13. GameSentenceMiner/web/static/js/database.js +44 -7
  14. GameSentenceMiner/web/static/js/heatmap.js +326 -0
  15. GameSentenceMiner/web/static/js/overview.js +20 -224
  16. GameSentenceMiner/web/static/js/search.js +190 -23
  17. GameSentenceMiner/web/static/js/stats.js +371 -1
  18. GameSentenceMiner/web/stats.py +188 -0
  19. GameSentenceMiner/web/templates/anki_stats.html +145 -58
  20. GameSentenceMiner/web/templates/components/date-range.html +19 -0
  21. GameSentenceMiner/web/templates/components/html-head.html +45 -0
  22. GameSentenceMiner/web/templates/components/js-config.html +37 -0
  23. GameSentenceMiner/web/templates/components/popups.html +15 -0
  24. GameSentenceMiner/web/templates/components/settings-modal.html +233 -0
  25. GameSentenceMiner/web/templates/database.html +13 -3
  26. GameSentenceMiner/web/templates/goals.html +9 -31
  27. GameSentenceMiner/web/templates/overview.html +16 -223
  28. GameSentenceMiner/web/templates/search.html +46 -0
  29. GameSentenceMiner/web/templates/stats.html +49 -311
  30. GameSentenceMiner/web/texthooking_page.py +4 -66
  31. {gamesentenceminer-2.18.14.dist-info → gamesentenceminer-2.18.16.dist-info}/METADATA +1 -1
  32. {gamesentenceminer-2.18.14.dist-info → gamesentenceminer-2.18.16.dist-info}/RECORD +36 -27
  33. {gamesentenceminer-2.18.14.dist-info → gamesentenceminer-2.18.16.dist-info}/WHEEL +0 -0
  34. {gamesentenceminer-2.18.14.dist-info → gamesentenceminer-2.18.16.dist-info}/entry_points.txt +0 -0
  35. {gamesentenceminer-2.18.14.dist-info → gamesentenceminer-2.18.16.dist-info}/licenses/LICENSE +0 -0
  36. {gamesentenceminer-2.18.14.dist-info → gamesentenceminer-2.18.16.dist-info}/top_level.txt +0 -0
@@ -527,6 +527,301 @@ document.addEventListener('DOMContentLoaded', function () {
527
527
  return `Speed: ${charsPerHour.toLocaleString()} chars/hour`;
528
528
  }
529
529
 
530
+ // Function to create hourly activity bar chart
531
+ function createHourlyActivityChart(canvasId, hourlyData) {
532
+ const canvas = document.getElementById(canvasId);
533
+ if (!canvas || !hourlyData || !Array.isArray(hourlyData)) return null;
534
+
535
+ const ctx = canvas.getContext('2d');
536
+
537
+ // Destroy existing chart if it exists
538
+ if (window.myCharts[canvasId]) {
539
+ window.myCharts[canvasId].destroy();
540
+ }
541
+
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}`);
548
+ }
549
+
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
+ window.myCharts[canvasId] = new Chart(ctx, {
576
+ type: 'bar',
577
+ data: {
578
+ labels: hourLabels,
579
+ datasets: [{
580
+ label: 'Characters Read',
581
+ data: hourlyData,
582
+ backgroundColor: barColors,
583
+ borderColor: borderColors,
584
+ borderWidth: 2
585
+ }]
586
+ },
587
+ options: {
588
+ responsive: true,
589
+ plugins: {
590
+ legend: {
591
+ display: false // Hide legend for cleaner look
592
+ },
593
+ 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
+ }
605
+ },
606
+ tooltip: {
607
+ callbacks: {
608
+ 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)`;
614
+ },
615
+ label: function(context) {
616
+ const activity = context.parsed.y;
617
+ if (activity === 0) {
618
+ return 'No reading activity';
619
+ }
620
+ return `Characters: ${activity.toLocaleString()}`;
621
+ },
622
+ 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`;
629
+ }
630
+ },
631
+ backgroundColor: 'rgba(0, 0, 0, 0.9)',
632
+ titleColor: '#fff',
633
+ bodyColor: '#fff',
634
+ borderColor: 'rgba(255, 255, 255, 0.2)',
635
+ borderWidth: 1,
636
+ cornerRadius: 8,
637
+ displayColors: true
638
+ }
639
+ },
640
+ scales: {
641
+ y: {
642
+ beginAtZero: true,
643
+ title: {
644
+ display: true,
645
+ text: 'Characters Read',
646
+ color: getThemeTextColor()
647
+ },
648
+ ticks: {
649
+ color: getThemeTextColor(),
650
+ callback: function(value) {
651
+ return value.toLocaleString();
652
+ }
653
+ }
654
+ },
655
+ x: {
656
+ title: {
657
+ display: true,
658
+ text: 'Hour of Day',
659
+ color: getThemeTextColor()
660
+ },
661
+ ticks: {
662
+ color: getThemeTextColor()
663
+ }
664
+ }
665
+ },
666
+ animation: {
667
+ duration: 1000,
668
+ easing: 'easeOutQuart'
669
+ }
670
+ }
671
+ });
672
+
673
+ return window.myCharts[canvasId];
674
+ }
675
+
676
+ // Function to create hourly reading speed bar chart
677
+ function createHourlyReadingSpeedChart(canvasId, hourlySpeedData) {
678
+ const canvas = document.getElementById(canvasId);
679
+ if (!canvas || !hourlySpeedData || !Array.isArray(hourlySpeedData)) return null;
680
+
681
+ const ctx = canvas.getContext('2d');
682
+
683
+ // Destroy existing chart if it exists
684
+ if (window.myCharts[canvasId]) {
685
+ window.myCharts[canvasId].destroy();
686
+ }
687
+
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));
699
+
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)';
703
+ }
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
+ });
710
+
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)`;
719
+ });
720
+
721
+ window.myCharts[canvasId] = new Chart(ctx, {
722
+ type: 'bar',
723
+ data: {
724
+ labels: hourLabels,
725
+ datasets: [{
726
+ label: 'Average Reading Speed',
727
+ data: hourlySpeedData,
728
+ backgroundColor: barColors,
729
+ borderColor: borderColors,
730
+ borderWidth: 2
731
+ }]
732
+ },
733
+ options: {
734
+ responsive: true,
735
+ plugins: {
736
+ legend: {
737
+ display: false // Hide legend for cleaner look
738
+ },
739
+ 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
+ }
751
+ },
752
+ tooltip: {
753
+ callbacks: {
754
+ 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)`;
760
+ },
761
+ label: function(context) {
762
+ const speed = context.parsed.y;
763
+ if (speed === 0) {
764
+ return 'No reading activity';
765
+ }
766
+ return `Speed: ${speed.toLocaleString()} chars/hour`;
767
+ },
768
+ 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`;
778
+ }
779
+ },
780
+ backgroundColor: 'rgba(0, 0, 0, 0.9)',
781
+ titleColor: '#fff',
782
+ bodyColor: '#fff',
783
+ borderColor: 'rgba(255, 255, 255, 0.2)',
784
+ borderWidth: 1,
785
+ cornerRadius: 8,
786
+ displayColors: true
787
+ }
788
+ },
789
+ scales: {
790
+ y: {
791
+ beginAtZero: true,
792
+ title: {
793
+ display: true,
794
+ text: 'Characters per Hour',
795
+ color: getThemeTextColor()
796
+ },
797
+ ticks: {
798
+ color: getThemeTextColor(),
799
+ callback: function(value) {
800
+ return value.toLocaleString();
801
+ }
802
+ }
803
+ },
804
+ x: {
805
+ title: {
806
+ display: true,
807
+ text: 'Hour of Day',
808
+ color: getThemeTextColor()
809
+ },
810
+ ticks: {
811
+ color: getThemeTextColor()
812
+ }
813
+ }
814
+ },
815
+ animation: {
816
+ duration: 1000,
817
+ easing: 'easeOutQuart'
818
+ }
819
+ }
820
+ });
821
+
822
+ return window.myCharts[canvasId];
823
+ }
824
+
530
825
  // Initialize Kanji Grid Renderer (using shared component)
531
826
  const kanjiGridRenderer = new KanjiGridRenderer({
532
827
  containerSelector: '#kanjiGrid',
@@ -540,10 +835,70 @@ document.addEventListener('DOMContentLoaded', function () {
540
835
  kanjiGridRenderer.render(kanjiData);
541
836
  }
542
837
 
838
+ // Function to update peak statistics display
839
+ function updatePeakStatistics(peakDailyStats, peakSessionStats) {
840
+ // Helper function to format large numbers
841
+ function formatLargeNumber(num) {
842
+ if (num >= 1000000) {
843
+ return (num / 1000000).toFixed(1) + 'M';
844
+ } else if (num >= 1000) {
845
+ return (num / 1000).toFixed(1) + 'K';
846
+ } else {
847
+ return num.toString();
848
+ }
849
+ }
850
+
851
+ // Helper function to format time in human-readable format
852
+ function formatTimeHuman(hours) {
853
+ if (hours < 1) {
854
+ const minutes = Math.round(hours * 60);
855
+ return minutes + 'm';
856
+ } else if (hours < 24) {
857
+ const wholeHours = Math.floor(hours);
858
+ const minutes = Math.round((hours - wholeHours) * 60);
859
+ if (minutes > 0) {
860
+ return wholeHours + 'h ' + minutes + 'm';
861
+ } else {
862
+ return wholeHours + 'h';
863
+ }
864
+ } else {
865
+ const days = Math.floor(hours / 24);
866
+ const remainingHours = Math.floor(hours % 24);
867
+ if (remainingHours > 0) {
868
+ return days + 'd ' + remainingHours + 'h';
869
+ } else {
870
+ return days + 'd';
871
+ }
872
+ }
873
+ }
874
+
875
+ // Update the display elements
876
+ const maxDailyCharsEl = document.getElementById('maxDailyChars');
877
+ const maxDailyHoursEl = document.getElementById('maxDailyHours');
878
+ const longestSessionEl = document.getElementById('longestSession');
879
+ const maxSessionCharsEl = document.getElementById('maxSessionChars');
880
+
881
+ if (maxDailyCharsEl) {
882
+ maxDailyCharsEl.textContent = formatLargeNumber(peakDailyStats.max_daily_chars || 0);
883
+ }
884
+
885
+ if (maxDailyHoursEl) {
886
+ maxDailyHoursEl.textContent = formatTimeHuman(peakDailyStats.max_daily_hours || 0);
887
+ }
888
+
889
+ if (longestSessionEl) {
890
+ longestSessionEl.textContent = formatTimeHuman(peakSessionStats.longest_session_hours || 0);
891
+ }
892
+
893
+ if (maxSessionCharsEl) {
894
+ maxSessionCharsEl.textContent = formatLargeNumber(peakSessionStats.max_session_chars || 0);
895
+ }
896
+ }
897
+
543
898
  function showNoDataPopup() {
544
899
  const popup = document.getElementById("noDataPopup");
545
900
  if (popup) popup.classList.remove("hidden");
546
- }
901
+ }
547
902
 
548
903
  const closeNoDataPopup = document.getElementById("closeNoDataPopup");
549
904
  if (closeNoDataPopup) {
@@ -618,11 +973,26 @@ document.addEventListener('DOMContentLoaded', function () {
618
973
  createGameBarChartWithCustomFormat('readingSpeedPerGameChart', data.readingSpeedPerGame, 'Reading Speed Improvement', 'Speed (chars/hour)', formatSpeed, true);
619
974
  }
620
975
 
976
+ // Create hourly activity polar chart if data exists
977
+ if (data.hourlyActivityData) {
978
+ createHourlyActivityChart('hourlyActivityChart', data.hourlyActivityData);
979
+ }
980
+
981
+ // Create hourly reading speed chart if data exists
982
+ if (data.hourlyReadingSpeedData) {
983
+ createHourlyReadingSpeedChart('hourlyReadingSpeedChart', data.hourlyReadingSpeedData);
984
+ }
985
+
621
986
  // Create kanji grid if data exists
622
987
  if (data.kanjiGridData) {
623
988
  createKanjiGrid(data.kanjiGridData);
624
989
  }
625
990
 
991
+ // Update peak statistics if data exists
992
+ if (data.peakDailyStats && data.peakSessionStats) {
993
+ updatePeakStatistics(data.peakDailyStats, data.peakSessionStats);
994
+ }
995
+
626
996
  return data;
627
997
  })
628
998
  .catch(error => {
@@ -139,6 +139,34 @@ def calculate_heatmap_data(all_lines, filter_year=None):
139
139
  return dict(heatmap_data)
140
140
 
141
141
 
142
+ def calculate_mining_heatmap_data(all_lines, filter_year=None):
143
+ """
144
+ Calculate heatmap data for mining activity.
145
+ Counts lines where screenshot_in_anki OR audio_in_anki is not empty.
146
+ """
147
+ heatmap_data = defaultdict(lambda: defaultdict(int))
148
+
149
+ for line in all_lines:
150
+ # Check if line has been mined (either screenshot or audio in Anki)
151
+ has_screenshot = line.screenshot_in_anki and line.screenshot_in_anki.strip()
152
+ has_audio = line.audio_in_anki and line.audio_in_anki.strip()
153
+
154
+ if not (has_screenshot or has_audio):
155
+ continue # Skip lines that haven't been mined
156
+
157
+ date_obj = datetime.date.fromtimestamp(float(line.timestamp))
158
+ year = str(date_obj.year)
159
+
160
+ # Filter by year if specified
161
+ if filter_year and year != filter_year:
162
+ continue
163
+
164
+ date_str = date_obj.strftime('%Y-%m-%d')
165
+ heatmap_data[year][date_str] += 1 # Count mined lines, not characters
166
+
167
+ return dict(heatmap_data)
168
+
169
+
142
170
  def calculate_total_chars_per_game(all_lines):
143
171
  """Calculate total characters read per game."""
144
172
  game_data = defaultdict(lambda: {'total_chars': 0, 'first_time': None})
@@ -515,6 +543,166 @@ def calculate_average_daily_reading_time(all_lines):
515
543
 
516
544
  return average_hours
517
545
 
546
+ def calculate_hourly_activity(all_lines):
547
+ """
548
+ Calculate reading activity aggregated by hour of day (0-23).
549
+ Returns character count for each hour across all days.
550
+ """
551
+ if not all_lines:
552
+ return [0] * 24
553
+
554
+ hourly_chars = [0] * 24
555
+
556
+ for line in all_lines:
557
+ # Get hour from timestamp (0-23)
558
+ hour = datetime.datetime.fromtimestamp(float(line.timestamp)).hour
559
+ char_count = len(line.line_text) if line.line_text else 0
560
+ hourly_chars[hour] += char_count
561
+
562
+ return hourly_chars
563
+
564
+ def calculate_hourly_reading_speed(all_lines):
565
+ """
566
+ Calculate average reading speed (chars/hour) aggregated by hour of day (0-23).
567
+ Returns average reading speed for each hour across all days.
568
+ """
569
+ if not all_lines:
570
+ return [0] * 24
571
+
572
+ # Group lines by hour and collect timestamps for each hour
573
+ hourly_data = defaultdict(lambda: {'chars': 0, 'timestamps': []})
574
+
575
+ for line in all_lines:
576
+ hour = datetime.datetime.fromtimestamp(float(line.timestamp)).hour
577
+ char_count = len(line.line_text) if line.line_text else 0
578
+
579
+ hourly_data[hour]['chars'] += char_count
580
+ hourly_data[hour]['timestamps'].append(float(line.timestamp))
581
+
582
+ # Calculate average reading speed for each hour
583
+ hourly_speeds = [0] * 24
584
+
585
+ for hour in range(24):
586
+ if hour in hourly_data and len(hourly_data[hour]['timestamps']) >= 2:
587
+ chars = hourly_data[hour]['chars']
588
+ timestamps = hourly_data[hour]['timestamps']
589
+
590
+ # Calculate actual reading time for this hour across all days
591
+ reading_time_seconds = calculate_actual_reading_time(timestamps)
592
+ reading_time_hours = reading_time_seconds / 3600
593
+
594
+ # Calculate speed (chars per hour)
595
+ if reading_time_hours > 0:
596
+ hourly_speeds[hour] = int(chars / reading_time_hours)
597
+
598
+ return hourly_speeds
599
+
600
+ def calculate_peak_daily_stats(all_lines):
601
+ """
602
+ Calculate peak daily statistics: most chars read in a day and most hours studied in a day.
603
+
604
+ Args:
605
+ all_lines: List of game lines
606
+
607
+ Returns:
608
+ dict: Dictionary containing max_daily_chars and max_daily_hours
609
+ """
610
+ if not all_lines:
611
+ return {
612
+ 'max_daily_chars': 0,
613
+ 'max_daily_hours': 0.0
614
+ }
615
+
616
+ # Calculate daily reading time using existing function
617
+ daily_reading_time = calculate_daily_reading_time(all_lines)
618
+
619
+ # Calculate daily character counts
620
+ daily_chars = defaultdict(int)
621
+ for line in all_lines:
622
+ date_str = datetime.date.fromtimestamp(float(line.timestamp)).strftime('%Y-%m-%d')
623
+ char_count = len(line.line_text) if line.line_text else 0
624
+ daily_chars[date_str] += char_count
625
+
626
+ # Find maximums
627
+ max_daily_chars = max(daily_chars.values()) if daily_chars else 0
628
+ max_daily_hours = max(daily_reading_time.values()) if daily_reading_time else 0.0
629
+
630
+ return {
631
+ 'max_daily_chars': max_daily_chars,
632
+ 'max_daily_hours': max_daily_hours
633
+ }
634
+
635
+ def calculate_peak_session_stats(all_lines):
636
+ """
637
+ Calculate peak session statistics: longest session and most chars in a session.
638
+
639
+ Args:
640
+ all_lines: List of game lines
641
+
642
+ Returns:
643
+ dict: Dictionary containing longest_session_hours and max_session_chars
644
+ """
645
+ if not all_lines:
646
+ return {
647
+ 'longest_session_hours': 0.0,
648
+ 'max_session_chars': 0
649
+ }
650
+
651
+ # Sort lines by timestamp
652
+ sorted_lines = sorted(all_lines, key=lambda line: float(line.timestamp))
653
+
654
+ # Get session gap from config
655
+ session_gap = get_stats_config().session_gap_seconds
656
+
657
+ # Group lines into sessions
658
+ sessions = []
659
+ current_session = []
660
+
661
+ for line in sorted_lines:
662
+ if not current_session:
663
+ current_session = [line]
664
+ else:
665
+ # Check if this line belongs to the current session
666
+ time_gap = float(line.timestamp) - float(current_session[-1].timestamp)
667
+ if time_gap <= session_gap:
668
+ current_session.append(line)
669
+ else:
670
+ # Start a new session
671
+ if current_session:
672
+ sessions.append(current_session)
673
+ current_session = [line]
674
+
675
+ # Don't forget the last session
676
+ if current_session:
677
+ sessions.append(current_session)
678
+
679
+ # Calculate session statistics
680
+ longest_session_hours = 0.0
681
+ max_session_chars = 0
682
+
683
+ for session in sessions:
684
+ if len(session) >= 2:
685
+ # Calculate session duration using actual reading time
686
+ timestamps = [float(line.timestamp) for line in session]
687
+ session_time_seconds = calculate_actual_reading_time(timestamps)
688
+ session_hours = session_time_seconds / 3600
689
+
690
+ # Calculate session character count
691
+ session_chars = sum(len(line.line_text) if line.line_text else 0 for line in session)
692
+
693
+ # Update maximums
694
+ longest_session_hours = max(longest_session_hours, session_hours)
695
+ max_session_chars = max(max_session_chars, session_chars)
696
+ elif len(session) == 1:
697
+ # Single line session - count characters but no time
698
+ session_chars = len(session[0].line_text) if session[0].line_text else 0
699
+ max_session_chars = max(max_session_chars, session_chars)
700
+
701
+ return {
702
+ 'longest_session_hours': longest_session_hours,
703
+ 'max_session_chars': max_session_chars
704
+ }
705
+
518
706
  def calculate_all_games_stats(all_lines):
519
707
  """Calculate aggregate statistics for all games combined."""
520
708
  if not all_lines: