Add rating snapshots, percentile, and exam readiness to the dashboard API
Backend half of the dashboard stat-card redesign. Three new data points the old DashboardResponse had no way to compute: - Week-over-week rating deltas: UserRating/CcsUserRating only ever stored the current Elo (mutated in place, UpdatedUtc just means "last touched"), so there was no way to know what a rating was 7 days ago. Added UserRatingSnapshot (new collection, unique on UserId+DateUtc) and UserRatingSnapshotService, a BackgroundService following AnswerStatisticsMaintenanceService's exact shape (a PeriodicTimer plus a Mongo lease so replicas behind the HPA don't race), writing one snapshot per user per day. No historical backfill - deltas are simply null for a user's first ~7 days after this ships, which the DTO represents honestly via nullable ints rather than a fake "+0". - Percentile rank: DashboardStore now counts how many users have a higher OverallElo/Elo than the current user out of the total. - Exam Readiness + projected pass rate: reuses the existing, tested IRT-based ExamSimulationScore.PassProbability (a real normal-CDF estimate of scoring >=200, computed in ExamSimulationScoring.Calculate) rather than inventing a new prediction model. DashboardStore pulls the latest completed simulation's score directly and maps PassProbability to a High/Medium/Low label via tunable thresholds. - Average Score's "+6% vs last 100" needed no new data source at all - just one more slice (Skip(100).Take(100)) over the QuestionAttempts already loaded for FirstAttemptAccuracy. Added DashboardStore.Percentile/ReadinessLabel as pure static helpers (matching the existing Percentage/CalculateCurrentStreak pattern) and unit tests for both. Full backend suite: 165 passed, 0 failed.
This commit is contained in:
@@ -100,7 +100,8 @@ public sealed class DashboardTests
|
||||
HistoryStartedUtc = baselineDate
|
||||
};
|
||||
|
||||
var result = DashboardStore.BuildPerformanceTrend(attempts, rating, today);
|
||||
var result = DashboardStore.BuildPerformanceTrend(
|
||||
attempts, rating, Array.Empty<CcsAttempt>(), new CcsUserRating(), today);
|
||||
|
||||
Assert.Equal(30, result.Count);
|
||||
Assert.Null(result[^4].QuestionBankRating);
|
||||
@@ -113,6 +114,39 @@ public sealed class DashboardTests
|
||||
Assert.Equal(100, result[^1].Accuracy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TrendCarriesCcsRatingForwardIndependentlyOfQuestionBankRating()
|
||||
{
|
||||
var today = new DateTime(2026, 7, 25, 0, 0, 0, DateTimeKind.Utc);
|
||||
var baselineDate = today.AddDays(-2).AddHours(8);
|
||||
var rating = new UserRating();
|
||||
var ccsRating = new CcsUserRating
|
||||
{
|
||||
Elo = 1050,
|
||||
HistoryBaselineElo = 1000,
|
||||
HistoryStartedUtc = baselineDate
|
||||
};
|
||||
var ccsAttempts = new[]
|
||||
{
|
||||
RatedCcsAttempt("one", today.AddDays(-1).AddHours(9), 1030),
|
||||
RatedCcsAttempt("two", today.AddHours(10), 1050)
|
||||
};
|
||||
|
||||
var result = DashboardStore.BuildPerformanceTrend(
|
||||
Array.Empty<QuestionAttempt>(), rating, ccsAttempts, ccsRating, today);
|
||||
|
||||
Assert.Equal(30, result.Count);
|
||||
Assert.Equal(1000, result[^3].CcsRating);
|
||||
Assert.Equal(1030, result[^2].CcsRating);
|
||||
Assert.Equal(1050, result[^1].CcsRating);
|
||||
}
|
||||
|
||||
private static CcsAttempt RatedCcsAttempt(string caseExternalId, DateTime appliedUtc, int userElo) => new()
|
||||
{
|
||||
CaseExternalId = caseExternalId,
|
||||
Rating = new CcsRatingChange { Rated = true, UserElo = userElo, AppliedUtc = appliedUtc }
|
||||
};
|
||||
|
||||
private static QuestionAttempt Attempt(
|
||||
string questionId,
|
||||
bool correct,
|
||||
@@ -134,4 +168,35 @@ public sealed class DashboardTests
|
||||
ExternalId = system,
|
||||
System = system
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void PercentileReturnsNullWithNoUsers()
|
||||
{
|
||||
Assert.Null(DashboardStore.Percentile(0, 0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PercentileComputesTopPercentFromUsersRatedHigher()
|
||||
{
|
||||
// 18 of 100 users rate higher than this user - "top 18%"
|
||||
Assert.Equal(18, DashboardStore.Percentile(18, 100));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PercentileNeverReturnsZero()
|
||||
{
|
||||
// Even the single best-rated user should read as "top 1%", not "top 0%"
|
||||
Assert.Equal(1, DashboardStore.Percentile(0, 100));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0.85, "High")]
|
||||
[InlineData(0.7, "High")]
|
||||
[InlineData(0.55, "Medium")]
|
||||
[InlineData(0.4, "Medium")]
|
||||
[InlineData(0.1, "Low")]
|
||||
public void ReadinessLabelUsesPassProbabilityThresholds(double passProbability, string expected)
|
||||
{
|
||||
Assert.Equal(expected, DashboardStore.ReadinessLabel(passProbability));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user