Some checks failed
Backend CI / test (push) Waiting to run
Backend CI / build (push) Blocked by required conditions
Web App CI / test (push) Waiting to run
Web App CI / build (push) Blocked by required conditions
Deploy Mnemo Cards / Deploy Backend (push) Waiting to run
Deploy Mnemo Cards / Deploy Web App (push) Blocked by required conditions
Deploy Mnemo Cards / Final Verification (push) Blocked by required conditions
Mobile App CI / test (push) Has been cancelled
Deploy Telegram Bot / Deploy Telegram Bot (push) Has been cancelled
Mobile App CI / build-android (push) Has been cancelled
Mobile App CI / build-ios (push) Has been cancelled
557 lines
18 KiB
Dart
557 lines
18 KiB
Dart
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<PackProgressDto> 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<int>(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 = <String, int>{};
|
||
for (final stat in wordStats) {
|
||
final attempts = stat.correctAnswers + stat.incorrectAnswers;
|
||
if (attempts > 0) {
|
||
cardAttempts[stat.cardId] = attempts;
|
||
}
|
||
}
|
||
|
||
// Средняя точность
|
||
final totalCorrect = wordStats.fold<int>(
|
||
0,
|
||
(sum, s) => sum + s.correctAnswers,
|
||
);
|
||
final totalIncorrect = wordStats.fold<int>(
|
||
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<List<PackProgressDto>> calculateAllPackProgress(String userId) async {
|
||
// Получить все паки пользователя
|
||
final userPacks = await _userRepository.getUserPacks(userId);
|
||
|
||
// Рассчитать прогресс для каждого пака
|
||
final progressList = <PackProgressDto>[];
|
||
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<List<DateTime>> calculateStudyDates(String userId) async {
|
||
final sessions = await _db.statisticsDao.getSessionsByUserId(userId);
|
||
|
||
// Получить уникальные даты (без времени)
|
||
final uniqueDates = <DateTime>{};
|
||
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<Map<String, int>> calculateCategoryMinutes(String userId) async {
|
||
final sessions = await _db.statisticsDao.getSessionsByUserId(userId);
|
||
|
||
final categoryMinutes = <String, int>{};
|
||
|
||
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<DateTime> studyDates) {
|
||
if (studyDates.isEmpty) return 0;
|
||
|
||
// Sort dates in descending order (newest first)
|
||
final sortedDates = List<DateTime>.from(studyDates)
|
||
..sort((a, b) => b.compareTo(a));
|
||
|
||
// Remove duplicates and normalize to date-only (remove time)
|
||
final uniqueDates = <DateTime>{};
|
||
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<DetailedWordStatisticsDto> 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 = <DetailedWordStatisticsDto>[];
|
||
|
||
// 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<int>(
|
||
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<DateTime, int> calculateDailyStudyTime(UserDataModel data) {
|
||
final dailyTime = <DateTime, int>{};
|
||
|
||
// 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<Map<String, dynamic>> 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 = <DateTime, int>{};
|
||
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 = <DateTime, int>{};
|
||
final periodStudyDates = <DateTime>[];
|
||
|
||
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<int>(
|
||
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<DateTime, int> calculateWordsLearnedTimeline(UserDataModel data) {
|
||
final timeline = <DateTime, int>{};
|
||
|
||
// 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<String, double> calculatePackCompletionProgress(UserDataModel data) {
|
||
final progress = <String, double>{};
|
||
|
||
for (final packProgress in data.packProgress) {
|
||
progress[packProgress.packId] = packProgress.progress;
|
||
}
|
||
|
||
return progress;
|
||
}
|
||
|
||
/// Get achievement progress for a user
|
||
List<AchievementDto> calculateAchievementProgress(UserDataModel data) {
|
||
final achievements = <AchievementDto>[];
|
||
|
||
// 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<String, double> 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<double>(
|
||
0,
|
||
(sum, word) => sum + word.correct,
|
||
);
|
||
final totalIncorrect = data.words.fold<double>(
|
||
0,
|
||
(sum, word) => sum + word.incorrect,
|
||
);
|
||
final accuracy = totalCorrect / (totalCorrect + totalIncorrect);
|
||
|
||
// Calculate average difficulty (simplified)
|
||
final averageDifficulty =
|
||
data.words.fold<double>(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,
|
||
};
|
||
}
|
||
}
|