import 'dart:math'; import 'package:injectable/injectable.dart'; import 'package:mnemo_cards_common/mnemo_cards_common.dart'; import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart'; /// Service for calculating various user statistics from raw data @lazySingleton class StatisticsCalculator { /// Calculate pack progress for a specific user and pack PackProgressDto calculatePackProgress( UserModel user, String packId, ) { // Load user data final userData = user.userData; if (userData == null) { return PackProgressDto.empty(packId, 0); } // Find existing pack progress or create new final existingProgress = userData.packProgress .where((progress) => progress.packId == packId) .firstOrNull; if (existingProgress != null) { return existingProgress.toDto(); } // If no existing progress, get pack info and create empty progress // This would need to be enhanced with actual pack data lookup // For now, return empty progress return PackProgressDto.empty(packId, 0); } /// Calculate current streak (consecutive days with study activity) int calculateStreak(List studyDates) { if (studyDates.isEmpty) return 0; // Sort dates in descending order (newest first) final sortedDates = List.from(studyDates) ..sort((a, b) => b.compareTo(a)); // Remove duplicates and normalize to date-only (remove time) final uniqueDates = {}; for (final date in sortedDates) { uniqueDates.add(DateTime(date.year, date.month, date.day)); } final normalizedDates = uniqueDates.toList() ..sort((a, b) => b.compareTo(a)); // Newest first if (normalizedDates.isEmpty) return 0; var streak = 0; var currentDate = DateTime.now(); currentDate = DateTime(currentDate.year, currentDate.month, currentDate.day); // Check if today or yesterday has activity final hasRecentActivity = normalizedDates.any((date) { final daysDiff = currentDate.difference(date).inDays; return daysDiff <= 1; // Allow 1 day gap for streak }); if (!hasRecentActivity) return 0; // Count consecutive days for (final date in normalizedDates) { final expectedDate = currentDate.subtract(Duration(days: streak)); if (date.year == expectedDate.year && date.month == expectedDate.month && date.day == expectedDate.day) { streak++; } else if (date.isBefore(expectedDate)) { // Gap in dates, streak broken break; } } return streak; } /// Find difficult words that need review List findDifficultWords( UserDataModel data, { int limit = 20, }) { final words = data.words.map((model) { // Convert to detailed stats if not already return DetailedWordStatisticsDto.fromWordStatisticsDto( WordStatisticsDto( word: model.word, correct: model.correct, incorrect: model.incorrect, skipped: model.skipped, questionTypes: model.questionTypes.toSet(), ), ); }).toList(); // Sort by difficulty score (highest first) words.sort((a, b) => b.difficultyScore.compareTo(a.difficultyScore)); // Filter to words that need review final needsReview = words.where((word) => word.needsReview).toList(); // Take top difficult words, prioritizing those that need review final result = []; // First add words that need review result.addAll(needsReview.take(limit)); // Then add other difficult words if we haven't reached the limit if (result.length < limit) { final remaining = words .where((word) => !word.needsReview && !result.any((added) => added.word == word.word)) .take(limit - result.length); result.addAll(remaining); } return result; } /// Calculate overall accuracy from word statistics double calculateAccuracy(AllWordsStatisticsDto words) { final totalCorrect = words.correct; final totalIncorrect = words.incorrect; final total = totalCorrect + totalIncorrect; if (total == 0) return 0.0; return totalCorrect / total; } /// Calculate total study time from user data int calculateTotalStudyTime(UserDataModel data) { // Sum study time from pack progress final packTime = data.packProgress.fold( 0, (sum, progress) => sum + progress.studyTimeMinutes, ); // Could also add time from study sessions if needed // For now, just return pack time return packTime; } /// Calculate study time per day for timeline Map calculateDailyStudyTime(UserDataModel data) { final dailyTime = {}; // Aggregate time from pack progress by date // This is simplified - in reality we'd need session data for (final progress in data.packProgress) { if (progress.lastStudyDate != null) { final date = DateTime( progress.lastStudyDate!.year, progress.lastStudyDate!.month, progress.lastStudyDate!.day, ); dailyTime[date] = (dailyTime[date] ?? 0) + progress.studyTimeMinutes; } } return dailyTime; } /// Get timeline statistics for a specific period Map getTimelineStatistics( UserDataModel data, { required String period, DateTime? from, DateTime? to, }) { final now = DateTime.now(); final startDate = from ?? _getStartDateForPeriod(period, now); final endDate = to ?? now; final dailyTime = calculateDailyStudyTime(data); final studyDates = data.studyDates; // Filter data for the period final periodDailyTime = {}; final periodStudyDates = []; for (final entry in dailyTime.entries) { if (entry.key.isAfter(startDate.subtract(const Duration(days: 1))) && entry.key.isBefore(endDate.add(const Duration(days: 1)))) { periodDailyTime[entry.key] = entry.value; } } for (final date in studyDates) { if (date.isAfter(startDate.subtract(const Duration(days: 1))) && date.isBefore(endDate.add(const Duration(days: 1)))) { periodStudyDates.add(date); } } // Calculate aggregates final totalDays = endDate.difference(startDate).inDays + 1; final activeDays = periodDailyTime.length; final totalMinutes = periodDailyTime.values.fold(0, (sum, time) => sum + time); final averageDailyMinutes = activeDays > 0 ? totalMinutes / activeDays : 0; // Calculate streak in period final periodStreak = calculateStreak(periodStudyDates); return { 'period': period, 'startDate': startDate.toIso8601String(), 'endDate': endDate.toIso8601String(), 'totalDays': totalDays, 'activeDays': activeDays, 'totalMinutes': totalMinutes, 'averageDailyMinutes': averageDailyMinutes, 'currentStreak': periodStreak, 'dailyActivity': periodDailyTime.map( (date, minutes) => MapEntry(date.toIso8601String(), minutes), ), 'studyDates': periodStudyDates.map((date) => date.toIso8601String()).toList(), }; } /// Calculate words learned over time Map calculateWordsLearnedTimeline(UserDataModel data) { final timeline = {}; // This would need to be implemented based on word learning history // For now, return empty map // In a real implementation, we'd track when each word was first learned return timeline; } /// Calculate pack completion progress Map calculatePackCompletionProgress(UserDataModel data) { final progress = {}; for (final packProgress in data.packProgress) { progress[packProgress.packId] = packProgress.progress; } return progress; } /// Get achievement progress for a user List calculateAchievementProgress(UserDataModel data) { final achievements = []; // Current streak achievement final currentStreak = data.currentStreak; if (currentStreak >= 3) { achievements.add(AchievementDto( id: 'streak_3', title: '3-Day Streak', description: 'Study for 3 consecutive days', type: AchievementType.streak3Days, )); } if (currentStreak >= 7) { achievements.add(AchievementDto( id: 'streak_7', title: 'Week Warrior', description: 'Study for 7 consecutive days', type: AchievementType.streak7Days, )); } // Add more streak achievements... // Words learned achievements final totalWords = data.words.length; if (totalWords >= 10) { achievements.add(AchievementDto( id: 'words_10', title: 'Word Explorer', description: 'Learn 10 words', type: AchievementType.words10Learned, )); } // Add more word achievements... // Study time achievements final totalHours = data.totalStudyTimeMinutes / 60.0; if (totalHours >= 100) { achievements.add(AchievementDto( id: 'dedicated_learner', title: 'Dedicated Learner', description: 'Study for 100 hours total', type: AchievementType.dedicatedLearner, )); } return achievements; } /// Calculate user level based on activity int calculateUserLevel(UserDataModel data) { final wordsLearned = data.words.length; final studyHours = data.totalStudyTimeMinutes / 60.0; final packsCompleted = data.packProgress.where((p) => p.progress >= 1.0).length; // Simple level calculation final score = wordsLearned + (studyHours * 2) + (packsCompleted * 10); return max(1, (score / 50).ceil()); } /// Get start date for a given period DateTime _getStartDateForPeriod(String period, DateTime now) { switch (period.toLowerCase()) { case 'day': return now.subtract(const Duration(days: 1)); case 'week': return now.subtract(const Duration(days: 7)); case 'month': return DateTime(now.year, now.month - 1, now.day); case 'year': return DateTime(now.year - 1, now.month, now.day); default: return now.subtract(const Duration(days: 30)); } } /// Calculate performance metrics Map calculatePerformanceMetrics(UserDataModel data) { final totalWords = data.words.length; if (totalWords == 0) { return { 'accuracy': 0.0, 'averageDifficulty': 0.0, 'consistency': 0.0, }; } // Calculate accuracy final totalCorrect = data.words.fold(0, (sum, word) => sum + word.correct); final totalIncorrect = data.words.fold(0, (sum, word) => sum + word.incorrect); final accuracy = totalCorrect / (totalCorrect + totalIncorrect); // Calculate average difficulty (simplified) final averageDifficulty = data.words.fold(0, (sum, word) { final total = word.correct + word.incorrect + word.skipped; if (total == 0) return sum; return sum + (word.incorrect / total); }) / totalWords; // Calculate consistency (based on streak) final consistency = min(1.0, data.currentStreak / 30.0); return { 'accuracy': accuracy.isNaN ? 0.0 : accuracy, 'averageDifficulty': averageDifficulty.isNaN ? 0.0 : averageDifficulty, 'consistency': consistency, }; } }