diff --git a/api/QuestionsApi.Tests/DashboardTests.cs b/api/QuestionsApi.Tests/DashboardTests.cs index 6b50d2c..b21e289 100644 --- a/api/QuestionsApi.Tests/DashboardTests.cs +++ b/api/QuestionsApi.Tests/DashboardTests.cs @@ -1,3 +1,4 @@ +using QuestionsApi.Models; using QuestionsApi.Services; using Xunit; @@ -27,4 +28,110 @@ public sealed class DashboardTests var dates = new[] { today, today.AddDays(-1), today.AddDays(-3) }; Assert.Equal(2, DashboardStore.CalculateCurrentStreak(dates, today)); } + + [Fact] + public void SystemPerformanceReturnsEveryOfficialSystemInOrder() + { + var attempts = new[] + { + Attempt("cardio-1", true), + Attempt("cardio-2", false), + Attempt("ethics-1", true), + Attempt("ignored", true), + Attempt("skipped-cardio", false, true) + }; + var questions = new Dictionary + { + ["cardio-1"] = Question("Cardiology"), + ["cardio-2"] = Question("Cardiovascular System"), + ["ethics-1"] = Question("Medical Ethics"), + ["ignored"] = Question("Unmapped Specialty"), + ["skipped-cardio"] = Question("Cardiology") + }; + + var result = DashboardStore.BuildSystemPerformance(attempts, questions); + + Assert.Equal(14, result.Count); + Assert.Equal(DashboardStore.Step3Systems, result.Select(value => value.System)); + var cardiovascular = result.Single(value => value.System == "Cardiovascular System"); + Assert.Equal(1, cardiovascular.Correct); + Assert.Equal(2, cardiovascular.Total); + Assert.Equal(50, cardiovascular.Accuracy); + var social = result.Single(value => value.System.StartsWith("Social Sciences")); + Assert.Equal(1, social.Correct); + Assert.Equal(1, social.Total); + Assert.All(result.Where(value => value.System != cardiovascular.System && value.System != social.System), + value => Assert.Equal(0, value.Total)); + } + + [Theory] + [InlineData("Preventive Medicine", "Human Development")] + [InlineData("Infectious Disease", "Immune System, Blood & Lymphoreticular System, and Multisystem Processes/Disorders")] + [InlineData("Dermatology", "Skin & Subcutaneous Tissue")] + [InlineData("Pulmonary", "Respiratory System")] + [InlineData("Pregnancy", "Pregnancy/Childbirth & Female Reproductive System & Breast")] + [InlineData("Biostatistics", "Biostatistics & Epidemiology/Population Health & Interpretation of the Medical Literature")] + [InlineData("Medical Ethics", "Social Sciences: Communication Skills/Ethics/Patient Safety")] + public void SystemAliasesMapToOfficialCategories(string source, string expected) + { + Assert.Equal(expected, DashboardStore.NormalizeStep3System(source)); + } + + [Fact] + public void TrendCarriesRatingForwardAndLeavesInactiveAccuracyAsGapData() + { + var today = new DateTime(2026, 7, 25, 0, 0, 0, DateTimeKind.Utc); + var baselineDate = today.AddDays(-2).AddHours(8); + var attempts = new[] + { + Attempt("one", true, createdUtc: today.AddDays(-2).AddHours(9), + rating: 1018, ratingUtc: today.AddDays(-2).AddHours(9)), + Attempt("two", false, createdUtc: today.AddDays(-2).AddHours(10), + rating: 1002, ratingUtc: today.AddDays(-2).AddHours(10)), + Attempt("three", true, createdUtc: today.AddHours(11), + rating: 1020, ratingUtc: today.AddHours(11)), + Attempt("skip", false, true, today.AddDays(-1).AddHours(4), + 990, today.AddDays(-1).AddHours(4)) + }; + var rating = new UserRating + { + OverallElo = 1020, + HistoryBaselineElo = 1000, + HistoryStartedUtc = baselineDate + }; + + var result = DashboardStore.BuildPerformanceTrend(attempts, rating, today); + + Assert.Equal(30, result.Count); + Assert.Null(result[^4].QuestionBankRating); + Assert.Equal(1002, result[^3].QuestionBankRating); + Assert.Equal(50, result[^3].Accuracy); + Assert.Equal(2, result[^3].Total); + Assert.Equal(990, result[^2].QuestionBankRating); + Assert.Equal(0, result[^2].Total); + Assert.Equal(1020, result[^1].QuestionBankRating); + Assert.Equal(100, result[^1].Accuracy); + } + + private static QuestionAttempt Attempt( + string questionId, + bool correct, + bool skip = false, + DateTime? createdUtc = null, + int? rating = null, + DateTime? ratingUtc = null) => new() + { + QuestionExternalId = questionId, + IsCorrect = correct, + IsSkip = skip, + CreatedUtc = createdUtc ?? DateTime.UtcNow, + UserOverallEloAfter = rating, + RatingAppliedUtc = ratingUtc + }; + + private static Question Question(string system) => new() + { + ExternalId = system, + System = system + }; } diff --git a/api/QuestionsApi/Controllers/QuestionsController.cs b/api/QuestionsApi/Controllers/QuestionsController.cs index defbb92..221be3d 100644 --- a/api/QuestionsApi/Controllers/QuestionsController.cs +++ b/api/QuestionsApi/Controllers/QuestionsController.cs @@ -187,7 +187,7 @@ public class QuestionsController : ControllerBase record.Attempt.IsCorrect, shouldRate); if (shouldRate) - await _attempts.MarkRatingAppliedAsync(record.Attempt.Id ?? "", ratingOwner); + await _attempts.MarkRatingAppliedAsync(record.Attempt.Id ?? "", ratingOwner, rating.UserOverallElo); } catch { @@ -286,7 +286,7 @@ public class QuestionsController : ControllerBase { rating = await _ratings.ApplyFirstResultAsync(userId, question, false, shouldRate); if (shouldRate) - await _attempts.MarkRatingAppliedAsync(record.Attempt.Id ?? "", ratingOwner); + await _attempts.MarkRatingAppliedAsync(record.Attempt.Id ?? "", ratingOwner, rating.UserOverallElo); } catch { diff --git a/api/QuestionsApi/Models/DashboardModels.cs b/api/QuestionsApi/Models/DashboardModels.cs index 43f8508..960aee7 100644 --- a/api/QuestionsApi/Models/DashboardModels.cs +++ b/api/QuestionsApi/Models/DashboardModels.cs @@ -32,6 +32,7 @@ public sealed class DashboardTrendPoint public int Correct { get; set; } public int Total { get; set; } public int Accuracy { get; set; } + public int? QuestionBankRating { get; set; } } public sealed class DashboardSystemPerformance diff --git a/api/QuestionsApi/Models/QuestionAttempt.cs b/api/QuestionsApi/Models/QuestionAttempt.cs index c0f4440..691c708 100644 --- a/api/QuestionsApi/Models/QuestionAttempt.cs +++ b/api/QuestionsApi/Models/QuestionAttempt.cs @@ -19,6 +19,8 @@ public class QuestionAttempt public int DurationSeconds { get; set; } public int CounterBucket { get; set; } public bool? RatingApplied { get; set; } + public int? UserOverallEloAfter { get; set; } + public DateTime? RatingAppliedUtc { get; set; } public string RatingLeaseOwner { get; set; } = ""; public DateTime RatingLeaseUntilUtc { get; set; } public DateTime CreatedUtc { get; set; } = DateTime.UtcNow; diff --git a/api/QuestionsApi/Models/RatingModels.cs b/api/QuestionsApi/Models/RatingModels.cs index 9b1feb7..fc6ff9d 100644 --- a/api/QuestionsApi/Models/RatingModels.cs +++ b/api/QuestionsApi/Models/RatingModels.cs @@ -13,6 +13,8 @@ public class UserRating public int OverallElo { get; set; } = 1000; public Dictionary CategoryElos { get; set; } = []; public int RatedQuestionCount { get; set; } + public int? HistoryBaselineElo { get; set; } + public DateTime? HistoryStartedUtc { get; set; } public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow; } diff --git a/api/QuestionsApi/Services/DashboardStore.cs b/api/QuestionsApi/Services/DashboardStore.cs index 44fe6da..3b0d1ed 100644 --- a/api/QuestionsApi/Services/DashboardStore.cs +++ b/api/QuestionsApi/Services/DashboardStore.cs @@ -7,7 +7,6 @@ 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; @@ -19,6 +18,25 @@ public sealed class DashboardStore private readonly DynamicQuestionStore _dynamicQuestions; private readonly FlashcardStudyService _flashcards; private readonly FlashcardScheduleStore _flashcardSchedules; + private readonly RatingStore _ratingStore; + + public static readonly string[] Step3Systems = + [ + "Human Development", + "Immune System, Blood & Lymphoreticular System, and Multisystem Processes/Disorders", + "Behavioral Health", + "Nervous System & Special Senses", + "Skin & Subcutaneous Tissue", + "Musculoskeletal System", + "Cardiovascular System", + "Respiratory System", + "Gastrointestinal System", + "Renal/Urinary & Male Reproductive Systems", + "Pregnancy/Childbirth & Female Reproductive System & Breast", + "Endocrine System", + "Biostatistics & Epidemiology/Population Health & Interpretation of the Medical Literature", + "Social Sciences: Communication Skills/Ethics/Patient Safety" + ]; public DashboardStore( MongoDatabaseProvider mongo, @@ -26,10 +44,10 @@ public sealed class DashboardStore ReviewQueueStore reviewQueue, DynamicQuestionStore dynamicQuestions, FlashcardStudyService flashcards, - FlashcardScheduleStore flashcardSchedules) + FlashcardScheduleStore flashcardSchedules, + RatingStore ratingStore) { _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"); @@ -41,6 +59,7 @@ public sealed class DashboardStore _dynamicQuestions = dynamicQuestions; _flashcards = flashcards; _flashcardSchedules = flashcardSchedules; + _ratingStore = ratingStore; } public async Task GetAsync(string userId, DateTime nowUtc) @@ -49,7 +68,7 @@ public sealed class DashboardStore 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 ratingTask = _ratingStore.GetOrInitializeHistoryAsync(userId, nowUtc); 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 @@ -77,8 +96,8 @@ public sealed class DashboardStore var response = new DashboardResponse { - QuestionBankRating = ratingTask.Result?.OverallElo ?? 1000, - RatedQuestionCount = ratingTask.Result?.RatedQuestionCount ?? 0, + QuestionBankRating = ratingTask.Result.OverallElo, + RatedQuestionCount = ratingTask.Result.RatedQuestionCount, CcsRating = ccsRatingTask.Result?.Elo ?? 1000, RatedCcsCaseCount = ccsRatingTask.Result?.RatedCaseCount ?? 0, QuestionsCompleted = answeredAttempts.Count, @@ -95,31 +114,8 @@ public sealed class DashboardStore 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.PerformanceTrend = BuildPerformanceTrend(attempts, ratingTask.Result, today); + response.Systems = BuildSystemPerformance(answeredAttempts, questionMap); response.WeeklyActivity = Enumerable.Range(0, 7).Select(offset => { @@ -166,6 +162,133 @@ public sealed class DashboardStore return streak; } + public static List BuildPerformanceTrend( + IEnumerable attempts, + UserRating rating, + DateTime todayUtc) + { + var attemptList = attempts.ToList(); + var trendStart = todayUtc.Date.AddDays(-29); + var ratingEvents = new List<(DateTime AtUtc, int Elo)>(); + if (rating.HistoryBaselineElo.HasValue && rating.HistoryStartedUtc.HasValue) + ratingEvents.Add((rating.HistoryStartedUtc.Value, rating.HistoryBaselineElo.Value)); + ratingEvents.AddRange(attemptList + .Where(value => value.RatingAppliedUtc.HasValue && value.UserOverallEloAfter.HasValue) + .Select(value => (value.RatingAppliedUtc!.Value, value.UserOverallEloAfter!.Value))); + ratingEvents.Sort((left, right) => left.AtUtc.CompareTo(right.AtUtc)); + + int? currentRating = null; + var eventIndex = 0; + var result = new List(); + foreach (var offset in Enumerable.Range(0, 30)) + { + var date = trendStart.AddDays(offset); + var nextDate = date.AddDays(1); + while (eventIndex < ratingEvents.Count && ratingEvents[eventIndex].AtUtc < nextDate) + { + currentRating = ratingEvents[eventIndex].Elo; + eventIndex++; + } + + var daily = attemptList + .Where(value => !value.IsSkip && value.CreatedUtc >= date && value.CreatedUtc < nextDate) + .ToList(); + var correct = daily.Count(value => value.IsCorrect); + result.Add(new DashboardTrendPoint + { + DateUtc = date, + Correct = correct, + Total = daily.Count, + Accuracy = Percentage(correct, daily.Count), + QuestionBankRating = currentRating + }); + } + return result; + } + + public static List BuildSystemPerformance( + IEnumerable attempts, + IReadOnlyDictionary questionMap) + { + var totals = Step3Systems.ToDictionary( + system => system, + _ => (Correct: 0, Total: 0), + StringComparer.Ordinal); + foreach (var attempt in attempts.Where(value => !value.IsSkip)) + { + if (!questionMap.TryGetValue(attempt.QuestionExternalId, out var question)) + continue; + var system = NormalizeStep3System(question); + if (system is null) + continue; + var current = totals[system]; + totals[system] = (current.Correct + (attempt.IsCorrect ? 1 : 0), current.Total + 1); + } + + return Step3Systems.Select(system => new DashboardSystemPerformance + { + System = system, + Correct = totals[system].Correct, + Total = totals[system].Total, + Accuracy = Percentage(totals[system].Correct, totals[system].Total) + }).ToList(); + } + + public static string? NormalizeStep3System(Question question) + { + foreach (var candidate in new[] { question.System, question.PerformanceCategory, question.Discipline }) + { + var normalized = NormalizeStep3System(candidate); + if (normalized is not null) + return normalized; + } + return null; + } + + public static string? NormalizeStep3System(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + return null; + var exact = Step3Systems.FirstOrDefault( + system => string.Equals(system, value.Trim(), StringComparison.OrdinalIgnoreCase)); + if (exact is not null) + return exact; + + var lower = value.Trim().ToLowerInvariant(); + if (ContainsAny(lower, "human development", "well patient", "preventive", "health maintenance", "geriatr", "age-related")) + return Step3Systems[0]; + if (ContainsAny(lower, "social science", "communication", "ethic", "patient safety", "professionalism", "systems-based")) + return Step3Systems[13]; + if (ContainsAny(lower, "pregnan", "obstetric", "gynec", "female reproductive", "breast", "puerper")) + return Step3Systems[10]; + if (ContainsAny(lower, "renal", "urinary", "male reproductive", "urolog", "nephro")) + return Step3Systems[9]; + if (ContainsAny(lower, "biostat", "epidemi", "population health", "medical literature")) + return Step3Systems[12]; + if (ContainsAny(lower, "immune", "immun", "blood", "hemat", "lymph", "multisystem", "infectious")) + return Step3Systems[1]; + if (ContainsAny(lower, "behavior", "psychi", "mental health")) + return Step3Systems[2]; + if (ContainsAny(lower, "nervous", "neuro", "special sense", "ophthalm", "otolaryng", " ent")) + return Step3Systems[3]; + if (ContainsAny(lower, "skin", "derm", "subcutaneous")) + return Step3Systems[4]; + if (ContainsAny(lower, "musculo", "orthop", "rheumat")) + return Step3Systems[5]; + if (ContainsAny(lower, "cardio", "circulatory")) + return Step3Systems[6]; + if (ContainsAny(lower, "resp", "pulmon")) + return Step3Systems[7]; + if (ContainsAny(lower, "gastro", "digest", "hepato")) + return Step3Systems[8]; + if (ContainsAny(lower, "endocr", "metabolic")) + return Step3Systems[11]; + return null; + } + + private static bool ContainsAny(string value, params string[] fragments) => + fragments.Any(value.Contains); + private async Task> BuildRecentActivityAsync( List attempts, Dictionary questionMap, diff --git a/api/QuestionsApi/Services/ExamSimulationStore.cs b/api/QuestionsApi/Services/ExamSimulationStore.cs index ec862fa..27ea73e 100644 --- a/api/QuestionsApi/Services/ExamSimulationStore.cs +++ b/api/QuestionsApi/Services/ExamSimulationStore.cs @@ -454,8 +454,8 @@ public class ExamSimulationStore try { shouldRate = await _attempts.TryAcquireRatingLeaseAsync(attemptId, owner, TimeSpan.FromSeconds(30)); - await _ratings.ApplyFirstResultAsync(simulation.UserId, question, record.Attempt.IsCorrect, shouldRate); - if (shouldRate) await _attempts.MarkRatingAppliedAsync(attemptId, owner); + var rating = await _ratings.ApplyFirstResultAsync(simulation.UserId, question, record.Attempt.IsCorrect, shouldRate); + if (shouldRate) await _attempts.MarkRatingAppliedAsync(attemptId, owner, rating.UserOverallElo); } catch { diff --git a/api/QuestionsApi/Services/QuestionAttemptStore.cs b/api/QuestionsApi/Services/QuestionAttemptStore.cs index dd2b887..c2921d2 100644 --- a/api/QuestionsApi/Services/QuestionAttemptStore.cs +++ b/api/QuestionsApi/Services/QuestionAttemptStore.cs @@ -103,11 +103,13 @@ public class QuestionAttemptStore return leased?.RatingLeaseOwner == owner; } - public Task MarkRatingAppliedAsync(string attemptId, string owner) => + public Task MarkRatingAppliedAsync(string attemptId, string owner, int userOverallElo) => _col.UpdateOneAsync( attempt => attempt.Id == attemptId && attempt.RatingLeaseOwner == owner, Builders.Update .Set(attempt => attempt.RatingApplied, true) + .Set(attempt => attempt.UserOverallEloAfter, userOverallElo) + .Set(attempt => attempt.RatingAppliedUtc, DateTime.UtcNow) .Set(attempt => attempt.RatingLeaseUntilUtc, DateTime.MinValue)); public Task ReleaseRatingLeaseAsync(string attemptId, string owner) => diff --git a/api/QuestionsApi/Services/RatingStore.cs b/api/QuestionsApi/Services/RatingStore.cs index df83118..0d3d790 100644 --- a/api/QuestionsApi/Services/RatingStore.cs +++ b/api/QuestionsApi/Services/RatingStore.cs @@ -35,6 +35,12 @@ public class RatingStore }; } + public async Task GetOrInitializeHistoryAsync(string userId, DateTime nowUtc) + { + var rating = await GetOrCreateUserRatingAsync(userId); + return await EnsureHistoryBaselineAsync(rating, nowUtc); + } + public async Task> GetQuestionElosAsync(IEnumerable externalIds) { var ids = externalIds.Where(id => !string.IsNullOrWhiteSpace(id)).Distinct().ToList(); @@ -71,6 +77,7 @@ public class RatingStore }; } + userRating = await EnsureHistoryBaselineAsync(userRating, DateTime.UtcNow); var actual = isCorrect ? 1.0 : 0.0; var userK = userRating.RatedQuestionCount < 20 ? 40 : 24; var questionK = questionRating.AttemptCount < 20 ? 32 : 24; @@ -164,6 +171,26 @@ public class RatingStore return rating; } + private async Task EnsureHistoryBaselineAsync(UserRating rating, DateTime nowUtc) + { + if (rating.HistoryBaselineElo.HasValue && rating.HistoryStartedUtc.HasValue) + return rating; + + var result = await _users.UpdateOneAsync( + value => value.UserId == rating.UserId && value.HistoryStartedUtc == null, + Builders.Update + .Set(value => value.HistoryBaselineElo, rating.OverallElo) + .Set(value => value.HistoryStartedUtc, nowUtc)); + if (result.ModifiedCount > 0) + { + rating.HistoryBaselineElo = rating.OverallElo; + rating.HistoryStartedUtc = nowUtc; + return rating; + } + + return await _users.Find(value => value.UserId == rating.UserId).FirstAsync(); + } + private async Task GetOrCreateQuestionRatingAsync(string questionExternalId) { var create = Builders.Update diff --git a/scripts/ui/dashboard_chart.gd b/scripts/ui/dashboard_chart.gd index bc3c002..849a7bd 100644 --- a/scripts/ui/dashboard_chart.gd +++ b/scripts/ui/dashboard_chart.gd @@ -2,39 +2,156 @@ extends Control const Brand := preload("res://scripts/ui/medmind_style.gd") -var values: PackedFloat32Array = [] +var points: Array = [] +var mode := "rating" var dark := true +var hover_index := -1 -func set_values(next_values: Array, dark_mode: bool) -> void: - values = PackedFloat32Array(next_values) +func _ready() -> void: + mouse_filter = Control.MOUSE_FILTER_STOP + + +func set_series(next_points: Array, next_mode: String, dark_mode: bool) -> void: + points = next_points.duplicate(true) + mode = next_mode dark = dark_mode + hover_index = -1 queue_redraw() +func _notification(what: int) -> void: + if what == NOTIFICATION_RESIZED: + queue_redraw() + + +func _gui_input(event: InputEvent) -> void: + if event is InputEventMouseMotion: + var next_hover := _index_near_x(event.position.x) + if next_hover != hover_index: + hover_index = next_hover + queue_redraw() + elif event is InputEventMouseButton and not event.pressed: + hover_index = _index_near_x(event.position.x) + queue_redraw() + + func _draw() -> void: - var left := 42.0 + var left := 52.0 var top := 12.0 - var right := size.x - 12.0 - var bottom := size.y - 28.0 + var right := maxf(left + 1.0, size.x - 12.0) + var bottom := maxf(top + 1.0, size.y - 34.0) var grid := Color("24314b") if dark else Color("dbe3ef") var muted := Brand.muted_color(dark) - for percentage in [0, 25, 50, 75, 100]: - var y: float = bottom - (bottom - top) * float(percentage) / 100.0 + var bounds := axis_bounds(points, mode) + var minimum := float(bounds.x) + var maximum := float(bounds.y) + for tick_index in range(5): + var ratio := float(tick_index) / 4.0 + var y := bottom - (bottom - top) * ratio + var tick_value := lerpf(minimum, maximum, ratio) draw_line(Vector2(left, y), Vector2(right, y), grid, 1.0) - draw_string(Brand.font, Vector2(4, y + 4), str(percentage), HORIZONTAL_ALIGNMENT_LEFT, 34, 11, muted) - var target_y: float = bottom - (bottom - top) * 0.70 - draw_dashed_line(Vector2(left, target_y), Vector2(right, target_y), Brand.ROYAL_BLUE, 5.0, 2.0) - if values.is_empty(): + draw_string(Brand.font, Vector2(2, y + 4), str(int(round(tick_value))), HORIZONTAL_ALIGNMENT_RIGHT, 43, 11, muted) + if mode == "accuracy": + var target_y := _value_y(70.0, minimum, maximum, top, bottom) + draw_dashed_line(Vector2(left, target_y), Vector2(right, target_y), Brand.ROYAL_BLUE, 5.0, 2.0) + if points.is_empty(): return - var points := PackedVector2Array() - var count: int = values.size() - for index in range(count): - var x: float = left if count == 1 else lerpf(left, right, float(index) / float(count - 1)) - var y: float = bottom - (bottom - top) * clampf(values[index], 0.0, 100.0) / 100.0 - points.append(Vector2(x, y)) - if points.size() > 1: - draw_polyline(points, Brand.ROYAL_BLUE, 2.5, true) - for point in points: + + var previous: Variant = null + for index in range(points.size()): + var value: Variant = point_value(points[index], mode) + if value == null: + previous = null + continue + var point := Vector2(_point_x(index, left, right), _value_y(float(value), minimum, maximum, top, bottom)) + if previous != null: + draw_line(previous, point, Brand.ROYAL_BLUE, 2.5, true) draw_circle(point, 3.5, Brand.PURPLE) draw_circle(point, 2.0, Color.WHITE) + previous = point + + for label_index in [0, int(floor(float(points.size() - 1) / 2.0)), points.size() - 1]: + var x := _point_x(label_index, left, right) + var label := _short_date(str((points[label_index] as Dictionary).get("dateUtc", ""))) + draw_string(Brand.font, Vector2(x - 24, bottom + 19), label, HORIZONTAL_ALIGNMENT_CENTER, 48, 10, muted) + + if hover_index >= 0 and hover_index < points.size(): + var hover_value: Variant = point_value(points[hover_index], mode) + if hover_value != null: + var hover_x := _point_x(hover_index, left, right) + var hover_y := _value_y(float(hover_value), minimum, maximum, top, bottom) + draw_line(Vector2(hover_x, top), Vector2(hover_x, bottom), muted, 1.0) + draw_circle(Vector2(hover_x, hover_y), 5.0, Brand.ROYAL_BLUE) + var suffix := "%" if mode == "accuracy" else "" + var tooltip := "%s %d%s" % [ + _short_date(str((points[hover_index] as Dictionary).get("dateUtc", ""))), + int(hover_value), + suffix + ] + var tooltip_size := Brand.font.get_string_size(tooltip, HORIZONTAL_ALIGNMENT_LEFT, -1, 11) + Vector2(16, 10) + var tooltip_x := clampf(hover_x - tooltip_size.x / 2.0, left, right - tooltip_size.x) + var tooltip_y := maxf(top, hover_y - tooltip_size.y - 10.0) + var tooltip_rect := Rect2(Vector2(tooltip_x, tooltip_y), tooltip_size) + draw_style_box(_tooltip_box(), tooltip_rect) + draw_string(Brand.font, Vector2(tooltip_x + 8, tooltip_y + tooltip_size.y - 7), tooltip, HORIZONTAL_ALIGNMENT_LEFT, -1, 11, Brand.text_color(dark)) + + +static func point_value(point: Dictionary, series_mode: String) -> Variant: + if series_mode == "accuracy": + return float(point.get("accuracy", 0)) if int(point.get("total", 0)) > 0 else null + var value: Variant = point.get("questionBankRating", null) + return null if value == null else float(value) + + +static func axis_bounds(series_points: Array, series_mode: String) -> Vector2: + if series_mode == "accuracy": + return Vector2(0, 100) + var values: Array[float] = [] + for point in series_points: + var value: Variant = point_value(point, series_mode) + if value != null: + values.append(float(value)) + if values.is_empty(): + return Vector2(900, 1100) + var minimum: float = values.min() + var maximum: float = values.max() + if maximum - minimum < 100.0: + var center: float = (minimum + maximum) / 2.0 + minimum = center - 50.0 + maximum = center + 50.0 + var padding := maxf(25.0, (maximum - minimum) * 0.15) + minimum = floor((minimum - padding) / 25.0) * 25.0 + maximum = ceil((maximum + padding) / 25.0) * 25.0 + return Vector2(minimum, maximum) + + +func _index_near_x(mouse_x: float) -> int: + if points.is_empty() or size.x <= 64.0: + return -1 + var ratio := clampf((mouse_x - 52.0) / maxf(1.0, size.x - 64.0), 0.0, 1.0) + return int(round(ratio * float(points.size() - 1))) + + +func _point_x(index: int, left: float, right: float) -> float: + return left if points.size() <= 1 else lerpf(left, right, float(index) / float(points.size() - 1)) + + +func _value_y(value: float, minimum: float, maximum: float, top: float, bottom: float) -> float: + return bottom - (bottom - top) * inverse_lerp(minimum, maximum, clampf(value, minimum, maximum)) + + +func _short_date(iso_value: String) -> String: + var date := iso_value.left(10) + if date.length() < 10: + return "" + return "%s/%s" % [date.substr(5, 2), date.substr(8, 2)] + + +func _tooltip_box() -> StyleBoxFlat: + var box := StyleBoxFlat.new() + box.bg_color = Color("111d31") if dark else Color.WHITE + box.border_color = Color("34435e") if dark else Brand.LIGHT_BORDER + box.set_border_width_all(1) + box.set_corner_radius_all(5) + return box diff --git a/scripts/ui/dashboard_screen.gd b/scripts/ui/dashboard_screen.gd index 6051c07..2da3d16 100644 --- a/scripts/ui/dashboard_screen.gd +++ b/scripts/ui/dashboard_screen.gd @@ -12,6 +12,8 @@ var metric_grid: GridContainer var analytics_grid: GridContainer var lower_grid: GridContainer var trend_chart: Control +var rating_toggle: Button +var accuracy_toggle: Button var activity_chart: Control var system_list: VBoxContainer var focus_content: VBoxContainer @@ -22,6 +24,7 @@ var metric_values: Dictionary = {} var metric_details: Dictionary = {} var pending_review_launch := false var review_queue_service: Variant +var trend_mode := "rating" func _ready() -> void: @@ -81,7 +84,18 @@ func _build() -> void: var performance := VBoxContainer.new() performance.size_flags_horizontal = Control.SIZE_EXPAND_FILL performance.add_theme_constant_override("separation", 10) - performance.add_child(_section_title("Performance trend")) + var trend_header := HBoxContainer.new() + trend_header.add_child(_section_title("Performance trend")) + var trend_space := Control.new() + trend_space.size_flags_horizontal = Control.SIZE_EXPAND_FILL + trend_header.add_child(trend_space) + var trend_group := ButtonGroup.new() + trend_group.allow_unpress = false + rating_toggle = _trend_toggle("Rating", "rating", trend_group) + accuracy_toggle = _trend_toggle("Correct %", "accuracy", trend_group) + trend_header.add_child(rating_toggle) + trend_header.add_child(accuracy_toggle) + performance.add_child(trend_header) trend_chart = TrendChart.new() trend_chart.custom_minimum_size = Vector2(520, 190) trend_chart.size_flags_horizontal = Control.SIZE_EXPAND_FILL @@ -153,11 +167,7 @@ func _render() -> void: _set_metric("ccs", str(int(data.get("ccsRating", 1000))), "%d rated cases" % int(data.get("ratedCcsCaseCount", 0))) _set_metric("accuracy", "%d%%" % int(data.get("firstAttemptAccuracy", 0)), "Last %d questions" % int(data.get("accuracySampleSize", 0))) _set_metric("time", _format_duration(int(data.get("studyTimeSecondsThisWeek", 0))), "This week") - var trend_values: Array = [] - for point in data.get("performanceTrend", []): - if int(point.get("total", 0)) > 0: - trend_values.append(float(point.get("accuracy", 0))) - trend_chart.set_values(trend_values, UIThemeService.is_dark_mode()) + trend_chart.set_series(data.get("performanceTrend", []), trend_mode, UIThemeService.is_dark_mode()) _render_systems(data.get("systems", [])) _render_focus(data.get("systems", [])) _render_review(data) @@ -170,38 +180,52 @@ func _render() -> void: func _render_systems(systems_value: Array) -> void: _clear(system_list) var systems := systems_value.duplicate(true) - systems.sort_custom(func(a: Dictionary, b: Dictionary): return int(a.get("total", 0)) > int(b.get("total", 0))) if systems.is_empty(): system_list.add_child(_label("Answer questions to build your system breakdown.", 14, Brand.muted_color(UIThemeService.is_dark_mode()))) return - var displayed: Array = systems.slice(0, mini(5, systems.size())) - var best_accuracy := -1 - var worst_accuracy := 101 - for item in displayed: - best_accuracy = maxi(best_accuracy, int(item.get("accuracy", 0))) - worst_accuracy = mini(worst_accuracy, int(item.get("accuracy", 0))) - for item in displayed: + var attempted: Array = systems.filter(func(item: Dictionary): return int(item.get("total", 0)) > 0) + var strongest_system := "" + var weakest_system := "" + if not attempted.is_empty(): + var strongest: Dictionary = attempted[0] + var weakest: Dictionary = attempted[0] + for item in attempted: + if int(item.get("accuracy", 0)) > int(strongest.get("accuracy", 0)) or ( + int(item.get("accuracy", 0)) == int(strongest.get("accuracy", 0)) + and int(item.get("total", 0)) > int(strongest.get("total", 0))): + strongest = item + if int(item.get("accuracy", 0)) < int(weakest.get("accuracy", 0)) or ( + int(item.get("accuracy", 0)) == int(weakest.get("accuracy", 0)) + and int(item.get("total", 0)) > int(weakest.get("total", 0))): + weakest = item + strongest_system = str(strongest.get("system", "")) + weakest_system = str(weakest.get("system", "")) + for item in systems: var row := HBoxContainer.new() row.add_theme_constant_override("separation", 10) var system_name_label := _label(str(item.get("system", "General")), 13, Brand.text_color(UIThemeService.is_dark_mode())) - system_name_label.custom_minimum_size.x = 190 - system_name_label.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS + system_name_label.custom_minimum_size.x = 170 + system_name_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL + system_name_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART row.add_child(system_name_label) var bar := ProgressBar.new() bar.show_percentage = false bar.value = int(item.get("accuracy", 0)) - bar.custom_minimum_size = Vector2(150, 7) + bar.custom_minimum_size = Vector2(80, 7) bar.size_flags_horizontal = Control.SIZE_EXPAND_FILL bar.size_flags_vertical = Control.SIZE_SHRINK_CENTER - _style_progress(bar, Brand.PURPLE if int(item.get("accuracy", 0)) == worst_accuracy else Brand.ROYAL_BLUE) + _style_progress(bar, Brand.PURPLE if str(item.get("system", "")) == weakest_system else Brand.ROYAL_BLUE) row.add_child(bar) - var percentage := _label("%d%%" % int(item.get("accuracy", 0)), 14, Brand.ROYAL_BLUE, true) + var total := int(item.get("total", 0)) + var percentage_text := "%d%%" % int(item.get("accuracy", 0)) if total > 0 else "—" + var percentage := _label(percentage_text, 14, Brand.ROYAL_BLUE, true) percentage.custom_minimum_size.x = 48 row.add_child(percentage) - var count := _label("%d / %d" % [int(item.get("correct", 0)), int(item.get("total", 0))], 12, Brand.muted_color(UIThemeService.is_dark_mode())) + var count := _label("%d / %d" % [int(item.get("correct", 0)), total], 12, Brand.muted_color(UIThemeService.is_dark_mode())) count.custom_minimum_size.x = 58 row.add_child(count) - var marker := "Strongest" if int(item.get("accuracy", 0)) == best_accuracy else ("Needs work" if int(item.get("accuracy", 0)) == worst_accuracy else "") + var system_name := str(item.get("system", "")) + var marker := "Strongest" if system_name == strongest_system else ("Needs work" if system_name == weakest_system else "") var marker_label := _label(marker, 11, Brand.SUCCESS if marker == "Strongest" else Brand.MAGENTA) marker_label.custom_minimum_size.x = 72 row.add_child(marker_label) @@ -442,6 +466,33 @@ func _button(text: String, icon_name: String = "", primary: bool = true) -> Butt return button +func _trend_toggle(text: String, mode: String, group: ButtonGroup) -> Button: + var button := Button.new() + button.text = text + button.toggle_mode = true + button.button_group = group + button.button_pressed = trend_mode == mode + button.custom_minimum_size = Vector2(86, 32) + button.add_theme_font_override("font", Brand.font) + button.add_theme_font_size_override("font_size", 12) + var normal := Brand.secondary_button_box(UIThemeService.is_dark_mode()) + var selected := StyleBoxFlat.new() + selected.bg_color = Brand.ROYAL_BLUE + selected.set_corner_radius_all(6) + button.add_theme_stylebox_override("normal", normal) + button.add_theme_stylebox_override("hover", normal) + button.add_theme_stylebox_override("pressed", selected) + button.add_theme_color_override("font_pressed_color", Color.WHITE) + button.pressed.connect(_set_trend_mode.bind(mode)) + return button + + +func _set_trend_mode(mode: String) -> void: + trend_mode = mode + if trend_chart != null and not DashboardService.data.is_empty(): + trend_chart.set_series(DashboardService.data.get("performanceTrend", []), trend_mode, UIThemeService.is_dark_mode()) + + func _label(text: String, font_size: int, color: Color, bold: bool = false) -> Label: var label := Label.new() label.text = text @@ -487,6 +538,8 @@ func _update_responsive() -> void: metric_grid.columns = 2 if compact else 4 analytics_grid.columns = 1 if compact else 2 lower_grid.columns = 1 if compact else 2 + if trend_chart != null: + trend_chart.custom_minimum_size.x = 300.0 if compact else 520.0 func _clear(container: Container) -> void: diff --git a/tests/dashboard_chart_test.gd b/tests/dashboard_chart_test.gd new file mode 100644 index 0000000..75f6030 --- /dev/null +++ b/tests/dashboard_chart_test.gd @@ -0,0 +1,60 @@ +extends SceneTree + +var failures := 0 + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + var Chart = load("res://scripts/ui/dashboard_chart.gd") + _test_point_values(Chart) + _test_axis_bounds(Chart) + await _test_runtime_modes(Chart) + quit(1 if failures > 0 else 0) + + +func _test_point_values(Chart: GDScript) -> void: + var active := {"accuracy": 75, "total": 4, "questionBankRating": 1042} + var inactive := {"accuracy": 0, "total": 0, "questionBankRating": null} + _assert(Chart.point_value(active, "accuracy") == 75.0, "accuracy mode should expose active-day accuracy") + _assert(Chart.point_value(inactive, "accuracy") == null, "accuracy mode should leave inactive days empty") + _assert(Chart.point_value(active, "rating") == 1042.0, "rating mode should expose stored Elo") + _assert(Chart.point_value(inactive, "rating") == null, "rating mode should preserve pre-baseline gaps") + + +func _test_axis_bounds(Chart: GDScript) -> void: + _assert(Chart.axis_bounds([], "accuracy") == Vector2(0, 100), "accuracy should use a fixed 0-100 axis") + var rating_points := [ + {"questionBankRating": 1000}, + {"questionBankRating": 1020} + ] + var bounds: Vector2 = Chart.axis_bounds(rating_points, "rating") + _assert(bounds.x < 1000 and bounds.y > 1020, "rating bounds should include padded values") + _assert(bounds.y - bounds.x >= 100, "rating bounds should preserve a useful minimum span") + + +func _test_runtime_modes(Chart: GDScript) -> void: + var chart = Chart.new() + chart.size = Vector2(560, 190) + root.add_child(chart) + await process_frame + var series := [ + {"dateUtc": "2026-07-23T00:00:00Z", "total": 2, "accuracy": 50, "questionBankRating": null}, + {"dateUtc": "2026-07-24T00:00:00Z", "total": 0, "accuracy": 0, "questionBankRating": 1000}, + {"dateUtc": "2026-07-25T00:00:00Z", "total": 1, "accuracy": 100, "questionBankRating": 1018} + ] + chart.set_series(series, "rating", true) + _assert(chart.mode == "rating" and chart.points.size() == 3, "chart should accept dated rating series") + chart.set_series(series, "accuracy", false) + _assert(chart.mode == "accuracy" and not chart.dark, "chart should switch modes and themes") + chart.queue_free() + await process_frame + + +func _assert(condition: bool, message: String) -> void: + if condition: + return + failures += 1 + push_error(message) diff --git a/tests/dashboard_chart_test.gd.uid b/tests/dashboard_chart_test.gd.uid new file mode 100644 index 0000000..c895faf --- /dev/null +++ b/tests/dashboard_chart_test.gd.uid @@ -0,0 +1 @@ +uid://di0806e6g2872 diff --git a/tests/dashboard_metric_ui_test.gd b/tests/dashboard_metric_ui_test.gd new file mode 100644 index 0000000..134c8a2 --- /dev/null +++ b/tests/dashboard_metric_ui_test.gd @@ -0,0 +1,66 @@ +extends SceneTree + +var failures := 0 + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + var dashboard_service = root.get_node("DashboardService") + var prior_data: Dictionary = dashboard_service.data + dashboard_service.data = _dashboard_data() + var dashboard = load("res://scripts/ui/dashboard_screen.gd").new() + dashboard.size = Vector2(1280, 900) + root.add_child(dashboard) + await process_frame + dashboard._render() + await process_frame + + _assert(dashboard.system_list.get_child_count() == 14, "dashboard should render all 14 Step 3 systems") + _assert(dashboard.rating_toggle.button_pressed, "rating should be the default trend mode") + _assert(dashboard.trend_chart.mode == "rating", "chart should initially render rating history") + dashboard._set_trend_mode("accuracy") + _assert(dashboard.trend_chart.mode == "accuracy", "correct percentage toggle should switch the chart series") + _assert(dashboard.trend_chart.points.size() == 30, "chart should retain all 30 calendar points") + + dashboard.queue_free() + dashboard_service.data = prior_data + await process_frame + quit(1 if failures > 0 else 0) + + +func _dashboard_data() -> Dictionary: + var systems: Array = [] + for index in range(14): + systems.append({ + "system": "System %02d" % index, + "correct": 1 if index == 0 else 0, + "total": 1 if index == 0 else 0, + "accuracy": 100 if index == 0 else 0 + }) + var trend: Array = [] + for index in range(30): + trend.append({ + "dateUtc": "2026-07-%02dT00:00:00Z" % (index + 1), + "correct": 1 if index % 2 == 0 else 0, + "total": 1 if index % 2 == 0 else 0, + "accuracy": 100 if index % 2 == 0 else 0, + "questionBankRating": 1000 + index + }) + return { + "questionBankRating": 1029, + "ratedQuestionCount": 30, + "performanceTrend": trend, + "systems": systems, + "weeklyActivity": [], + "recentActivity": [] + } + + +func _assert(condition: bool, message: String) -> void: + if condition: + return + failures += 1 + push_error(message) diff --git a/tests/dashboard_metric_ui_test.gd.uid b/tests/dashboard_metric_ui_test.gd.uid new file mode 100644 index 0000000..88df6f5 --- /dev/null +++ b/tests/dashboard_metric_ui_test.gd.uid @@ -0,0 +1 @@ +uid://bxm4twkfwu455