284 lines
7.7 KiB
Dart
284 lines
7.7 KiB
Dart
|
|
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();
|
||
|
|
}
|
||
|
|
}
|