Add dashboard performance trends
This commit is contained in:
@@ -1,3 +1,4 @@
|
|||||||
|
using QuestionsApi.Models;
|
||||||
using QuestionsApi.Services;
|
using QuestionsApi.Services;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
@@ -27,4 +28,110 @@ public sealed class DashboardTests
|
|||||||
var dates = new[] { today, today.AddDays(-1), today.AddDays(-3) };
|
var dates = new[] { today, today.AddDays(-1), today.AddDays(-3) };
|
||||||
Assert.Equal(2, DashboardStore.CalculateCurrentStreak(dates, today));
|
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<string, Question>
|
||||||
|
{
|
||||||
|
["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
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -187,7 +187,7 @@ public class QuestionsController : ControllerBase
|
|||||||
record.Attempt.IsCorrect,
|
record.Attempt.IsCorrect,
|
||||||
shouldRate);
|
shouldRate);
|
||||||
if (shouldRate)
|
if (shouldRate)
|
||||||
await _attempts.MarkRatingAppliedAsync(record.Attempt.Id ?? "", ratingOwner);
|
await _attempts.MarkRatingAppliedAsync(record.Attempt.Id ?? "", ratingOwner, rating.UserOverallElo);
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
@@ -286,7 +286,7 @@ public class QuestionsController : ControllerBase
|
|||||||
{
|
{
|
||||||
rating = await _ratings.ApplyFirstResultAsync(userId, question, false, shouldRate);
|
rating = await _ratings.ApplyFirstResultAsync(userId, question, false, shouldRate);
|
||||||
if (shouldRate)
|
if (shouldRate)
|
||||||
await _attempts.MarkRatingAppliedAsync(record.Attempt.Id ?? "", ratingOwner);
|
await _attempts.MarkRatingAppliedAsync(record.Attempt.Id ?? "", ratingOwner, rating.UserOverallElo);
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ public sealed class DashboardTrendPoint
|
|||||||
public int Correct { get; set; }
|
public int Correct { get; set; }
|
||||||
public int Total { get; set; }
|
public int Total { get; set; }
|
||||||
public int Accuracy { get; set; }
|
public int Accuracy { get; set; }
|
||||||
|
public int? QuestionBankRating { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class DashboardSystemPerformance
|
public sealed class DashboardSystemPerformance
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ public class QuestionAttempt
|
|||||||
public int DurationSeconds { get; set; }
|
public int DurationSeconds { get; set; }
|
||||||
public int CounterBucket { get; set; }
|
public int CounterBucket { get; set; }
|
||||||
public bool? RatingApplied { get; set; }
|
public bool? RatingApplied { get; set; }
|
||||||
|
public int? UserOverallEloAfter { get; set; }
|
||||||
|
public DateTime? RatingAppliedUtc { get; set; }
|
||||||
public string RatingLeaseOwner { get; set; } = "";
|
public string RatingLeaseOwner { get; set; } = "";
|
||||||
public DateTime RatingLeaseUntilUtc { get; set; }
|
public DateTime RatingLeaseUntilUtc { get; set; }
|
||||||
public DateTime CreatedUtc { get; set; } = DateTime.UtcNow;
|
public DateTime CreatedUtc { get; set; } = DateTime.UtcNow;
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ public class UserRating
|
|||||||
public int OverallElo { get; set; } = 1000;
|
public int OverallElo { get; set; } = 1000;
|
||||||
public Dictionary<string, int> CategoryElos { get; set; } = [];
|
public Dictionary<string, int> CategoryElos { get; set; } = [];
|
||||||
public int RatedQuestionCount { get; set; }
|
public int RatedQuestionCount { get; set; }
|
||||||
|
public int? HistoryBaselineElo { get; set; }
|
||||||
|
public DateTime? HistoryStartedUtc { get; set; }
|
||||||
public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow;
|
public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ namespace QuestionsApi.Services;
|
|||||||
public sealed class DashboardStore
|
public sealed class DashboardStore
|
||||||
{
|
{
|
||||||
private readonly IMongoCollection<QuestionAttempt> _attempts;
|
private readonly IMongoCollection<QuestionAttempt> _attempts;
|
||||||
private readonly IMongoCollection<UserRating> _ratings;
|
|
||||||
private readonly IMongoCollection<CcsUserRating> _ccsRatings;
|
private readonly IMongoCollection<CcsUserRating> _ccsRatings;
|
||||||
private readonly IMongoCollection<CcsAttempt> _ccsAttempts;
|
private readonly IMongoCollection<CcsAttempt> _ccsAttempts;
|
||||||
private readonly IMongoCollection<CcsCaseRevision> _ccsCases;
|
private readonly IMongoCollection<CcsCaseRevision> _ccsCases;
|
||||||
@@ -19,6 +18,25 @@ public sealed class DashboardStore
|
|||||||
private readonly DynamicQuestionStore _dynamicQuestions;
|
private readonly DynamicQuestionStore _dynamicQuestions;
|
||||||
private readonly FlashcardStudyService _flashcards;
|
private readonly FlashcardStudyService _flashcards;
|
||||||
private readonly FlashcardScheduleStore _flashcardSchedules;
|
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(
|
public DashboardStore(
|
||||||
MongoDatabaseProvider mongo,
|
MongoDatabaseProvider mongo,
|
||||||
@@ -26,10 +44,10 @@ public sealed class DashboardStore
|
|||||||
ReviewQueueStore reviewQueue,
|
ReviewQueueStore reviewQueue,
|
||||||
DynamicQuestionStore dynamicQuestions,
|
DynamicQuestionStore dynamicQuestions,
|
||||||
FlashcardStudyService flashcards,
|
FlashcardStudyService flashcards,
|
||||||
FlashcardScheduleStore flashcardSchedules)
|
FlashcardScheduleStore flashcardSchedules,
|
||||||
|
RatingStore ratingStore)
|
||||||
{
|
{
|
||||||
_attempts = mongo.Database.GetCollection<QuestionAttempt>("QuestionAttempts");
|
_attempts = mongo.Database.GetCollection<QuestionAttempt>("QuestionAttempts");
|
||||||
_ratings = mongo.Database.GetCollection<UserRating>("UserRatings");
|
|
||||||
_ccsRatings = mongo.Database.GetCollection<CcsUserRating>("CcsUserRatings");
|
_ccsRatings = mongo.Database.GetCollection<CcsUserRating>("CcsUserRatings");
|
||||||
_ccsAttempts = mongo.Database.GetCollection<CcsAttempt>("CcsAttempts");
|
_ccsAttempts = mongo.Database.GetCollection<CcsAttempt>("CcsAttempts");
|
||||||
_ccsCases = mongo.Database.GetCollection<CcsCaseRevision>("CcsCaseRevisions");
|
_ccsCases = mongo.Database.GetCollection<CcsCaseRevision>("CcsCaseRevisions");
|
||||||
@@ -41,6 +59,7 @@ public sealed class DashboardStore
|
|||||||
_dynamicQuestions = dynamicQuestions;
|
_dynamicQuestions = dynamicQuestions;
|
||||||
_flashcards = flashcards;
|
_flashcards = flashcards;
|
||||||
_flashcardSchedules = flashcardSchedules;
|
_flashcardSchedules = flashcardSchedules;
|
||||||
|
_ratingStore = ratingStore;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<DashboardResponse> GetAsync(string userId, DateTime nowUtc)
|
public async Task<DashboardResponse> GetAsync(string userId, DateTime nowUtc)
|
||||||
@@ -49,7 +68,7 @@ public sealed class DashboardStore
|
|||||||
var trendStart = today.AddDays(-29);
|
var trendStart = today.AddDays(-29);
|
||||||
var weekStart = today.AddDays(-6);
|
var weekStart = today.AddDays(-6);
|
||||||
var attemptsTask = _attempts.Find(value => value.UserId == userId).ToListAsync();
|
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 ccsRatingTask = _ccsRatings.Find(value => value.UserId == userId).FirstOrDefaultAsync();
|
||||||
var ccsAttemptsTask = _ccsAttempts.Find(value => value.UserId == userId && value.CreatedUtc >= trendStart).ToListAsync();
|
var ccsAttemptsTask = _ccsAttempts.Find(value => value.UserId == userId && value.CreatedUtc >= trendStart).ToListAsync();
|
||||||
var completedCcsCountTask = _ccsAttempts.CountDocumentsAsync(value => value.UserId == userId
|
var completedCcsCountTask = _ccsAttempts.CountDocumentsAsync(value => value.UserId == userId
|
||||||
@@ -77,8 +96,8 @@ public sealed class DashboardStore
|
|||||||
|
|
||||||
var response = new DashboardResponse
|
var response = new DashboardResponse
|
||||||
{
|
{
|
||||||
QuestionBankRating = ratingTask.Result?.OverallElo ?? 1000,
|
QuestionBankRating = ratingTask.Result.OverallElo,
|
||||||
RatedQuestionCount = ratingTask.Result?.RatedQuestionCount ?? 0,
|
RatedQuestionCount = ratingTask.Result.RatedQuestionCount,
|
||||||
CcsRating = ccsRatingTask.Result?.Elo ?? 1000,
|
CcsRating = ccsRatingTask.Result?.Elo ?? 1000,
|
||||||
RatedCcsCaseCount = ccsRatingTask.Result?.RatedCaseCount ?? 0,
|
RatedCcsCaseCount = ccsRatingTask.Result?.RatedCaseCount ?? 0,
|
||||||
QuestionsCompleted = answeredAttempts.Count,
|
QuestionsCompleted = answeredAttempts.Count,
|
||||||
@@ -95,31 +114,8 @@ public sealed class DashboardStore
|
|||||||
FlashcardsReviewedThisWeek = flashcardCountsTask.Result.ReviewedThisWeek
|
FlashcardsReviewedThisWeek = flashcardCountsTask.Result.ReviewedThisWeek
|
||||||
};
|
};
|
||||||
|
|
||||||
response.PerformanceTrend = Enumerable.Range(0, 30).Select(offset =>
|
response.PerformanceTrend = BuildPerformanceTrend(attempts, ratingTask.Result, today);
|
||||||
{
|
response.Systems = BuildSystemPerformance(answeredAttempts, questionMap);
|
||||||
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 =>
|
response.WeeklyActivity = Enumerable.Range(0, 7).Select(offset =>
|
||||||
{
|
{
|
||||||
@@ -166,6 +162,133 @@ public sealed class DashboardStore
|
|||||||
return streak;
|
return streak;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static List<DashboardTrendPoint> BuildPerformanceTrend(
|
||||||
|
IEnumerable<QuestionAttempt> 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<DashboardTrendPoint>();
|
||||||
|
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<DashboardSystemPerformance> BuildSystemPerformance(
|
||||||
|
IEnumerable<QuestionAttempt> attempts,
|
||||||
|
IReadOnlyDictionary<string, Question> 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<List<DashboardRecentActivity>> BuildRecentActivityAsync(
|
private async Task<List<DashboardRecentActivity>> BuildRecentActivityAsync(
|
||||||
List<QuestionAttempt> attempts,
|
List<QuestionAttempt> attempts,
|
||||||
Dictionary<string, Question> questionMap,
|
Dictionary<string, Question> questionMap,
|
||||||
|
|||||||
@@ -454,8 +454,8 @@ public class ExamSimulationStore
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
shouldRate = await _attempts.TryAcquireRatingLeaseAsync(attemptId, owner, TimeSpan.FromSeconds(30));
|
shouldRate = await _attempts.TryAcquireRatingLeaseAsync(attemptId, owner, TimeSpan.FromSeconds(30));
|
||||||
await _ratings.ApplyFirstResultAsync(simulation.UserId, question, record.Attempt.IsCorrect, shouldRate);
|
var rating = await _ratings.ApplyFirstResultAsync(simulation.UserId, question, record.Attempt.IsCorrect, shouldRate);
|
||||||
if (shouldRate) await _attempts.MarkRatingAppliedAsync(attemptId, owner);
|
if (shouldRate) await _attempts.MarkRatingAppliedAsync(attemptId, owner, rating.UserOverallElo);
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -103,11 +103,13 @@ public class QuestionAttemptStore
|
|||||||
return leased?.RatingLeaseOwner == owner;
|
return leased?.RatingLeaseOwner == owner;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task MarkRatingAppliedAsync(string attemptId, string owner) =>
|
public Task MarkRatingAppliedAsync(string attemptId, string owner, int userOverallElo) =>
|
||||||
_col.UpdateOneAsync(
|
_col.UpdateOneAsync(
|
||||||
attempt => attempt.Id == attemptId && attempt.RatingLeaseOwner == owner,
|
attempt => attempt.Id == attemptId && attempt.RatingLeaseOwner == owner,
|
||||||
Builders<QuestionAttempt>.Update
|
Builders<QuestionAttempt>.Update
|
||||||
.Set(attempt => attempt.RatingApplied, true)
|
.Set(attempt => attempt.RatingApplied, true)
|
||||||
|
.Set(attempt => attempt.UserOverallEloAfter, userOverallElo)
|
||||||
|
.Set(attempt => attempt.RatingAppliedUtc, DateTime.UtcNow)
|
||||||
.Set(attempt => attempt.RatingLeaseUntilUtc, DateTime.MinValue));
|
.Set(attempt => attempt.RatingLeaseUntilUtc, DateTime.MinValue));
|
||||||
|
|
||||||
public Task ReleaseRatingLeaseAsync(string attemptId, string owner) =>
|
public Task ReleaseRatingLeaseAsync(string attemptId, string owner) =>
|
||||||
|
|||||||
@@ -35,6 +35,12 @@ public class RatingStore
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<UserRating> GetOrInitializeHistoryAsync(string userId, DateTime nowUtc)
|
||||||
|
{
|
||||||
|
var rating = await GetOrCreateUserRatingAsync(userId);
|
||||||
|
return await EnsureHistoryBaselineAsync(rating, nowUtc);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<Dictionary<string, int>> GetQuestionElosAsync(IEnumerable<string> externalIds)
|
public async Task<Dictionary<string, int>> GetQuestionElosAsync(IEnumerable<string> externalIds)
|
||||||
{
|
{
|
||||||
var ids = externalIds.Where(id => !string.IsNullOrWhiteSpace(id)).Distinct().ToList();
|
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 actual = isCorrect ? 1.0 : 0.0;
|
||||||
var userK = userRating.RatedQuestionCount < 20 ? 40 : 24;
|
var userK = userRating.RatedQuestionCount < 20 ? 40 : 24;
|
||||||
var questionK = questionRating.AttemptCount < 20 ? 32 : 24;
|
var questionK = questionRating.AttemptCount < 20 ? 32 : 24;
|
||||||
@@ -164,6 +171,26 @@ public class RatingStore
|
|||||||
return rating;
|
return rating;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<UserRating> 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<UserRating>.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<QuestionRating> GetOrCreateQuestionRatingAsync(string questionExternalId)
|
private async Task<QuestionRating> GetOrCreateQuestionRatingAsync(string questionExternalId)
|
||||||
{
|
{
|
||||||
var create = Builders<QuestionRating>.Update
|
var create = Builders<QuestionRating>.Update
|
||||||
|
|||||||
+138
-21
@@ -2,39 +2,156 @@ extends Control
|
|||||||
|
|
||||||
const Brand := preload("res://scripts/ui/medmind_style.gd")
|
const Brand := preload("res://scripts/ui/medmind_style.gd")
|
||||||
|
|
||||||
var values: PackedFloat32Array = []
|
var points: Array = []
|
||||||
|
var mode := "rating"
|
||||||
var dark := true
|
var dark := true
|
||||||
|
var hover_index := -1
|
||||||
|
|
||||||
|
|
||||||
func set_values(next_values: Array, dark_mode: bool) -> void:
|
func _ready() -> void:
|
||||||
values = PackedFloat32Array(next_values)
|
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
|
dark = dark_mode
|
||||||
|
hover_index = -1
|
||||||
queue_redraw()
|
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:
|
func _draw() -> void:
|
||||||
var left := 42.0
|
var left := 52.0
|
||||||
var top := 12.0
|
var top := 12.0
|
||||||
var right := size.x - 12.0
|
var right := maxf(left + 1.0, size.x - 12.0)
|
||||||
var bottom := size.y - 28.0
|
var bottom := maxf(top + 1.0, size.y - 34.0)
|
||||||
var grid := Color("24314b") if dark else Color("dbe3ef")
|
var grid := Color("24314b") if dark else Color("dbe3ef")
|
||||||
var muted := Brand.muted_color(dark)
|
var muted := Brand.muted_color(dark)
|
||||||
for percentage in [0, 25, 50, 75, 100]:
|
var bounds := axis_bounds(points, mode)
|
||||||
var y: float = bottom - (bottom - top) * float(percentage) / 100.0
|
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_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)
|
draw_string(Brand.font, Vector2(2, y + 4), str(int(round(tick_value))), HORIZONTAL_ALIGNMENT_RIGHT, 43, 11, muted)
|
||||||
var target_y: float = bottom - (bottom - top) * 0.70
|
if mode == "accuracy":
|
||||||
draw_dashed_line(Vector2(left, target_y), Vector2(right, target_y), Brand.ROYAL_BLUE, 5.0, 2.0)
|
var target_y := _value_y(70.0, minimum, maximum, top, bottom)
|
||||||
if values.is_empty():
|
draw_dashed_line(Vector2(left, target_y), Vector2(right, target_y), Brand.ROYAL_BLUE, 5.0, 2.0)
|
||||||
|
if points.is_empty():
|
||||||
return
|
return
|
||||||
var points := PackedVector2Array()
|
|
||||||
var count: int = values.size()
|
var previous: Variant = null
|
||||||
for index in range(count):
|
for index in range(points.size()):
|
||||||
var x: float = left if count == 1 else lerpf(left, right, float(index) / float(count - 1))
|
var value: Variant = point_value(points[index], mode)
|
||||||
var y: float = bottom - (bottom - top) * clampf(values[index], 0.0, 100.0) / 100.0
|
if value == null:
|
||||||
points.append(Vector2(x, y))
|
previous = null
|
||||||
if points.size() > 1:
|
continue
|
||||||
draw_polyline(points, Brand.ROYAL_BLUE, 2.5, true)
|
var point := Vector2(_point_x(index, left, right), _value_y(float(value), minimum, maximum, top, bottom))
|
||||||
for point in points:
|
if previous != null:
|
||||||
|
draw_line(previous, point, Brand.ROYAL_BLUE, 2.5, true)
|
||||||
draw_circle(point, 3.5, Brand.PURPLE)
|
draw_circle(point, 3.5, Brand.PURPLE)
|
||||||
draw_circle(point, 2.0, Color.WHITE)
|
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
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ var metric_grid: GridContainer
|
|||||||
var analytics_grid: GridContainer
|
var analytics_grid: GridContainer
|
||||||
var lower_grid: GridContainer
|
var lower_grid: GridContainer
|
||||||
var trend_chart: Control
|
var trend_chart: Control
|
||||||
|
var rating_toggle: Button
|
||||||
|
var accuracy_toggle: Button
|
||||||
var activity_chart: Control
|
var activity_chart: Control
|
||||||
var system_list: VBoxContainer
|
var system_list: VBoxContainer
|
||||||
var focus_content: VBoxContainer
|
var focus_content: VBoxContainer
|
||||||
@@ -22,6 +24,7 @@ var metric_values: Dictionary = {}
|
|||||||
var metric_details: Dictionary = {}
|
var metric_details: Dictionary = {}
|
||||||
var pending_review_launch := false
|
var pending_review_launch := false
|
||||||
var review_queue_service: Variant
|
var review_queue_service: Variant
|
||||||
|
var trend_mode := "rating"
|
||||||
|
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
@@ -81,7 +84,18 @@ func _build() -> void:
|
|||||||
var performance := VBoxContainer.new()
|
var performance := VBoxContainer.new()
|
||||||
performance.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
performance.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
performance.add_theme_constant_override("separation", 10)
|
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 = TrendChart.new()
|
||||||
trend_chart.custom_minimum_size = Vector2(520, 190)
|
trend_chart.custom_minimum_size = Vector2(520, 190)
|
||||||
trend_chart.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
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("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("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")
|
_set_metric("time", _format_duration(int(data.get("studyTimeSecondsThisWeek", 0))), "This week")
|
||||||
var trend_values: Array = []
|
trend_chart.set_series(data.get("performanceTrend", []), trend_mode, UIThemeService.is_dark_mode())
|
||||||
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())
|
|
||||||
_render_systems(data.get("systems", []))
|
_render_systems(data.get("systems", []))
|
||||||
_render_focus(data.get("systems", []))
|
_render_focus(data.get("systems", []))
|
||||||
_render_review(data)
|
_render_review(data)
|
||||||
@@ -170,38 +180,52 @@ func _render() -> void:
|
|||||||
func _render_systems(systems_value: Array) -> void:
|
func _render_systems(systems_value: Array) -> void:
|
||||||
_clear(system_list)
|
_clear(system_list)
|
||||||
var systems := systems_value.duplicate(true)
|
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():
|
if systems.is_empty():
|
||||||
system_list.add_child(_label("Answer questions to build your system breakdown.", 14, Brand.muted_color(UIThemeService.is_dark_mode())))
|
system_list.add_child(_label("Answer questions to build your system breakdown.", 14, Brand.muted_color(UIThemeService.is_dark_mode())))
|
||||||
return
|
return
|
||||||
var displayed: Array = systems.slice(0, mini(5, systems.size()))
|
var attempted: Array = systems.filter(func(item: Dictionary): return int(item.get("total", 0)) > 0)
|
||||||
var best_accuracy := -1
|
var strongest_system := ""
|
||||||
var worst_accuracy := 101
|
var weakest_system := ""
|
||||||
for item in displayed:
|
if not attempted.is_empty():
|
||||||
best_accuracy = maxi(best_accuracy, int(item.get("accuracy", 0)))
|
var strongest: Dictionary = attempted[0]
|
||||||
worst_accuracy = mini(worst_accuracy, int(item.get("accuracy", 0)))
|
var weakest: Dictionary = attempted[0]
|
||||||
for item in displayed:
|
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()
|
var row := HBoxContainer.new()
|
||||||
row.add_theme_constant_override("separation", 10)
|
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()))
|
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.custom_minimum_size.x = 170
|
||||||
system_name_label.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
|
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)
|
row.add_child(system_name_label)
|
||||||
var bar := ProgressBar.new()
|
var bar := ProgressBar.new()
|
||||||
bar.show_percentage = false
|
bar.show_percentage = false
|
||||||
bar.value = int(item.get("accuracy", 0))
|
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_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
bar.size_flags_vertical = Control.SIZE_SHRINK_CENTER
|
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)
|
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
|
percentage.custom_minimum_size.x = 48
|
||||||
row.add_child(percentage)
|
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
|
count.custom_minimum_size.x = 58
|
||||||
row.add_child(count)
|
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)
|
var marker_label := _label(marker, 11, Brand.SUCCESS if marker == "Strongest" else Brand.MAGENTA)
|
||||||
marker_label.custom_minimum_size.x = 72
|
marker_label.custom_minimum_size.x = 72
|
||||||
row.add_child(marker_label)
|
row.add_child(marker_label)
|
||||||
@@ -442,6 +466,33 @@ func _button(text: String, icon_name: String = "", primary: bool = true) -> Butt
|
|||||||
return button
|
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:
|
func _label(text: String, font_size: int, color: Color, bold: bool = false) -> Label:
|
||||||
var label := Label.new()
|
var label := Label.new()
|
||||||
label.text = text
|
label.text = text
|
||||||
@@ -487,6 +538,8 @@ func _update_responsive() -> void:
|
|||||||
metric_grid.columns = 2 if compact else 4
|
metric_grid.columns = 2 if compact else 4
|
||||||
analytics_grid.columns = 1 if compact else 2
|
analytics_grid.columns = 1 if compact else 2
|
||||||
lower_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:
|
func _clear(container: Container) -> void:
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://di0806e6g2872
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://bxm4twkfwu455
|
||||||
Reference in New Issue
Block a user