mnemo_cards/mnemo_cards_backend/lib/statistics/session_tracker.dart

284 lines
7.7 KiB
Dart
Raw Normal View History

2025-11-16 11:25:27 +00:00
import 'dart:async';
import 'dart:collection';
import 'package:injectable/injectable.dart';
import 'package:isar/isar.dart';
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
/// Service for tracking and managing user study sessions
///
/// Automatically creates, updates, and manages study sessions based on user activity.
/// Sessions are created when users start studying and are ended based on inactivity timeouts.
@lazySingleton
class SessionTracker {
final Isar _isar;
/// Active sessions cache: userId -> sessionId
final Map<int, String> _activeSessions = {};
/// Session timers for automatic timeout: sessionId -> timer
final Map<String, Timer> _sessionTimers = {};
/// Session timeout duration (30 minutes by default)
static const Duration _sessionTimeout = Duration(minutes: 30);
SessionTracker(this._isar);
/// Get or create an active session for a user
///
/// Returns the session ID of the active session.
/// If no active session exists, creates a new one.
Future<String> getOrCreateSession(int userId, {
String? packId,
String? testId,
}) async {
// Check if user already has an active session
if (_activeSessions.containsKey(userId)) {
final sessionId = _activeSessions[userId]!;
// Reset timeout timer
_resetSessionTimer(sessionId);
// Update session activity
await _updateSessionActivity(sessionId);
return sessionId;
}
// Create new session
final sessionId = _generateSessionId();
final session = StudySessionModel(
sessionId: sessionId,
userId: userId,
startTime: DateTime.now(),
packId: packId,
testId: testId,
);
// Save to database
await _isar.writeTxn(() async {
await _isar.studySessionModels.put(session);
});
// Track as active
_activeSessions[userId] = sessionId;
_resetSessionTimer(sessionId);
return sessionId;
}
/// Update session with progress data
///
/// Called when user completes a test or makes progress.
Future<void> updateSessionProgress(
String sessionId, {
int wordsLearned = 0,
int testsCompleted = 0,
double? accuracy,
}) async {
final session = await _isar.studySessionModels
.filter()
.sessionIdEqualTo(sessionId)
.findFirst();
if (session == null || !session.isActive) return;
// Update session with new progress
final updatedSession = session.addProgress(
wordsLearned: wordsLearned,
testsCompleted: testsCompleted,
accuracy: accuracy,
);
await _isar.writeTxn(() async {
await _isar.studySessionModels.put(updatedSession);
});
// Reset timeout timer
_resetSessionTimer(sessionId);
}
/// End a session manually with final statistics
///
/// Called when user explicitly ends a session or when session times out.
Future<void> endSession(
String sessionId, {
int wordsLearned = 0,
int testsCompleted = 0,
double accuracy = 0.0,
}) async {
final session = await _isar.studySessionModels
.filter()
.sessionIdEqualTo(sessionId)
.findFirst();
if (session == null || !session.isActive) return;
// End session with final statistics
final endedSession = session.end(
wordsLearned: wordsLearned,
testsCompleted: testsCompleted,
accuracy: accuracy,
);
await _isar.writeTxn(() async {
await _isar.studySessionModels.put(endedSession);
});
// Clean up tracking
final userId = session.userId;
_activeSessions.remove(userId);
_cancelSessionTimer(sessionId);
}
/// End session for a specific user
Future<void> endUserSession(int userId) async {
final sessionId = _activeSessions[userId];
if (sessionId != null) {
await endSession(sessionId);
}
}
/// Get active session for a user
Future<StudySessionModel?> getActiveSession(int userId) async {
final sessionId = _activeSessions[userId];
if (sessionId == null) return null;
return await _isar.studySessionModels
.filter()
.sessionIdEqualTo(sessionId)
.findFirst();
}
/// Get recent sessions for a user
Future<List<StudySessionModel>> getRecentSessions(
int userId, {
int limit = 10,
}) async {
return await _isar.studySessionModels
.filter()
.userIdEqualTo(userId)
.sortByStartTimeDesc()
.limit(limit)
.findAll();
}
/// Clean up expired sessions
///
/// Should be called periodically (e.g., via cron job)
Future<void> cleanupExpiredSessions() async {
final cutoffTime = DateTime.now().subtract(_sessionTimeout);
// Find sessions that should have expired but are still marked as active
final expiredSessions = await _isar.studySessionModels
.filter()
.endTimeIsNull()
.startTimeLessThan(cutoffTime)
.findAll();
for (final session in expiredSessions) {
await endSession(session.sessionId!);
}
}
/// Get session statistics for a user over a time period
Future<Map<String, dynamic>> getSessionStatistics(
int userId, {
DateTime? from,
DateTime? to,
}) async {
final query = _isar.studySessionModels.filter().userIdEqualTo(userId);
if (from != null) {
query.startTimeGreaterThan(from);
}
if (to != null) {
query.startTimeLessThan(to);
}
final sessions = await query.findAll();
if (sessions.isEmpty) {
return {
'totalSessions': 0,
'totalDurationMinutes': 0,
'totalWordsLearned': 0,
'totalTestsCompleted': 0,
'averageAccuracy': 0.0,
'averageSessionLength': 0.0,
};
}
final completedSessions = sessions.where((s) => !s.isActive).toList();
final totalDuration = completedSessions.fold<int>(
0,
(sum, session) => sum + session.durationMinutes,
);
final totalWordsLearned = completedSessions.fold<int>(
0,
(sum, session) => sum + session.wordsLearned,
);
final totalTestsCompleted = completedSessions.fold<int>(
0,
(sum, session) => sum + session.testsCompleted,
);
final averageAccuracy = completedSessions.isEmpty
? 0.0
: completedSessions.fold<double>(
0,
(sum, session) => sum + session.accuracy,
) /
completedSessions.length;
return {
'totalSessions': sessions.length,
'completedSessions': completedSessions.length,
'totalDurationMinutes': totalDuration,
'totalWordsLearned': totalWordsLearned,
'totalTestsCompleted': totalTestsCompleted,
'averageAccuracy': averageAccuracy,
'averageSessionLength': completedSessions.isEmpty
? 0.0
: totalDuration / completedSessions.length,
};
}
/// Generate a unique session ID
String _generateSessionId() {
final timestamp = DateTime.now().millisecondsSinceEpoch;
final random = DateTime.now().microsecondsSinceEpoch % 10000;
return 'session_${timestamp}_$random';
}
/// Reset the timeout timer for a session
void _resetSessionTimer(String sessionId) {
_cancelSessionTimer(sessionId);
_sessionTimers[sessionId] = Timer(_sessionTimeout, () {
// Session timed out - end it
endSession(sessionId);
});
}
/// Cancel the timeout timer for a session
void _cancelSessionTimer(String sessionId) {
final timer = _sessionTimers.remove(sessionId);
timer?.cancel();
}
/// Update session last activity time
Future<void> _updateSessionActivity(String sessionId) async {
// For now, we just reset the timer
// In the future, we could track last activity timestamps
}
/// Dispose of all timers (for cleanup)
void dispose() {
for (final timer in _sessionTimers.values) {
timer.cancel();
}
_sessionTimers.clear();
_activeSessions.clear();
}
}