Add user progress dashboard
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
using QuestionsApi.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace QuestionsApi.Tests;
|
||||
|
||||
public sealed class DashboardTests
|
||||
{
|
||||
[Fact]
|
||||
public void PercentageRoundsToNearestWholeNumber()
|
||||
{
|
||||
Assert.Equal(67, DashboardStore.Percentage(2, 3));
|
||||
Assert.Equal(0, DashboardStore.Percentage(0, 0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StreakIncludesYesterdayWhenUserHasNotStudiedToday()
|
||||
{
|
||||
var today = new DateTime(2026, 7, 18, 0, 0, 0, DateTimeKind.Utc);
|
||||
var dates = new[] { today.AddDays(-1), today.AddDays(-2), today.AddDays(-3), today.AddDays(-5) };
|
||||
Assert.Equal(3, DashboardStore.CalculateCurrentStreak(dates, today));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StreakStopsAtFirstMissingDay()
|
||||
{
|
||||
var today = new DateTime(2026, 7, 18, 0, 0, 0, DateTimeKind.Utc);
|
||||
var dates = new[] { today, today.AddDays(-1), today.AddDays(-3) };
|
||||
Assert.Equal(2, DashboardStore.CalculateCurrentStreak(dates, today));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using QuestionsApi.Services;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace QuestionsApi.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/dashboard")]
|
||||
[Authorize(Roles = "USER,SUPER")]
|
||||
public sealed class DashboardController(DashboardStore dashboard) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Get()
|
||||
{
|
||||
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier)
|
||||
?? User.FindFirstValue(ClaimTypes.Name)
|
||||
?? User.Identity?.Name
|
||||
?? "";
|
||||
if (string.IsNullOrWhiteSpace(userId)) return Unauthorized();
|
||||
return Ok(await dashboard.GetAsync(userId, DateTime.UtcNow));
|
||||
}
|
||||
}
|
||||
@@ -150,7 +150,8 @@ public class QuestionsController : ControllerBase
|
||||
req.ChoiceId.Trim(),
|
||||
req.ChoiceId.Trim() == question.CorrectChoiceId,
|
||||
false,
|
||||
presentedChoiceIds);
|
||||
presentedChoiceIds,
|
||||
req.DurationSeconds);
|
||||
if (record.Inserted)
|
||||
{
|
||||
_metrics.AttemptCreated();
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
namespace QuestionsApi.Models;
|
||||
|
||||
public sealed class DashboardResponse
|
||||
{
|
||||
public int QuestionBankRating { get; set; } = 1000;
|
||||
public int RatedQuestionCount { get; set; }
|
||||
public int CcsRating { get; set; } = 1000;
|
||||
public int RatedCcsCaseCount { get; set; }
|
||||
public int FirstAttemptAccuracy { get; set; }
|
||||
public int AccuracySampleSize { get; set; }
|
||||
public int QuestionsThisWeek { get; set; }
|
||||
public int StudyTimeSecondsThisWeek { get; set; }
|
||||
public int CurrentStreak { get; set; }
|
||||
public int MissedQuestionCount { get; set; }
|
||||
public int BookmarkCount { get; set; }
|
||||
public int NoteCount { get; set; }
|
||||
public List<DashboardTrendPoint> PerformanceTrend { get; set; } = [];
|
||||
public List<DashboardSystemPerformance> Systems { get; set; } = [];
|
||||
public List<DashboardActivityDay> WeeklyActivity { get; set; } = [];
|
||||
public List<DashboardRecentActivity> RecentActivity { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class DashboardTrendPoint
|
||||
{
|
||||
public DateTime DateUtc { get; set; }
|
||||
public int Correct { get; set; }
|
||||
public int Total { get; set; }
|
||||
public int Accuracy { get; set; }
|
||||
}
|
||||
|
||||
public sealed class DashboardSystemPerformance
|
||||
{
|
||||
public string System { get; set; } = "General";
|
||||
public int Correct { get; set; }
|
||||
public int Total { get; set; }
|
||||
public int Accuracy { get; set; }
|
||||
}
|
||||
|
||||
public sealed class DashboardActivityDay
|
||||
{
|
||||
public DateTime DateUtc { get; set; }
|
||||
public int Questions { get; set; }
|
||||
public int CcsCases { get; set; }
|
||||
public int StudyTimeSeconds { get; set; }
|
||||
}
|
||||
|
||||
public sealed class DashboardRecentActivity
|
||||
{
|
||||
public string Type { get; set; } = "question";
|
||||
public string Title { get; set; } = "";
|
||||
public string Detail { get; set; } = "";
|
||||
public DateTime OccurredUtc { get; set; }
|
||||
public string Route { get; set; } = "";
|
||||
}
|
||||
@@ -16,6 +16,7 @@ public class QuestionAttempt
|
||||
public bool TracksPresentation { get; set; }
|
||||
public bool IsCorrect { get; set; }
|
||||
public bool IsSkip { get; set; }
|
||||
public int DurationSeconds { get; set; }
|
||||
public int CounterBucket { get; set; }
|
||||
public bool? RatingApplied { get; set; }
|
||||
public string RatingLeaseOwner { get; set; } = "";
|
||||
|
||||
@@ -43,4 +43,5 @@ public class FirstQuestionAttemptRequest
|
||||
public string QuestionExternalId { get; set; } = "";
|
||||
public string ChoiceId { get; set; } = "";
|
||||
public List<string> PresentedChoiceIds { get; set; } = [];
|
||||
public int DurationSeconds { get; set; }
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ builder.Services.AddSingleton<CcsCaseReportStore>();
|
||||
builder.Services.AddSingleton<AppFeedbackStore>();
|
||||
builder.Services.AddSingleton<UserNotepadStore>();
|
||||
builder.Services.AddSingleton<QuestionBookmarkStore>();
|
||||
builder.Services.AddSingleton<DashboardStore>();
|
||||
builder.Services.AddSingleton<ServiceMetrics>();
|
||||
builder.Services.AddHostedService<AnswerStatisticsMaintenanceService>();
|
||||
|
||||
|
||||
@@ -27,6 +27,8 @@ public sealed class CcsStore
|
||||
Builders<CcsAttempt>.IndexKeys.Ascending(value => value.UserId).Ascending(value => value.State).Descending(value => value.UpdatedUtc)));
|
||||
_attempts.Indexes.CreateOne(new CreateIndexModel<CcsAttempt>(
|
||||
Builders<CcsAttempt>.IndexKeys.Ascending(value => value.UserId).Ascending(value => value.CaseExternalId).Ascending(value => value.Mode).Ascending(value => value.CreatedUtc)));
|
||||
_attempts.Indexes.CreateOne(new CreateIndexModel<CcsAttempt>(
|
||||
Builders<CcsAttempt>.IndexKeys.Ascending(value => value.UserId).Descending(value => value.CreatedUtc)));
|
||||
}
|
||||
|
||||
public async Task<CcsImportResponse> ImportAsync(CcsImportRequest request)
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
using MongoDB.Driver;
|
||||
using QuestionsApi.Models;
|
||||
|
||||
namespace QuestionsApi.Services;
|
||||
|
||||
public sealed class DashboardStore
|
||||
{
|
||||
private readonly IMongoCollection<QuestionAttempt> _attempts;
|
||||
private readonly IMongoCollection<UserRating> _ratings;
|
||||
private readonly IMongoCollection<CcsUserRating> _ccsRatings;
|
||||
private readonly IMongoCollection<CcsAttempt> _ccsAttempts;
|
||||
private readonly IMongoCollection<CcsCaseRevision> _ccsCases;
|
||||
private readonly IMongoCollection<ExamSimulation> _simulations;
|
||||
private readonly IMongoCollection<QuestionBookmark> _bookmarks;
|
||||
private readonly IMongoCollection<UserNotepad> _notes;
|
||||
private readonly QuestionStore _questions;
|
||||
|
||||
public DashboardStore(MongoDatabaseProvider mongo, QuestionStore questions)
|
||||
{
|
||||
_attempts = mongo.Database.GetCollection<QuestionAttempt>("QuestionAttempts");
|
||||
_ratings = mongo.Database.GetCollection<UserRating>("UserRatings");
|
||||
_ccsRatings = mongo.Database.GetCollection<CcsUserRating>("CcsUserRatings");
|
||||
_ccsAttempts = mongo.Database.GetCollection<CcsAttempt>("CcsAttempts");
|
||||
_ccsCases = mongo.Database.GetCollection<CcsCaseRevision>("CcsCaseRevisions");
|
||||
_simulations = mongo.Database.GetCollection<ExamSimulation>("ExamSimulations");
|
||||
_bookmarks = mongo.Database.GetCollection<QuestionBookmark>("QuestionBookmarks");
|
||||
_notes = mongo.Database.GetCollection<UserNotepad>("UserNotepads");
|
||||
_questions = questions;
|
||||
}
|
||||
|
||||
public async Task<DashboardResponse> 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 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();
|
||||
await Task.WhenAll(attemptsTask, ratingTask, ccsRatingTask, ccsAttemptsTask,
|
||||
simulationsTask, bookmarkCountTask, notepadTask);
|
||||
|
||||
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();
|
||||
|
||||
var response = new DashboardResponse
|
||||
{
|
||||
QuestionBankRating = ratingTask.Result?.OverallElo ?? 1000,
|
||||
RatedQuestionCount = ratingTask.Result?.RatedQuestionCount ?? 0,
|
||||
CcsRating = ccsRatingTask.Result?.Elo ?? 1000,
|
||||
RatedCcsCaseCount = ccsRatingTask.Result?.RatedCaseCount ?? 0,
|
||||
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 = attempts.Where(value => !value.IsCorrect).Select(value => value.QuestionExternalId).Distinct().Count()
|
||||
};
|
||||
|
||||
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)))
|
||||
};
|
||||
}).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));
|
||||
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<DateTime> 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<List<DashboardRecentActivity>> BuildRecentActivityAsync(
|
||||
List<QuestionAttempt> attempts,
|
||||
Dictionary<string, Question> questionMap,
|
||||
List<CcsAttempt> ccsAttempts,
|
||||
List<ExamSimulation> 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();
|
||||
}
|
||||
@@ -27,6 +27,8 @@ public class QuestionAttemptStore
|
||||
|
||||
var createdIndex = Builders<QuestionAttempt>.IndexKeys.Ascending(a => a.CreatedUtc);
|
||||
_col.Indexes.CreateOne(new CreateIndexModel<QuestionAttempt>(createdIndex));
|
||||
_col.Indexes.CreateOne(new CreateIndexModel<QuestionAttempt>(
|
||||
Builders<QuestionAttempt>.IndexKeys.Ascending(a => a.UserId).Descending(a => a.CreatedUtc)));
|
||||
}
|
||||
|
||||
public async Task<QuestionAttemptRecordResult> RecordFirstAttemptAsync(
|
||||
@@ -35,7 +37,8 @@ public class QuestionAttemptStore
|
||||
string choiceId,
|
||||
bool isCorrect,
|
||||
bool isSkip = false,
|
||||
IReadOnlyCollection<string>? presentedChoiceIds = null)
|
||||
IReadOnlyCollection<string>? presentedChoiceIds = null,
|
||||
int durationSeconds = 0)
|
||||
{
|
||||
var attempt = new QuestionAttempt
|
||||
{
|
||||
@@ -46,6 +49,7 @@ public class QuestionAttemptStore
|
||||
TracksPresentation = presentedChoiceIds is { Count: > 0 },
|
||||
IsCorrect = isCorrect,
|
||||
IsSkip = isSkip,
|
||||
DurationSeconds = Math.Clamp(durationSeconds, 0, 86_400),
|
||||
CounterBucket = GetCounterBucket(userId),
|
||||
RatingApplied = false,
|
||||
CreatedUtc = DateTime.UtcNow
|
||||
|
||||
Reference in New Issue
Block a user