using MongoDB.Driver; using QuestionsApi.Models; using QuestionsApi.Services.Flashcards; namespace QuestionsApi.Services; public sealed class DashboardStore { private readonly IMongoCollection _attempts; private readonly IMongoCollection _ratings; private readonly IMongoCollection _ccsRatings; private readonly IMongoCollection _ccsAttempts; private readonly IMongoCollection _ccsCases; private readonly IMongoCollection _simulations; private readonly IMongoCollection _bookmarks; private readonly IMongoCollection _notes; private readonly QuestionStore _questions; private readonly ReviewQueueStore _reviewQueue; private readonly FlashcardStudyService _flashcards; private readonly FlashcardScheduleStore _flashcardSchedules; public DashboardStore( MongoDatabaseProvider mongo, QuestionStore questions, ReviewQueueStore reviewQueue, FlashcardStudyService flashcards, FlashcardScheduleStore flashcardSchedules) { _attempts = mongo.Database.GetCollection("QuestionAttempts"); _ratings = mongo.Database.GetCollection("UserRatings"); _ccsRatings = mongo.Database.GetCollection("CcsUserRatings"); _ccsAttempts = mongo.Database.GetCollection("CcsAttempts"); _ccsCases = mongo.Database.GetCollection("CcsCaseRevisions"); _simulations = mongo.Database.GetCollection("ExamSimulations"); _bookmarks = mongo.Database.GetCollection("QuestionBookmarks"); _notes = mongo.Database.GetCollection("UserNotepads"); _questions = questions; _reviewQueue = reviewQueue; _flashcards = flashcards; _flashcardSchedules = flashcardSchedules; } public async Task GetAsync(string userId, DateTime nowUtc) { var today = nowUtc.Date; var trendStart = today.AddDays(-29); var weekStart = today.AddDays(-6); var attemptsTask = _attempts.Find(value => value.UserId == userId).ToListAsync(); var ratingTask = _ratings.Find(value => value.UserId == userId).FirstOrDefaultAsync(); var ccsRatingTask = _ccsRatings.Find(value => value.UserId == userId).FirstOrDefaultAsync(); var ccsAttemptsTask = _ccsAttempts.Find(value => value.UserId == userId && value.CreatedUtc >= trendStart).ToListAsync(); var completedCcsCountTask = _ccsAttempts.CountDocumentsAsync(value => value.UserId == userId && value.State == CcsAttemptStates.Completed && value.CompletedUtc.HasValue); var simulationsTask = _simulations.Find(value => value.UserId == userId && value.CreatedUtc >= trendStart).ToListAsync(); var bookmarkCountTask = _bookmarks.CountDocumentsAsync(value => value.UserId == userId); var notepadTask = _notes.Find(value => value.UserId == userId).FirstOrDefaultAsync(); var reviewAttemptsTask = _reviewQueue.GetReviewsSinceAsync(userId, weekStart); var flashcardCountsTask = _flashcards.GetDashboardCountsAsync(userId, nowUtc, weekStart); var flashcardReviewsTask = _flashcardSchedules.GetReviewsSinceAsync(userId, weekStart); await Task.WhenAll(attemptsTask, ratingTask, ccsRatingTask, ccsAttemptsTask, completedCcsCountTask, simulationsTask, bookmarkCountTask, notepadTask, reviewAttemptsTask, flashcardCountsTask, flashcardReviewsTask); var attempts = attemptsTask.Result; var answeredAttempts = attempts.Where(value => !value.IsSkip).ToList(); var recentAnswered = answeredAttempts.OrderByDescending(value => value.CreatedUtc).Take(100).ToList(); var questionMap = await _questions.GetByExternalIdsAsync(attempts.Select(value => value.QuestionExternalId)); var ccsAttempts = ccsAttemptsTask.Result; var simulations = simulationsTask.Result; var completedCcs = ccsAttempts.Where(value => value.State == CcsAttemptStates.Completed && value.CompletedUtc.HasValue).ToList(); await _reviewQueue.EnsureUserQueueAsync(userId); var pendingReviewCount = await _reviewQueue.CountPendingAsync(userId); var response = new DashboardResponse { QuestionBankRating = ratingTask.Result?.OverallElo ?? 1000, RatedQuestionCount = ratingTask.Result?.RatedQuestionCount ?? 0, CcsRating = ccsRatingTask.Result?.Elo ?? 1000, RatedCcsCaseCount = ccsRatingTask.Result?.RatedCaseCount ?? 0, QuestionsCompleted = answeredAttempts.Count, CcsCasesCompleted = checked((int)completedCcsCountTask.Result), AccuracySampleSize = recentAnswered.Count, FirstAttemptAccuracy = Percentage(recentAnswered.Count(value => value.IsCorrect), recentAnswered.Count), QuestionsThisWeek = answeredAttempts.Count(value => value.CreatedUtc >= weekStart), BookmarkCount = checked((int)bookmarkCountTask.Result), NoteCount = CountNotes(notepadTask.Result?.Content), MissedQuestionCount = checked((int)pendingReviewCount), FlashcardsDue = flashcardCountsTask.Result.Due, FlashcardsNewAvailable = flashcardCountsTask.Result.NewAvailable, FlashcardsReviewedThisWeek = flashcardCountsTask.Result.ReviewedThisWeek }; response.PerformanceTrend = Enumerable.Range(0, 30).Select(offset => { var date = trendStart.AddDays(offset); var daily = answeredAttempts.Where(value => value.CreatedUtc.Date == date).ToList(); return new DashboardTrendPoint { DateUtc = date, Correct = daily.Count(value => value.IsCorrect), Total = daily.Count, Accuracy = Percentage(daily.Count(value => value.IsCorrect), daily.Count) }; }).ToList(); response.Systems = answeredAttempts .Where(value => questionMap.ContainsKey(value.QuestionExternalId)) .GroupBy(value => NormalizeSystem(questionMap[value.QuestionExternalId].System)) .Select(group => new DashboardSystemPerformance { System = group.Key, Correct = group.Count(value => value.IsCorrect), Total = group.Count(), Accuracy = Percentage(group.Count(value => value.IsCorrect), group.Count()) }) .OrderByDescending(value => value.Total).ThenByDescending(value => value.Accuracy) .ToList(); response.WeeklyActivity = Enumerable.Range(0, 7).Select(offset => { var date = weekStart.AddDays(offset); var dailyQuestions = answeredAttempts.Where(value => value.CreatedUtc.Date == date).ToList(); var dailyCcs = completedCcs.Where(value => value.CompletedUtc!.Value.Date == date).ToList(); var dailySimulations = simulations.Where(value => value.CompletedUtc?.Date == date).ToList(); return new DashboardActivityDay { DateUtc = date, Questions = dailyQuestions.Count, CcsCases = dailyCcs.Count, StudyTimeSeconds = dailyQuestions.Sum(value => value.DurationSeconds) + dailyCcs.Sum(CcsDurationSeconds) + dailySimulations.Sum(value => value.Responses.Sum(item => Math.Max(0, item.ElapsedSeconds))) + reviewAttemptsTask.Result.Where(value => value.CreatedUtc.Date == date).Sum(value => value.DurationSeconds) + flashcardReviewsTask.Result.Where(value => value.ReviewedUtc.Date == date).Sum(value => value.DurationSeconds) }; }).ToList(); response.StudyTimeSecondsThisWeek = response.WeeklyActivity.Sum(value => value.StudyTimeSeconds); var activeDates = attempts.Select(value => value.CreatedUtc.Date) .Concat(completedCcs.Select(value => value.CompletedUtc!.Value.Date)) .Concat(simulations.Where(value => value.CompletedUtc.HasValue).Select(value => value.CompletedUtc!.Value.Date)) .Concat(flashcardReviewsTask.Result.Select(value => value.ReviewedUtc.Date)); response.CurrentStreak = CalculateCurrentStreak(activeDates, today); response.RecentActivity = await BuildRecentActivityAsync(answeredAttempts, questionMap, completedCcs, simulations); return response; } public static int Percentage(int correct, int total) => total <= 0 ? 0 : (int)Math.Round(correct * 100.0 / total, MidpointRounding.AwayFromZero); public static int CalculateCurrentStreak(IEnumerable activeDates, DateTime todayUtc) { var dates = activeDates.Select(value => value.Date).ToHashSet(); var cursor = todayUtc.Date; if (!dates.Contains(cursor)) cursor = cursor.AddDays(-1); var streak = 0; while (dates.Contains(cursor)) { streak++; cursor = cursor.AddDays(-1); } return streak; } private async Task> BuildRecentActivityAsync( List attempts, Dictionary questionMap, List ccsAttempts, List simulations) { var caseIds = ccsAttempts.Select(value => value.CaseExternalId).Distinct().ToList(); var caseTitles = caseIds.Count == 0 ? [] : await _ccsCases.Find(value => caseIds.Contains(value.ExternalId) && value.IsCurrent).ToListAsync(); var titleMap = caseTitles.GroupBy(value => value.ExternalId).ToDictionary(group => group.Key, group => group.First().Title); var activity = attempts.OrderByDescending(value => value.CreatedUtc).Take(6).Select(value => { questionMap.TryGetValue(value.QuestionExternalId, out var question); return new DashboardRecentActivity { Type = "question", Title = $"Answered {NormalizeSystem(question?.System)} question", Detail = value.IsCorrect ? "Correct" : "Incorrect", OccurredUtc = value.CreatedUtc, Route = "question_bank" }; }).ToList(); activity.AddRange(ccsAttempts.OrderByDescending(value => value.CompletedUtc).Take(4).Select(value => new DashboardRecentActivity { Type = "ccs", Title = $"Completed {titleMap.GetValueOrDefault(value.CaseExternalId, "CCS case")}", Detail = $"{Percentage(value.Score?.EarnedPoints ?? 0, value.Score?.PossiblePoints ?? 0)}%", OccurredUtc = value.CompletedUtc ?? value.UpdatedUtc, Route = "ccs" })); activity.AddRange(simulations.Where(value => value.State == ExamSimulationStates.Completed) .OrderByDescending(value => value.CompletedUtc).Take(3).Select(value => new DashboardRecentActivity { Type = "simulation", Title = "Completed Step 3 simulation", Detail = value.Score is null ? "Completed" : $"Predicted score {value.Score.PredictedScore}", OccurredUtc = value.CompletedUtc ?? value.UpdatedUtc, Route = "exams" })); return activity.OrderByDescending(value => value.OccurredUtc).Take(6).ToList(); } private static int CcsDurationSeconds(CcsAttempt attempt) => attempt.CompletedUtc.HasValue ? Math.Max(0, (int)(attempt.CompletedUtc.Value - attempt.CreatedUtc).TotalSeconds) : 0; private static int CountNotes(string? content) => string.IsNullOrWhiteSpace(content) ? 0 : content.Split('\n').Count(line => !string.IsNullOrWhiteSpace(line)); private static string NormalizeSystem(string? value) => string.IsNullOrWhiteSpace(value) ? "General" : value.Trim(); }