mnemo_cards/mnemo_cards_backend/lib/statistics/statistics_calculator.dart
Dmitry 336bafc600
Some checks are pending
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 Admin Panel / Deploy Admin Panel (push) Waiting to run
Deploy Admin Panel / Admin Panel Verification (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
Deploy Telegram Bot / Deploy Telegram Bot (push) Waiting to run
tasks and stuff
2026-01-09 20:21:18 +03:00

566 lines
18 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'dart:math';
import 'package:injectable/injectable.dart';
import 'package:mnemo_cards_backend/repository/export.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;
final PackRepository _packRepository;
final WordStatisticsRepository _wordStatisticsRepository;
final StatisticsRepository _statisticsRepository;
StatisticsCalculator(
this._db,
this._userRepository,
this._packRepository,
this._wordStatisticsRepository,
this._statisticsRepository,
);
/// 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 _packRepository.getPackById(packId);
if (pack == null) {
return PackProgressDto.empty(packId, 0);
}
// Получить статистику по словам пака из WordStatistics
final wordStats = await _wordStatisticsRepository.getPackStatistics(
userId,
packId,
);
// Получить сессии изучения этого пака
final sessions = await _statisticsRepository.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 _statisticsRepository.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 _statisticsRepository.getSessionsByUserId(userId);
final categoryMinutes = <String, int>{};
for (final session in sessions) {
if (session.packId == null) continue;
// Получить категорию пака
// Примечание: в CardPacks нет поля category, используем 'unknown'
// В будущем можно добавить поле category в CardPacks или использовать другую логику
final pack = await _packRepository.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,
};
}
}