360 lines
11 KiB
Dart
360 lines
11 KiB
Dart
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.value;
|
|
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<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
|
|
Map<String, dynamic> 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 = <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 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<int>(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<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,
|
|
};
|
|
}
|
|
}
|