import 'dart:math'; import 'package:injectable/injectable.dart'; import 'package:mnemo_cards_backend/user/user_repository.dart'; import 'package:mnemo_cards_common/mnemo_cards_common.dart'; import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart'; import 'package:mnemo_cards_backend/database/database.dart'; /// Service for calculating various user statistics from raw data @lazySingleton class StatisticsCalculator { final AppDatabase _db; final UserRepository _userRepository; StatisticsCalculator(this._db, this._userRepository); /// Calculate pack progress for a specific user and pack /// /// Данные теперь берутся из WordStatistics + UserPacks + CardPacks /// вместо денормализованного поля UserDatas.packProgress Future calculatePackProgress( String userId, String packId, ) async { // Получить информацию о паке final pack = await _db.packDao.getPackById(packId); if (pack == null) { return PackProgressDto.empty(packId, 0); } // Получить статистику по словам пака из WordStatistics final wordStats = await _db.wordStatisticsDao.getPackStatistics( userId, packId, ); // Получить сессии изучения этого пака final sessions = await _db.statisticsDao.getSessionsByUserId( userId, limit: null, ); final packSessions = sessions.where((s) => s.packId == packId).toList(); // Рассчитать метрики final learnedCards = wordStats.length; // Карточки, на которые был ответ final totalCards = pack.size; // Время изучения из сессий final studyTimeMinutes = packSessions.fold(0, (sum, session) { if (session.endTime != null && session.startTime != null) { final duration = session.endTime!.dateTime.difference( session.startTime.dateTime, ); return sum + duration.inMinutes; } return sum; }); // Даты изучения final lastStudyDate = packSessions.isNotEmpty ? packSessions .map((s) => s.startTime.dateTime) .reduce((a, b) => a.isAfter(b) ? a : b) : null; final firstStudyDate = packSessions.isNotEmpty ? packSessions .map((s) => s.startTime.dateTime) .reduce((a, b) => a.isBefore(b) ? a : b) : null; // Попытки по карточкам final cardAttempts = {}; for (final stat in wordStats) { final attempts = stat.correctAnswers + stat.incorrectAnswers; if (attempts > 0) { cardAttempts[stat.cardId] = attempts; } } // Средняя точность final totalCorrect = wordStats.fold( 0, (sum, s) => sum + s.correctAnswers, ); final totalIncorrect = wordStats.fold( 0, (sum, s) => sum + s.incorrectAnswers, ); final totalAttempts = totalCorrect + totalIncorrect; final averageAccuracy = totalAttempts > 0 ? totalCorrect / totalAttempts : 0.0; return PackProgressDto( packId: packId, totalCards: totalCards, learnedCards: learnedCards, studyTimeMinutes: studyTimeMinutes, lastStudyDate: lastStudyDate, firstStudyDate: firstStudyDate, cardAttempts: cardAttempts, averageAccuracy: averageAccuracy, ); } /// Calculate pack progress for all user packs Future> calculateAllPackProgress(String userId) async { // Получить все паки пользователя final userPacks = await _userRepository.getUserPacks(userId); // Рассчитать прогресс для каждого пака final progressList = []; for (final pack in userPacks) { if (pack.id == null) continue; final progress = await calculatePackProgress(userId, pack.id!); progressList.add(progress); } return progressList; } /// Calculate study dates (unique dates when user studied) /// /// Данные теперь берутся из StudySessions вместо UserDatas.studyDates Future> calculateStudyDates(String userId) async { final sessions = await _db.statisticsDao.getSessionsByUserId(userId); // Получить уникальные даты (без времени) final uniqueDates = {}; for (final session in sessions) { final date = DateTime( session.startTime.dateTime.year, session.startTime.dateTime.month, session.startTime.dateTime.day, ); uniqueDates.add(date); } final datesList = uniqueDates.toList() ..sort((a, b) => b.compareTo(a)); // Новые первыми return datesList; } /// Calculate category minutes (study time per pack category) /// /// Данные теперь берутся из StudySessions + CardPacks.category /// вместо UserDatas.categoryMinutes Future> calculateCategoryMinutes(String userId) async { final sessions = await _db.statisticsDao.getSessionsByUserId(userId); final categoryMinutes = {}; for (final session in sessions) { if (session.packId == null) continue; // Получить категорию пака // Примечание: в CardPacks нет поля category, используем 'unknown' // В будущем можно добавить поле category в CardPacks или использовать другую логику final pack = await _db.packDao.getPackById(session.packId!); final category = pack != null ? 'unknown' : 'unknown'; // TODO: добавить category в CardPacks // Рассчитать время сессии в минутах int sessionMinutes = 0; if (session.endTime != null && session.startTime != null) { final duration = session.endTime!.dateTime.difference( session.startTime.dateTime, ); sessionMinutes = duration.inMinutes; } categoryMinutes[category] = (categoryMinutes[category] ?? 0) + sessionMinutes; } return categoryMinutes; } /// 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 /// /// Примечание: теперь принимает userId вместо UserDataModel /// для работы с новой структурой (без удаленных полей) Future> getTimelineStatistics( String userId, { required String period, DateTime? from, DateTime? to, }) async { final now = DateTime.now(); final startDate = from ?? _getStartDateForPeriod(period, now); final endDate = to ?? now; // Получить сессии для расчета времени final sessions = await _db.statisticsDao.getSessionsByUserId( userId, fromDate: startDate, toDate: endDate, ); // Рассчитать ежедневное время из сессий final dailyTime = {}; for (final session in sessions) { if (session.endTime != null && session.startTime != null) { final date = DateTime( session.startTime.dateTime.year, session.startTime.dateTime.month, session.startTime.dateTime.day, ); final duration = session.endTime!.dateTime.difference( session.startTime.dateTime, ); dailyTime[date] = (dailyTime[date] ?? 0) + duration.inMinutes; } } // Получить studyDates final allStudyDates = await calculateStudyDates(userId); // 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 allStudyDates) { 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 (из рассчитанных studyDates) 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, }; } }