Add clinical progression ranks and records
This commit is contained in:
@@ -0,0 +1,57 @@
|
|||||||
|
using QuestionsApi.Models;
|
||||||
|
using QuestionsApi.Services;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace QuestionsApi.Tests;
|
||||||
|
|
||||||
|
public sealed class ProgressionPolicyTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void New_learner_starts_as_case_scout_three()
|
||||||
|
{
|
||||||
|
Assert.Equal(0, ProgressionPolicy.Qualified([], 0));
|
||||||
|
Assert.Equal("Case Scout", ProgressionPolicy.Name(0));
|
||||||
|
Assert.Equal("III", ProgressionPolicy.Division(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Pattern_hunter_requires_every_visible_gate()
|
||||||
|
{
|
||||||
|
var systems = Systems(learning: 6, practicing: 2, strong: 0, mastered: 0, hard: 6);
|
||||||
|
Assert.Equal(1, ProgressionPolicy.Qualified(systems, 0));
|
||||||
|
Assert.Equal(3, ProgressionPolicy.Qualified(systems, 1));
|
||||||
|
Assert.Equal("Pattern Hunter", ProgressionPolicy.Name(3));
|
||||||
|
Assert.Equal("III", ProgressionPolicy.Division(3));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Highest_division_requires_breadth_challenge_and_retention()
|
||||||
|
{
|
||||||
|
var systems = Systems(learning: 14, practicing: 14, strong: 14, mastered: 12, hard: 105);
|
||||||
|
Assert.Equal(13, ProgressionPolicy.Qualified(systems, 20));
|
||||||
|
Assert.Equal(14, ProgressionPolicy.Qualified(systems, 21));
|
||||||
|
Assert.Equal("Clinical Vanguard", ProgressionPolicy.Name(14));
|
||||||
|
Assert.Equal("I", ProgressionPolicy.Division(14));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("unseen", 0)] [InlineData("learning", 1)] [InlineData("practicing", 2)]
|
||||||
|
[InlineData("strong", 3)] [InlineData("mastered", 4)]
|
||||||
|
public void Mastery_states_have_stable_order(string state, int expected) =>
|
||||||
|
Assert.Equal(expected, ProgressionPolicy.State(state));
|
||||||
|
|
||||||
|
private static List<DashboardSystemPerformance> Systems(int learning, int practicing, int strong, int mastered, int hard)
|
||||||
|
{
|
||||||
|
var result = new List<DashboardSystemPerformance>();
|
||||||
|
for (var i = 0; i < 14; i++)
|
||||||
|
{
|
||||||
|
var state = i < mastered ? MasteryStates.Mastered
|
||||||
|
: i < strong ? MasteryStates.Strong
|
||||||
|
: i < practicing ? MasteryStates.Practicing
|
||||||
|
: i < learning ? MasteryStates.Learning : MasteryStates.Unseen;
|
||||||
|
result.Add(new DashboardSystemPerformance { System = $"s{i}", MasteryState = state });
|
||||||
|
}
|
||||||
|
for (var i = 0; i < hard; i++) result[i % result.Count].Difficulty4PlusSuccessful++;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ namespace QuestionsApi.Controllers;
|
|||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/dashboard")]
|
[Route("api/dashboard")]
|
||||||
[Authorize(Roles = "USER,SUPER")]
|
[Authorize(Roles = "USER,SUPER")]
|
||||||
public sealed class DashboardController(DashboardStore dashboard) : ControllerBase
|
public sealed class DashboardController(DashboardStore dashboard, ProgressionStore progression) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public async Task<IActionResult> Get()
|
public async Task<IActionResult> Get()
|
||||||
@@ -18,6 +18,8 @@ public sealed class DashboardController(DashboardStore dashboard) : ControllerBa
|
|||||||
?? User.Identity?.Name
|
?? User.Identity?.Name
|
||||||
?? "";
|
?? "";
|
||||||
if (string.IsNullOrWhiteSpace(userId)) return Unauthorized();
|
if (string.IsNullOrWhiteSpace(userId)) return Unauthorized();
|
||||||
return Ok(await dashboard.GetAsync(userId, DateTime.UtcNow));
|
var response = await dashboard.GetAsync(userId, DateTime.UtcNow);
|
||||||
|
response.Progression = await progression.GetAsync(userId);
|
||||||
|
return Ok(response);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ public class ExamSimulationsController(
|
|||||||
ExamSimulationStore simulations,
|
ExamSimulationStore simulations,
|
||||||
CreditStore credits,
|
CreditStore credits,
|
||||||
MasteryAwardService masteryAwards,
|
MasteryAwardService masteryAwards,
|
||||||
StudySessionStore studySessions) : ControllerBase
|
StudySessionStore studySessions,
|
||||||
|
ProgressionStore progression) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet("readiness")]
|
[HttpGet("readiness")]
|
||||||
public async Task<IActionResult> Readiness() => Ok(await simulations.GetReadinessAsync(UserId()));
|
public async Task<IActionResult> Readiness() => Ok(await simulations.GetReadinessAsync(UserId()));
|
||||||
@@ -145,6 +146,7 @@ public class ExamSimulationsController(
|
|||||||
{
|
{
|
||||||
var userId = UserId();
|
var userId = UserId();
|
||||||
await masteryAwards.PrepareAsync(userId);
|
await masteryAwards.PrepareAsync(userId);
|
||||||
|
await progression.PrepareAsync(userId);
|
||||||
simulation = await simulations.SubmitBlockAsync(simulation);
|
simulation = await simulations.SubmitBlockAsync(simulation);
|
||||||
var response = await simulations.ToResponseAsync(simulation);
|
var response = await simulations.ToResponseAsync(simulation);
|
||||||
var session = await studySessions.CompleteSimulationBlockAsync(userId, simulation.Id ?? id, simulation.CurrentBlock);
|
var session = await studySessions.CompleteSimulationBlockAsync(userId, simulation.Id ?? id, simulation.CurrentBlock);
|
||||||
@@ -164,6 +166,10 @@ public class ExamSimulationsController(
|
|||||||
}
|
}
|
||||||
else if (session.Summary is not null)
|
else if (session.Summary is not null)
|
||||||
response.Credits = await credits.GetStatusAsync(userId);
|
response.Credits = await credits.GetStatusAsync(userId);
|
||||||
|
var progressionResult = await progression.RefreshAsync(userId);
|
||||||
|
response.Progression = progressionResult.State;
|
||||||
|
response.CreditAwards.AddRange(progressionResult.Awards);
|
||||||
|
if (progressionResult.Awards.Count > 0) response.Credits = await credits.GetStatusAsync(userId);
|
||||||
return Ok(response);
|
return Ok(response);
|
||||||
}
|
}
|
||||||
catch (InvalidOperationException exception) { return Conflict(exception.Message); }
|
catch (InvalidOperationException exception) { return Conflict(exception.Message); }
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using QuestionsApi.Services;
|
||||||
|
using System.Security.Claims;
|
||||||
|
|
||||||
|
namespace QuestionsApi.Controllers;
|
||||||
|
|
||||||
|
[ApiController, Route("api/progression"), Authorize(Roles = "USER,SUPER")]
|
||||||
|
public sealed class ProgressionController(ProgressionStore progression) : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<IActionResult> Get()
|
||||||
|
{
|
||||||
|
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue(ClaimTypes.Name)
|
||||||
|
?? User.Identity?.Name ?? "";
|
||||||
|
return string.IsNullOrWhiteSpace(userId) ? Unauthorized() : Ok(await progression.GetAsync(userId));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ public class QuestionsController : ControllerBase
|
|||||||
private readonly MasteryAwardService _masteryAwards;
|
private readonly MasteryAwardService _masteryAwards;
|
||||||
private readonly StudySessionStore _studySessions;
|
private readonly StudySessionStore _studySessions;
|
||||||
private readonly DailyMissionStore _dailyMissions;
|
private readonly DailyMissionStore _dailyMissions;
|
||||||
|
private readonly ProgressionStore _progression;
|
||||||
|
|
||||||
public QuestionsController(
|
public QuestionsController(
|
||||||
QuestionStore questions,
|
QuestionStore questions,
|
||||||
@@ -36,7 +37,8 @@ public class QuestionsController : ControllerBase
|
|||||||
CreditStore credits,
|
CreditStore credits,
|
||||||
MasteryAwardService masteryAwards,
|
MasteryAwardService masteryAwards,
|
||||||
StudySessionStore studySessions,
|
StudySessionStore studySessions,
|
||||||
DailyMissionStore dailyMissions)
|
DailyMissionStore dailyMissions,
|
||||||
|
ProgressionStore progression)
|
||||||
{
|
{
|
||||||
_questions = questions;
|
_questions = questions;
|
||||||
_attempts = attempts;
|
_attempts = attempts;
|
||||||
@@ -50,6 +52,7 @@ public class QuestionsController : ControllerBase
|
|||||||
_masteryAwards = masteryAwards;
|
_masteryAwards = masteryAwards;
|
||||||
_studySessions = studySessions;
|
_studySessions = studySessions;
|
||||||
_dailyMissions = dailyMissions;
|
_dailyMissions = dailyMissions;
|
||||||
|
_progression = progression;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("systems")]
|
[HttpGet("systems")]
|
||||||
@@ -162,6 +165,7 @@ public class QuestionsController : ControllerBase
|
|||||||
return Unauthorized();
|
return Unauthorized();
|
||||||
|
|
||||||
await _masteryAwards.PrepareAsync(userId);
|
await _masteryAwards.PrepareAsync(userId);
|
||||||
|
await _progression.PrepareAsync(userId);
|
||||||
|
|
||||||
var record = await _attempts.RecordFirstAttemptAsync(
|
var record = await _attempts.RecordFirstAttemptAsync(
|
||||||
question.ExternalId,
|
question.ExternalId,
|
||||||
@@ -237,6 +241,8 @@ public class QuestionsController : ControllerBase
|
|||||||
&& creditAwards.All(existing => existing.ReasonCode != value.ReasonCode)));
|
&& creditAwards.All(existing => existing.ReasonCode != value.ReasonCode)));
|
||||||
var dailyMissions = await _dailyMissions.RecordQuestionAsync(userId, record.Attempt, question);
|
var dailyMissions = await _dailyMissions.RecordQuestionAsync(userId, record.Attempt, question);
|
||||||
if (dailyMissions is not null) creditAwards.AddRange(dailyMissions.CreditAwards);
|
if (dailyMissions is not null) creditAwards.AddRange(dailyMissions.CreditAwards);
|
||||||
|
var progression = await _progression.RefreshAsync(userId);
|
||||||
|
creditAwards.AddRange(progression.Awards);
|
||||||
return Ok(new FirstQuestionAttemptResponse
|
return Ok(new FirstQuestionAttemptResponse
|
||||||
{
|
{
|
||||||
QuestionExternalId = question.ExternalId,
|
QuestionExternalId = question.ExternalId,
|
||||||
@@ -249,7 +255,8 @@ public class QuestionsController : ControllerBase
|
|||||||
CreditAwards = creditAwards,
|
CreditAwards = creditAwards,
|
||||||
StudySessionProgress = studySession?.Progress,
|
StudySessionProgress = studySession?.Progress,
|
||||||
SessionSummary = studySession?.Summary,
|
SessionSummary = studySession?.Summary,
|
||||||
DailyMissions = dailyMissions?.State
|
DailyMissions = dailyMissions?.State,
|
||||||
|
Progression = progression.State
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -310,6 +317,8 @@ public class QuestionsController : ControllerBase
|
|||||||
if (string.IsNullOrWhiteSpace(userId))
|
if (string.IsNullOrWhiteSpace(userId))
|
||||||
return Unauthorized();
|
return Unauthorized();
|
||||||
|
|
||||||
|
await _progression.PrepareAsync(userId);
|
||||||
|
|
||||||
var record = await _attempts.RecordFirstAttemptAsync(
|
var record = await _attempts.RecordFirstAttemptAsync(
|
||||||
question.ExternalId,
|
question.ExternalId,
|
||||||
userId,
|
userId,
|
||||||
@@ -342,6 +351,7 @@ public class QuestionsController : ControllerBase
|
|||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var progression = await _progression.RefreshAsync(userId);
|
||||||
return Ok(new SkipQuestionResponse
|
return Ok(new SkipQuestionResponse
|
||||||
{
|
{
|
||||||
QuestionExternalId = question.ExternalId,
|
QuestionExternalId = question.ExternalId,
|
||||||
@@ -350,7 +360,8 @@ public class QuestionsController : ControllerBase
|
|||||||
question,
|
question,
|
||||||
await _statistics.GetPercentagesForQuestionAsync(question.ExternalId)),
|
await _statistics.GetPercentagesForQuestionAsync(question.ExternalId)),
|
||||||
QuestionElo = rating.QuestionElo,
|
QuestionElo = rating.QuestionElo,
|
||||||
Rating = rating
|
Rating = rating,
|
||||||
|
Progression = progression.State
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ public sealed class ReviewQueueController(
|
|||||||
CreditStore credits,
|
CreditStore credits,
|
||||||
MasteryAwardService masteryAwards,
|
MasteryAwardService masteryAwards,
|
||||||
StudySessionStore studySessions,
|
StudySessionStore studySessions,
|
||||||
DailyMissionStore dailyMissions) : ControllerBase
|
DailyMissionStore dailyMissions,
|
||||||
|
ProgressionStore progression) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpPost("next")]
|
[HttpPost("next")]
|
||||||
public async Task<IActionResult> Next([FromBody] ReviewQueueNextRequest req)
|
public async Task<IActionResult> Next([FromBody] ReviewQueueNextRequest req)
|
||||||
@@ -76,6 +77,7 @@ public sealed class ReviewQueueController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
await masteryAwards.PrepareAsync(userId);
|
await masteryAwards.PrepareAsync(userId);
|
||||||
|
await progression.PrepareAsync(userId);
|
||||||
var result = await queue.RecordReviewAsync(
|
var result = await queue.RecordReviewAsync(
|
||||||
userId,
|
userId,
|
||||||
req.ClientAttemptId,
|
req.ClientAttemptId,
|
||||||
@@ -110,6 +112,8 @@ public sealed class ReviewQueueController(
|
|||||||
&& awards.All(existing => existing.ReasonCode != value.ReasonCode)));
|
&& awards.All(existing => existing.ReasonCode != value.ReasonCode)));
|
||||||
var missionResult = await dailyMissions.RecordReviewAsync(userId, result.Attempt);
|
var missionResult = await dailyMissions.RecordReviewAsync(userId, result.Attempt);
|
||||||
if (missionResult is not null) awards.AddRange(missionResult.CreditAwards);
|
if (missionResult is not null) awards.AddRange(missionResult.CreditAwards);
|
||||||
|
var progressionResult = await progression.RefreshAsync(userId);
|
||||||
|
awards.AddRange(progressionResult.Awards);
|
||||||
return Ok(new ReviewQueueAttemptResponse
|
return Ok(new ReviewQueueAttemptResponse
|
||||||
{
|
{
|
||||||
QuestionExternalId = question.ExternalId,
|
QuestionExternalId = question.ExternalId,
|
||||||
@@ -120,7 +124,8 @@ public sealed class ReviewQueueController(
|
|||||||
CreditAwards = awards,
|
CreditAwards = awards,
|
||||||
StudySessionProgress = studySession?.Progress,
|
StudySessionProgress = studySession?.Progress,
|
||||||
SessionSummary = studySession?.Summary,
|
SessionSummary = studySession?.Summary,
|
||||||
DailyMissions = missionResult?.State
|
DailyMissions = missionResult?.State,
|
||||||
|
Progression = progressionResult.State
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -112,6 +112,7 @@ public static class CreditReasonCodes
|
|||||||
public const string SessionGoalComplete = "session_goal_complete";
|
public const string SessionGoalComplete = "session_goal_complete";
|
||||||
public const string DailyMissionComplete = "daily_mission_complete";
|
public const string DailyMissionComplete = "daily_mission_complete";
|
||||||
public const string WeeklyGoalComplete = "weekly_goal_complete";
|
public const string WeeklyGoalComplete = "weekly_goal_complete";
|
||||||
|
public const string RankPromotion = "rank_promotion";
|
||||||
public const string Purchase = "purchase";
|
public const string Purchase = "purchase";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ public sealed class DashboardResponse
|
|||||||
public List<DashboardSystemPerformance> Systems { get; set; } = [];
|
public List<DashboardSystemPerformance> Systems { get; set; } = [];
|
||||||
public List<DashboardActivityDay> WeeklyActivity { get; set; } = [];
|
public List<DashboardActivityDay> WeeklyActivity { get; set; } = [];
|
||||||
public List<DashboardRecentActivity> RecentActivity { get; set; } = [];
|
public List<DashboardRecentActivity> RecentActivity { get; set; } = [];
|
||||||
|
public ProgressionResponse? Progression { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class DashboardTrendPoint
|
public sealed class DashboardTrendPoint
|
||||||
|
|||||||
@@ -121,6 +121,7 @@ public class ExamSimulationStateResponse
|
|||||||
public List<CreditAward> CreditAwards { get; set; } = [];
|
public List<CreditAward> CreditAwards { get; set; } = [];
|
||||||
public StudySessionProgress? StudySessionProgress { get; set; }
|
public StudySessionProgress? StudySessionProgress { get; set; }
|
||||||
public StudySessionSummary? SessionSummary { get; set; }
|
public StudySessionSummary? SessionSummary { get; set; }
|
||||||
|
public ProgressionResponse? Progression { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class ExamSimulationReadinessResponse
|
public class ExamSimulationReadinessResponse
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
using MongoDB.Bson;
|
||||||
|
using MongoDB.Bson.Serialization.Attributes;
|
||||||
|
|
||||||
|
namespace QuestionsApi.Models;
|
||||||
|
|
||||||
|
public sealed class UserProgression
|
||||||
|
{
|
||||||
|
[BsonId, BsonRepresentation(BsonType.ObjectId)] public string? Id { get; set; }
|
||||||
|
public string UserId { get; set; } = "";
|
||||||
|
public int HighestRankOrdinal { get; set; }
|
||||||
|
public DateTime TrackingStartedUtc { get; set; }
|
||||||
|
public bool BaselineApplied { get; set; }
|
||||||
|
public Dictionary<string, DateTime> EarnedRanksUtc { get; set; } = [];
|
||||||
|
public int CurrentHardStreak { get; set; }
|
||||||
|
public int BestHardStreak { get; set; }
|
||||||
|
public DateTime? BestHardStreakUtc { get; set; }
|
||||||
|
public PersonalRecordValue BestTimedBlock { get; set; } = new();
|
||||||
|
public PersonalRecordValue SystemsAdvancedWeek { get; set; } = new();
|
||||||
|
public PersonalRecordValue ReviewQueueReduction { get; set; } = new();
|
||||||
|
public PersonalRecordValue WeakSystemImprovement { get; set; } = new();
|
||||||
|
public DateTime UpdatedUtc { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class PersonalRecordValue
|
||||||
|
{
|
||||||
|
public int Value { get; set; }
|
||||||
|
public int SampleSize { get; set; }
|
||||||
|
public string Detail { get; set; } = "";
|
||||||
|
public DateTime? AchievedUtc { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ProgressionWeeklySnapshot
|
||||||
|
{
|
||||||
|
[BsonId] public string Id { get; set; } = "";
|
||||||
|
public string UserId { get; set; } = "";
|
||||||
|
public DateTime WeekStartUtc { get; set; }
|
||||||
|
public List<ProgressionSystemSnapshot> OpeningSystems { get; set; } = [];
|
||||||
|
public List<ProgressionSystemSnapshot> LatestSystems { get; set; } = [];
|
||||||
|
public int OpeningPendingReviews { get; set; }
|
||||||
|
public int LatestPendingReviews { get; set; }
|
||||||
|
public DateTime UpdatedUtc { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ProgressionSystemSnapshot
|
||||||
|
{
|
||||||
|
public string System { get; set; } = "";
|
||||||
|
public int StateOrdinal { get; set; }
|
||||||
|
public int MasteryScore { get; set; }
|
||||||
|
public int DistinctQuestions { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ProgressionRequirementResponse
|
||||||
|
{
|
||||||
|
public string Key { get; set; } = "";
|
||||||
|
public string Label { get; set; } = "";
|
||||||
|
public int Current { get; set; }
|
||||||
|
public int Required { get; set; }
|
||||||
|
public bool Complete => Current >= Required;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ProgressionRecordResponse
|
||||||
|
{
|
||||||
|
public string Key { get; set; } = "";
|
||||||
|
public string Label { get; set; } = "";
|
||||||
|
public string Definition { get; set; } = "";
|
||||||
|
public int Value { get; set; }
|
||||||
|
public int SampleSize { get; set; }
|
||||||
|
public string Detail { get; set; } = "";
|
||||||
|
public DateTime? AchievedUtc { get; set; }
|
||||||
|
public bool TrackingFromLaunch { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ProgressionResponse
|
||||||
|
{
|
||||||
|
public int RankOrdinal { get; set; }
|
||||||
|
public int QualifiedRankOrdinal { get; set; }
|
||||||
|
public string RankName { get; set; } = "Case Scout";
|
||||||
|
public string Division { get; set; } = "III";
|
||||||
|
public string DisplayName => $"{RankName} {Division}";
|
||||||
|
public DateTime TrackingStartedUtc { get; set; }
|
||||||
|
public List<string> EarnedBadges { get; set; } = [];
|
||||||
|
public Dictionary<string, DateTime> EarnedRanksUtc { get; set; } = [];
|
||||||
|
public List<ProgressionRankResponse> Ladder { get; set; } = [];
|
||||||
|
public string? NextRankName { get; set; }
|
||||||
|
public string? NextDivision { get; set; }
|
||||||
|
public List<ProgressionRequirementResponse> NextRequirements { get; set; } = [];
|
||||||
|
public List<ProgressionRecordResponse> Records { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ProgressionRankResponse
|
||||||
|
{
|
||||||
|
public int Ordinal { get; set; }
|
||||||
|
public string RankName { get; set; } = "";
|
||||||
|
public string Division { get; set; } = "";
|
||||||
|
public bool Earned { get; set; }
|
||||||
|
public DateTime? EarnedUtc { get; set; }
|
||||||
|
public string RequirementSummary { get; set; } = "";
|
||||||
|
}
|
||||||
@@ -150,6 +150,7 @@ public class FirstQuestionAttemptResponse
|
|||||||
public StudySessionProgress? StudySessionProgress { get; set; }
|
public StudySessionProgress? StudySessionProgress { get; set; }
|
||||||
public StudySessionSummary? SessionSummary { get; set; }
|
public StudySessionSummary? SessionSummary { get; set; }
|
||||||
public DailyMissionSetResponse? DailyMissions { get; set; }
|
public DailyMissionSetResponse? DailyMissions { get; set; }
|
||||||
|
public ProgressionResponse? Progression { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class SkipQuestionResponse
|
public class SkipQuestionResponse
|
||||||
@@ -159,6 +160,7 @@ public class SkipQuestionResponse
|
|||||||
public Dictionary<string, int> ChoiceSelectionPercentages { get; set; } = [];
|
public Dictionary<string, int> ChoiceSelectionPercentages { get; set; } = [];
|
||||||
public int QuestionElo { get; set; } = 1000;
|
public int QuestionElo { get; set; } = 1000;
|
||||||
public RatingChangeResponse? Rating { get; set; }
|
public RatingChangeResponse? Rating { get; set; }
|
||||||
|
public ProgressionResponse? Progression { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public static class RatingCategory
|
public static class RatingCategory
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ public sealed class ReviewQueueAttemptResponse
|
|||||||
public StudySessionProgress? StudySessionProgress { get; set; }
|
public StudySessionProgress? StudySessionProgress { get; set; }
|
||||||
public StudySessionSummary? SessionSummary { get; set; }
|
public StudySessionSummary? SessionSummary { get; set; }
|
||||||
public DailyMissionSetResponse? DailyMissions { get; set; }
|
public DailyMissionSetResponse? DailyMissions { get; set; }
|
||||||
|
public ProgressionResponse? Progression { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class ReviewQueueRecordResult
|
public sealed class ReviewQueueRecordResult
|
||||||
|
|||||||
@@ -114,6 +114,7 @@ builder.Services.AddSingleton<DashboardStore>();
|
|||||||
builder.Services.AddSingleton<StudySessionStore>();
|
builder.Services.AddSingleton<StudySessionStore>();
|
||||||
builder.Services.AddSingleton<DailyMissionStore>();
|
builder.Services.AddSingleton<DailyMissionStore>();
|
||||||
builder.Services.AddSingleton<WeeklyGoalStore>();
|
builder.Services.AddSingleton<WeeklyGoalStore>();
|
||||||
|
builder.Services.AddSingleton<ProgressionStore>();
|
||||||
builder.Services.AddSingleton<UserProfileStore>();
|
builder.Services.AddSingleton<UserProfileStore>();
|
||||||
builder.Services.AddSingleton<IFlashcardScheduler, FsrsFlashcardScheduler>();
|
builder.Services.AddSingleton<IFlashcardScheduler, FsrsFlashcardScheduler>();
|
||||||
builder.Services.AddSingleton<FlashcardDeckStore>();
|
builder.Services.AddSingleton<FlashcardDeckStore>();
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
using QuestionsApi.Models;
|
||||||
|
|
||||||
|
namespace QuestionsApi.Services;
|
||||||
|
|
||||||
|
public static class ProgressionPolicy
|
||||||
|
{
|
||||||
|
public sealed record Threshold(int L, int P, int S, int M, int H, int R);
|
||||||
|
public static readonly Threshold[] Thresholds =
|
||||||
|
[
|
||||||
|
new(0,0,0,0,0,0), new(2,0,0,0,1,0), new(4,1,0,0,3,1),
|
||||||
|
new(6,2,0,0,6,1), new(8,4,0,0,10,2), new(10,6,0,0,15,3),
|
||||||
|
new(0,8,1,0,20,4), new(0,9,2,0,28,5), new(0,10,4,0,36,7),
|
||||||
|
new(0,11,5,1,45,9), new(0,12,7,2,55,11), new(0,13,9,4,65,13),
|
||||||
|
new(0,14,10,6,75,15), new(0,14,12,9,90,18), new(0,14,14,12,105,21)
|
||||||
|
];
|
||||||
|
public static readonly string[] Names = ["Case Scout", "Pattern Hunter", "Diagnostic Tactician", "Management Strategist", "Clinical Vanguard"];
|
||||||
|
public static readonly string[] Divisions = ["III", "II", "I"];
|
||||||
|
|
||||||
|
public static int Qualified(IReadOnlyCollection<DashboardSystemPerformance> systems, int retention)
|
||||||
|
{
|
||||||
|
var l = systems.Count(x => State(x.MasteryState) >= 1);
|
||||||
|
var p = systems.Count(x => State(x.MasteryState) >= 2);
|
||||||
|
var s = systems.Count(x => State(x.MasteryState) >= 3);
|
||||||
|
var m = systems.Count(x => State(x.MasteryState) >= 4);
|
||||||
|
var h = systems.Sum(x => x.Difficulty4PlusSuccessful);
|
||||||
|
for (var i = Thresholds.Length - 1; i >= 0; i--)
|
||||||
|
{
|
||||||
|
var t = Thresholds[i];
|
||||||
|
if (l >= t.L && p >= t.P && s >= t.S && m >= t.M && h >= t.H && retention >= t.R) return i;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static int State(string state) => state switch
|
||||||
|
{
|
||||||
|
MasteryStates.Learning => 1, MasteryStates.Practicing => 2,
|
||||||
|
MasteryStates.Strong => 3, MasteryStates.Mastered => 4, _ => 0
|
||||||
|
};
|
||||||
|
public static string Name(int ordinal) => Names[Math.Clamp(ordinal / 3, 0, 4)];
|
||||||
|
public static string Division(int ordinal) => Divisions[Math.Clamp(ordinal % 3, 0, 2)];
|
||||||
|
public static int TierStart(int ordinal) => ordinal / 3 * 3;
|
||||||
|
}
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
using MongoDB.Driver;
|
||||||
|
using QuestionsApi.Models;
|
||||||
|
|
||||||
|
namespace QuestionsApi.Services;
|
||||||
|
|
||||||
|
public sealed class ProgressionStore
|
||||||
|
{
|
||||||
|
private readonly IMongoCollection<UserProgression> _progressions;
|
||||||
|
private readonly IMongoCollection<ProgressionWeeklySnapshot> _snapshots;
|
||||||
|
private readonly IMongoCollection<QuestionAttempt> _attempts;
|
||||||
|
private readonly IMongoCollection<QuestionReviewAttempt> _reviews;
|
||||||
|
private readonly IMongoCollection<ReviewQueueItem> _queue;
|
||||||
|
private readonly IMongoCollection<StudySession> _sessions;
|
||||||
|
private readonly QuestionStore _questions;
|
||||||
|
private readonly CreditStore _credits;
|
||||||
|
private readonly TimeProvider _clock;
|
||||||
|
|
||||||
|
public ProgressionStore(MongoDatabaseProvider mongo, QuestionStore questions, CreditStore credits, TimeProvider clock)
|
||||||
|
{
|
||||||
|
var db = mongo.Database;
|
||||||
|
_progressions = db.GetCollection<UserProgression>("UserProgressions");
|
||||||
|
_snapshots = db.GetCollection<ProgressionWeeklySnapshot>("ProgressionWeeklySnapshots");
|
||||||
|
_attempts = db.GetCollection<QuestionAttempt>("QuestionAttempts");
|
||||||
|
_reviews = db.GetCollection<QuestionReviewAttempt>("QuestionReviewAttempts");
|
||||||
|
_queue = db.GetCollection<ReviewQueueItem>("ReviewQueueItems");
|
||||||
|
_sessions = db.GetCollection<StudySession>("StudySessions");
|
||||||
|
_questions = questions;
|
||||||
|
_credits = credits;
|
||||||
|
_clock = clock;
|
||||||
|
_progressions.Indexes.CreateOne(new CreateIndexModel<UserProgression>(
|
||||||
|
Builders<UserProgression>.IndexKeys.Ascending(x => x.UserId), new CreateIndexOptions { Unique = true }));
|
||||||
|
_snapshots.Indexes.CreateOne(new CreateIndexModel<ProgressionWeeklySnapshot>(
|
||||||
|
Builders<ProgressionWeeklySnapshot>.IndexKeys.Ascending(x => x.UserId).Descending(x => x.WeekStartUtc)));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task PrepareAsync(string userId)
|
||||||
|
{
|
||||||
|
if (await _progressions.Find(x => x.UserId == userId).AnyAsync())
|
||||||
|
{
|
||||||
|
await EnsureWeeklyOpeningAsync(userId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var evidence = await EvidenceAsync(userId);
|
||||||
|
var qualified = ProgressionPolicy.Qualified(evidence.Systems, evidence.Retention);
|
||||||
|
var now = _clock.GetUtcNow().UtcDateTime;
|
||||||
|
var progression = new UserProgression
|
||||||
|
{
|
||||||
|
UserId = userId, HighestRankOrdinal = qualified, BaselineApplied = true,
|
||||||
|
TrackingStartedUtc = now, UpdatedUtc = now,
|
||||||
|
EarnedRanksUtc = Enumerable.Range(0, qualified + 1).ToDictionary(x => x.ToString(), _ => now)
|
||||||
|
};
|
||||||
|
ApplyHistoricalRecords(progression, evidence);
|
||||||
|
try { await _progressions.InsertOneAsync(progression); }
|
||||||
|
catch (MongoWriteException exception) when (exception.WriteError?.Category == ServerErrorCategory.DuplicateKey) { }
|
||||||
|
await EnsureWeeklyOpeningAsync(userId, evidence);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<(ProgressionResponse State, List<CreditAward> Awards)> RefreshAsync(string userId)
|
||||||
|
{
|
||||||
|
await PrepareAsync(userId);
|
||||||
|
var evidence = await EvidenceAsync(userId);
|
||||||
|
var progression = await _progressions.Find(x => x.UserId == userId).FirstAsync();
|
||||||
|
var oldHighest = progression.HighestRankOrdinal;
|
||||||
|
var qualified = ProgressionPolicy.Qualified(evidence.Systems, evidence.Retention);
|
||||||
|
var now = _clock.GetUtcNow().UtcDateTime;
|
||||||
|
ApplyHistoricalRecords(progression, evidence);
|
||||||
|
if (qualified > progression.HighestRankOrdinal)
|
||||||
|
{
|
||||||
|
progression.HighestRankOrdinal = qualified;
|
||||||
|
for (var i = oldHighest + 1; i <= qualified; i++) progression.EarnedRanksUtc.TryAdd(i.ToString(), now);
|
||||||
|
}
|
||||||
|
await UpdateWeeklyAsync(progression, evidence, now);
|
||||||
|
progression.UpdatedUtc = now;
|
||||||
|
var update = Builders<UserProgression>.Update.Max(x => x.HighestRankOrdinal, qualified)
|
||||||
|
.Set(x => x.CurrentHardStreak, progression.CurrentHardStreak)
|
||||||
|
.Set(x => x.BestHardStreak, progression.BestHardStreak)
|
||||||
|
.Set(x => x.BestHardStreakUtc, progression.BestHardStreakUtc)
|
||||||
|
.Set(x => x.BestTimedBlock, progression.BestTimedBlock)
|
||||||
|
.Set(x => x.SystemsAdvancedWeek, progression.SystemsAdvancedWeek)
|
||||||
|
.Set(x => x.ReviewQueueReduction, progression.ReviewQueueReduction)
|
||||||
|
.Set(x => x.WeakSystemImprovement, progression.WeakSystemImprovement)
|
||||||
|
.Set(x => x.UpdatedUtc, now);
|
||||||
|
for (var i = oldHighest + 1; i <= qualified; i++) update = update.Set($"EarnedRanksUtc.{i}", now);
|
||||||
|
await _progressions.UpdateOneAsync(x => x.UserId == userId, update);
|
||||||
|
progression = await _progressions.Find(x => x.UserId == userId).FirstAsync();
|
||||||
|
|
||||||
|
var awards = new List<CreditAward>();
|
||||||
|
foreach (var tier in new[] { 3, 6, 9, 12 }.Where(x => oldHighest < x && qualified >= x))
|
||||||
|
{
|
||||||
|
var amount = tier switch { 3 => 5, 6 => 10, 9 => 15, _ => 25 };
|
||||||
|
var result = await _credits.AwardAsync(userId, new CreditAwardRequest(
|
||||||
|
$"progression:rank:{tier}", amount, CreditReasonCodes.RankPromotion,
|
||||||
|
$"Advanced to {ProgressionPolicy.Name(tier)}", "progression_rank", tier.ToString(), false));
|
||||||
|
if (result.Award is not null) awards.Add(result.Award);
|
||||||
|
}
|
||||||
|
return (Response(progression, qualified, evidence), awards);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ProgressionResponse> GetAsync(string userId) => (await RefreshAsync(userId)).State;
|
||||||
|
|
||||||
|
private async Task EnsureWeeklyOpeningAsync(string userId, Evidence? supplied = null)
|
||||||
|
{
|
||||||
|
var now = _clock.GetUtcNow().UtcDateTime;
|
||||||
|
var week = WeekStart(now);
|
||||||
|
var id = $"{userId}:{week:yyyyMMdd}";
|
||||||
|
if (await _snapshots.Find(x => x.Id == id).AnyAsync()) return;
|
||||||
|
var evidence = supplied ?? await EvidenceAsync(userId);
|
||||||
|
var values = Snapshots(evidence.Systems);
|
||||||
|
var snapshot = new ProgressionWeeklySnapshot
|
||||||
|
{
|
||||||
|
Id = id, UserId = userId, WeekStartUtc = week,
|
||||||
|
OpeningSystems = values, LatestSystems = values,
|
||||||
|
OpeningPendingReviews = evidence.Pending, LatestPendingReviews = evidence.Pending, UpdatedUtc = now
|
||||||
|
};
|
||||||
|
try { await _snapshots.InsertOneAsync(snapshot); }
|
||||||
|
catch (MongoWriteException exception) when (exception.WriteError?.Category == ServerErrorCategory.DuplicateKey) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task UpdateWeeklyAsync(UserProgression progression, Evidence evidence, DateTime now)
|
||||||
|
{
|
||||||
|
await EnsureWeeklyOpeningAsync(progression.UserId, evidence);
|
||||||
|
var week = WeekStart(now);
|
||||||
|
var id = $"{progression.UserId}:{week:yyyyMMdd}";
|
||||||
|
await _snapshots.UpdateOneAsync(x => x.Id == id, Builders<ProgressionWeeklySnapshot>.Update
|
||||||
|
.Set(x => x.LatestSystems, Snapshots(evidence.Systems)).Set(x => x.LatestPendingReviews, evidence.Pending)
|
||||||
|
.Set(x => x.UpdatedUtc, now));
|
||||||
|
var all = await _snapshots.Find(x => x.UserId == progression.UserId).ToListAsync();
|
||||||
|
foreach (var snapshot in all)
|
||||||
|
{
|
||||||
|
var opening = snapshot.OpeningSystems.ToDictionary(x => x.System);
|
||||||
|
var advanced = snapshot.LatestSystems.Count(x => opening.TryGetValue(x.System, out var before) && x.StateOrdinal > before.StateOrdinal);
|
||||||
|
SetRecordIfBetter(progression.SystemsAdvancedWeek, advanced, 0, $"Week of {snapshot.WeekStartUtc:yyyy-MM-dd}", snapshot.UpdatedUtc);
|
||||||
|
var reduced = Math.Max(0, snapshot.OpeningPendingReviews - snapshot.LatestPendingReviews);
|
||||||
|
SetRecordIfBetter(progression.ReviewQueueReduction, reduced, snapshot.OpeningPendingReviews,
|
||||||
|
$"Week of {snapshot.WeekStartUtc:yyyy-MM-dd}", snapshot.UpdatedUtc);
|
||||||
|
foreach (var after in snapshot.LatestSystems)
|
||||||
|
{
|
||||||
|
if (!opening.TryGetValue(after.System, out var before) || before.MasteryScore >= 75 || before.DistinctQuestions < 5) continue;
|
||||||
|
SetRecordIfBetter(progression.WeakSystemImprovement, Math.Max(0, after.MasteryScore - before.MasteryScore),
|
||||||
|
after.DistinctQuestions, after.System, snapshot.UpdatedUtc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<Evidence> EvidenceAsync(string userId)
|
||||||
|
{
|
||||||
|
var attempts = await _attempts.Find(x => x.UserId == userId).SortBy(x => x.CreatedUtc).ToListAsync();
|
||||||
|
var reviews = await _reviews.Find(x => x.UserId == userId).SortBy(x => x.CreatedUtc).ToListAsync();
|
||||||
|
var queue = await _queue.Find(x => x.UserId == userId).ToListAsync();
|
||||||
|
var ids = attempts.Select(x => x.QuestionExternalId).Concat(reviews.Select(x => x.QuestionExternalId)).Concat(queue.Select(x => x.QuestionExternalId));
|
||||||
|
var questions = await _questions.GetByExternalIdsAsync(ids);
|
||||||
|
var systems = MasteryCalculator.Calculate(attempts, reviews, queue, questions, _clock.GetUtcNow().UtcDateTime);
|
||||||
|
var delayed = reviews.Where(x => x.IsCorrect).Select(x => new
|
||||||
|
{
|
||||||
|
Review = x, Item = queue.FirstOrDefault(q => q.QuestionExternalId == x.QuestionExternalId)
|
||||||
|
}).Where(x => x.Item is not null && x.Review.CreatedUtc - x.Item.CreatedUtc >= TimeSpan.FromHours(24))
|
||||||
|
.Select(x => x.Review.QuestionExternalId).Distinct().Count();
|
||||||
|
var directHard = attempts.Where(x => x.IsCorrect && !x.IsSkip && questions.TryGetValue(x.QuestionExternalId, out var q) && q.Difficulty >= 4)
|
||||||
|
.Select(x => x.QuestionExternalId).Distinct().Count();
|
||||||
|
var sessions = await _sessions.Find(x => x.UserId == userId && x.State == StudySessionStates.Completed
|
||||||
|
&& x.Mode == "Timed" && x.GoalType != StudyGoalTypes.SimulationBlock).ToListAsync();
|
||||||
|
return new Evidence(attempts, reviews, queue, sessions, questions, systems, delayed + directHard / 5,
|
||||||
|
queue.Count(x => x.State == ReviewQueueStates.Pending));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyHistoricalRecords(UserProgression progression, Evidence evidence)
|
||||||
|
{
|
||||||
|
var streak = 0;
|
||||||
|
foreach (var attempt in evidence.Attempts.Where(x => evidence.Questions.TryGetValue(x.QuestionExternalId, out var q) && q.Difficulty >= 4))
|
||||||
|
{
|
||||||
|
streak = attempt.IsCorrect && !attempt.IsSkip ? streak + 1 : 0;
|
||||||
|
if (streak > progression.BestHardStreak)
|
||||||
|
{
|
||||||
|
progression.BestHardStreak = streak;
|
||||||
|
progression.BestHardStreakUtc = attempt.CreatedUtc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
progression.CurrentHardStreak = streak;
|
||||||
|
|
||||||
|
var attemptsById = evidence.Attempts.Where(x => x.Id is not null).ToDictionary(x => x.Id!);
|
||||||
|
foreach (var session in evidence.Sessions)
|
||||||
|
{
|
||||||
|
var sessionAttempts = session.AppliedEventIds.Where(attemptsById.ContainsKey).Select(x => attemptsById[x]).ToList();
|
||||||
|
if (sessionAttempts.Count < 20 || sessionAttempts.Count != session.AppliedEventIds.Count) continue;
|
||||||
|
var hard = sessionAttempts.Count(x => evidence.Questions.TryGetValue(x.QuestionExternalId, out var q) && q.Difficulty >= 4);
|
||||||
|
if (hard * 2 < sessionAttempts.Count) continue;
|
||||||
|
var accuracy = (int)Math.Round(sessionAttempts.Count(x => x.IsCorrect && !x.IsSkip) * 100d / sessionAttempts.Count);
|
||||||
|
if (accuracy > progression.BestTimedBlock.Value
|
||||||
|
|| accuracy == progression.BestTimedBlock.Value && sessionAttempts.Count > progression.BestTimedBlock.SampleSize)
|
||||||
|
progression.BestTimedBlock = new PersonalRecordValue { Value = accuracy, SampleSize = sessionAttempts.Count,
|
||||||
|
Detail = $"{hard} difficulty 4–5 questions", AchievedUtc = session.CompletedUtc ?? session.UpdatedUtc };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void SetRecordIfBetter(PersonalRecordValue record, int value, int sample, string detail, DateTime date)
|
||||||
|
{
|
||||||
|
if (value <= record.Value) return;
|
||||||
|
record.Value = value; record.SampleSize = sample; record.Detail = detail; record.AchievedUtc = date;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ProgressionResponse Response(UserProgression p, int qualified, Evidence evidence)
|
||||||
|
{
|
||||||
|
var earnedTierNames = Enumerable.Range(0, p.HighestRankOrdinal / 3 + 1).Select(x => ProgressionPolicy.Names[x]).ToList();
|
||||||
|
var response = new ProgressionResponse
|
||||||
|
{
|
||||||
|
RankOrdinal = p.HighestRankOrdinal, QualifiedRankOrdinal = qualified,
|
||||||
|
RankName = ProgressionPolicy.Name(p.HighestRankOrdinal), Division = ProgressionPolicy.Division(p.HighestRankOrdinal),
|
||||||
|
TrackingStartedUtc = p.TrackingStartedUtc, EarnedBadges = earnedTierNames, EarnedRanksUtc = p.EarnedRanksUtc,
|
||||||
|
Records = Records(p), Ladder = Enumerable.Range(0, ProgressionPolicy.Thresholds.Length).Select(i =>
|
||||||
|
{
|
||||||
|
var t = ProgressionPolicy.Thresholds[i];
|
||||||
|
var parts = new List<string>();
|
||||||
|
if (t.L > 0) parts.Add($"Learning+ {t.L}"); if (t.P > 0) parts.Add($"Practicing+ {t.P}");
|
||||||
|
if (t.S > 0) parts.Add($"Strong+ {t.S}"); if (t.M > 0) parts.Add($"Mastered {t.M}");
|
||||||
|
if (t.H > 0) parts.Add($"Hard successes {t.H}"); if (t.R > 0) parts.Add($"Retention {t.R}");
|
||||||
|
return new ProgressionRankResponse { Ordinal=i, RankName=ProgressionPolicy.Name(i), Division=ProgressionPolicy.Division(i),
|
||||||
|
Earned=i <= p.HighestRankOrdinal, EarnedUtc=p.EarnedRanksUtc.GetValueOrDefault(i.ToString()),
|
||||||
|
RequirementSummary=parts.Count == 0 ? "Starting rank" : string.Join(" · ", parts) };
|
||||||
|
}).ToList()
|
||||||
|
};
|
||||||
|
if (p.HighestRankOrdinal >= ProgressionPolicy.Thresholds.Length - 1) return response;
|
||||||
|
var next = p.HighestRankOrdinal + 1;
|
||||||
|
response.NextRankName = ProgressionPolicy.Name(next); response.NextDivision = ProgressionPolicy.Division(next);
|
||||||
|
var threshold = ProgressionPolicy.Thresholds[next];
|
||||||
|
var values = new Dictionary<string, (string Label, int Current, int Required)>
|
||||||
|
{
|
||||||
|
["learning"] = ("Systems at Learning+", evidence.Systems.Count(x => ProgressionPolicy.State(x.MasteryState) >= 1), threshold.L),
|
||||||
|
["practicing"] = ("Systems at Practicing+", evidence.Systems.Count(x => ProgressionPolicy.State(x.MasteryState) >= 2), threshold.P),
|
||||||
|
["strong"] = ("Systems at Strong+", evidence.Systems.Count(x => ProgressionPolicy.State(x.MasteryState) >= 3), threshold.S),
|
||||||
|
["mastered"] = ("Systems Mastered", evidence.Systems.Count(x => ProgressionPolicy.State(x.MasteryState) >= 4), threshold.M),
|
||||||
|
["hard"] = ("Difficulty 4+ successes", evidence.Systems.Sum(x => x.Difficulty4PlusSuccessful), threshold.H),
|
||||||
|
["retention"] = ("Retention evidence", evidence.Retention, threshold.R)
|
||||||
|
};
|
||||||
|
response.NextRequirements = values.Where(x => x.Value.Required > 0).Select(x => new ProgressionRequirementResponse
|
||||||
|
{ Key = x.Key, Label = x.Value.Label, Current = x.Value.Current, Required = x.Value.Required }).ToList();
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<ProgressionRecordResponse> Records(UserProgression p) =>
|
||||||
|
[
|
||||||
|
new() { Key="timed_block", Label="Advanced Timed Block", Definition="Best accuracy in a completed 20+ question Timed block with at least 50% difficulty 4–5.", Value=p.BestTimedBlock.Value, SampleSize=p.BestTimedBlock.SampleSize, Detail=p.BestTimedBlock.Detail, AchievedUtc=p.BestTimedBlock.AchievedUtc },
|
||||||
|
new() { Key="hard_streak", Label="Hard-Question Streak", Definition="Consecutive correct first attempts at difficulty 4–5; easier questions are ignored.", Value=p.BestHardStreak, AchievedUtc=p.BestHardStreakUtc },
|
||||||
|
new() { Key="systems_week", Label="Systems Advanced in a Week", Definition="Unique systems whose mastery state increased in one UTC Monday–Sunday week.", Value=p.SystemsAdvancedWeek.Value, Detail=p.SystemsAdvancedWeek.Detail, AchievedUtc=p.SystemsAdvancedWeek.AchievedUtc, TrackingFromLaunch=true },
|
||||||
|
new() { Key="reviews_week", Label="Review Queue Reduction", Definition="Largest weekly decrease in pending reviews.", Value=p.ReviewQueueReduction.Value, SampleSize=p.ReviewQueueReduction.SampleSize, Detail=p.ReviewQueueReduction.Detail, AchievedUtc=p.ReviewQueueReduction.AchievedUtc, TrackingFromLaunch=true },
|
||||||
|
new() { Key="weak_gain", Label="Weak-System Improvement", Definition="Largest weekly mastery-score gain in a system that began below 75 with at least five questions of evidence.", Value=p.WeakSystemImprovement.Value, SampleSize=p.WeakSystemImprovement.SampleSize, Detail=p.WeakSystemImprovement.Detail, AchievedUtc=p.WeakSystemImprovement.AchievedUtc, TrackingFromLaunch=true }
|
||||||
|
];
|
||||||
|
|
||||||
|
private static List<ProgressionSystemSnapshot> Snapshots(IEnumerable<DashboardSystemPerformance> systems) => systems.Select(x => new ProgressionSystemSnapshot
|
||||||
|
{ System=x.System, StateOrdinal=ProgressionPolicy.State(x.MasteryState), MasteryScore=x.MasteryScore, DistinctQuestions=x.DistinctQuestions }).ToList();
|
||||||
|
private static DateTime WeekStart(DateTime value) => value.Date.AddDays(-((7 + (int)value.DayOfWeek - (int)DayOfWeek.Monday) % 7));
|
||||||
|
private sealed record Evidence(List<QuestionAttempt> Attempts, List<QuestionReviewAttempt> Reviews, List<ReviewQueueItem> Queue, List<StudySession> Sessions,
|
||||||
|
IReadOnlyDictionary<string, Question> Questions, List<DashboardSystemPerformance> Systems, int Retention, int Pending);
|
||||||
|
}
|
||||||
@@ -20,7 +20,8 @@ public sealed class UserProfileStore
|
|||||||
"ExternalIdentities", "ExternalAuthTransactions",
|
"ExternalIdentities", "ExternalAuthTransactions",
|
||||||
"FlashcardDecks", "FlashcardNotes", "FlashcardSchedules", "FlashcardReviewLogs",
|
"FlashcardDecks", "FlashcardNotes", "FlashcardSchedules", "FlashcardReviewLogs",
|
||||||
"FlashcardMediaAssets", "FlashcardSettings", "FlashcardImportPreviews",
|
"FlashcardMediaAssets", "FlashcardSettings", "FlashcardImportPreviews",
|
||||||
"UserCreditAccounts", "CreditLedgerEntries", "StudySessions", "DailyMissionSets", "WeeklyGoals"
|
"UserCreditAccounts", "CreditLedgerEntries", "StudySessions", "DailyMissionSets", "WeeklyGoals",
|
||||||
|
"UserProgressions", "ProgressionWeeklySnapshots"
|
||||||
];
|
];
|
||||||
|
|
||||||
public UserProfileStore(MongoDatabaseProvider mongo)
|
public UserProfileStore(MongoDatabaseProvider mongo)
|
||||||
|
|||||||
@@ -163,7 +163,7 @@ social notifications.
|
|||||||
### Phase 2: progression
|
### Phase 2: progression
|
||||||
|
|
||||||
1. [x] Add weekly consistency goals.
|
1. [x] Add weekly consistency goals.
|
||||||
2. Add breadth-based ranks and personal records.
|
2. [x] Add breadth-based ranks and personal records.
|
||||||
3. Expand the RZ Coin store with cosmetic and utility items.
|
3. Expand the RZ Coin store with cosmetic and utility items.
|
||||||
4. Add limited streak protection and recovery days.
|
4. Add limited streak protection and recovery days.
|
||||||
|
|
||||||
|
|||||||
@@ -553,6 +553,9 @@ func _apply_first_attempt_response(parsed: Dictionary) -> void:
|
|||||||
_apply_rating_change(rating)
|
_apply_rating_change(rating)
|
||||||
CreditsService.apply_award_response(parsed)
|
CreditsService.apply_award_response(parsed)
|
||||||
DailyMissionService.apply_response(parsed)
|
DailyMissionService.apply_response(parsed)
|
||||||
|
if typeof(parsed.get("progression")) == TYPE_DICTIONARY:
|
||||||
|
DashboardService.data["progression"] = parsed.get("progression")
|
||||||
|
DashboardService.dashboard_changed.emit()
|
||||||
# Let Tutor users inspect the final answer before the summary replaces the player.
|
# Let Tutor users inspect the final answer before the summary replaces the player.
|
||||||
StudySessionService.apply_response(parsed, true)
|
StudySessionService.apply_response(parsed, true)
|
||||||
choice_percentages_updated.emit(question_id, percentages)
|
choice_percentages_updated.emit(question_id, percentages)
|
||||||
@@ -560,6 +563,9 @@ func _apply_first_attempt_response(parsed: Dictionary) -> void:
|
|||||||
|
|
||||||
|
|
||||||
func _apply_skip_response(parsed: Dictionary) -> void:
|
func _apply_skip_response(parsed: Dictionary) -> void:
|
||||||
|
if typeof(parsed.get("progression")) == TYPE_DICTIONARY:
|
||||||
|
DashboardService.data["progression"] = parsed.get("progression")
|
||||||
|
DashboardService.dashboard_changed.emit()
|
||||||
var question_id := str(parsed.get("questionExternalId", parsed.get("question_external_id", "")))
|
var question_id := str(parsed.get("questionExternalId", parsed.get("question_external_id", "")))
|
||||||
var correct_choice_id := str(parsed.get("correctChoiceId", parsed.get("correct_choice_id", "")))
|
var correct_choice_id := str(parsed.get("correctChoiceId", parsed.get("correct_choice_id", "")))
|
||||||
var percentages: Dictionary = parsed.get("choiceSelectionPercentages", parsed.get("choice_selection_percentages", {}))
|
var percentages: Dictionary = parsed.get("choiceSelectionPercentages", parsed.get("choice_selection_percentages", {}))
|
||||||
|
|||||||
@@ -199,6 +199,9 @@ func _on_attempt_completed(result: int, response_code: int, _headers_value: Pack
|
|||||||
if typeof(parsed) == TYPE_DICTIONARY:
|
if typeof(parsed) == TYPE_DICTIONARY:
|
||||||
CreditsService.apply_award_response(parsed)
|
CreditsService.apply_award_response(parsed)
|
||||||
DailyMissionService.apply_response(parsed)
|
DailyMissionService.apply_response(parsed)
|
||||||
|
if typeof(parsed.get("progression")) == TYPE_DICTIONARY:
|
||||||
|
DashboardService.data["progression"] = parsed.get("progression")
|
||||||
|
DashboardService.dashboard_changed.emit()
|
||||||
StudySessionService.apply_response(parsed, true)
|
StudySessionService.apply_response(parsed, true)
|
||||||
pending_count = int(parsed.get("pendingCount", parsed.get("pending_count", pending_count)))
|
pending_count = int(parsed.get("pendingCount", parsed.get("pending_count", pending_count)))
|
||||||
synced_question_id = str(parsed.get("questionExternalId", parsed.get("question_external_id", "")))
|
synced_question_id = str(parsed.get("questionExternalId", parsed.get("question_external_id", "")))
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ var recent_list: VBoxContainer
|
|||||||
var weekly_summary: VBoxContainer
|
var weekly_summary: VBoxContainer
|
||||||
var mission_content: VBoxContainer
|
var mission_content: VBoxContainer
|
||||||
var weekly_goal_content: VBoxContainer
|
var weekly_goal_content: VBoxContainer
|
||||||
|
var rank_content: VBoxContainer
|
||||||
var metric_values: Dictionary = {}
|
var metric_values: Dictionary = {}
|
||||||
var metric_details: Dictionary = {}
|
var metric_details: Dictionary = {}
|
||||||
var metric_trends: Dictionary = {}
|
var metric_trends: Dictionary = {}
|
||||||
@@ -165,6 +166,9 @@ func _build() -> void:
|
|||||||
right_column.add_theme_constant_override("separation", 12)
|
right_column.add_theme_constant_override("separation", 12)
|
||||||
right_column.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
right_column.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
analytics_grid.add_child(right_column)
|
analytics_grid.add_child(right_column)
|
||||||
|
rank_content = VBoxContainer.new()
|
||||||
|
rank_content.add_theme_constant_override("separation", 9)
|
||||||
|
right_column.add_child(_card(rank_content, 16))
|
||||||
focus_content = VBoxContainer.new()
|
focus_content = VBoxContainer.new()
|
||||||
focus_content.add_theme_constant_override("separation", 10)
|
focus_content.add_theme_constant_override("separation", 10)
|
||||||
right_column.add_child(_card(focus_content, 16))
|
right_column.add_child(_card(focus_content, 16))
|
||||||
@@ -224,6 +228,7 @@ func _render() -> void:
|
|||||||
_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)
|
||||||
|
_render_rank(data.get("progression", {}))
|
||||||
var weekly: Array = data.get("weeklyActivity", [])
|
var weekly: Array = data.get("weeklyActivity", [])
|
||||||
activity_chart.set_days(weekly, UIThemeService.is_dark_mode())
|
activity_chart.set_days(weekly, UIThemeService.is_dark_mode())
|
||||||
_render_weekly(data, weekly)
|
_render_weekly(data, weekly)
|
||||||
@@ -232,6 +237,40 @@ func _render() -> void:
|
|||||||
_render_weekly_goal()
|
_render_weekly_goal()
|
||||||
|
|
||||||
|
|
||||||
|
func _render_rank(value: Variant) -> void:
|
||||||
|
if rank_content == null:
|
||||||
|
return
|
||||||
|
_clear(rank_content)
|
||||||
|
rank_content.add_child(_section_title("Clinical progression"))
|
||||||
|
var progression: Dictionary = value if typeof(value) == TYPE_DICTIONARY else {}
|
||||||
|
if progression.is_empty():
|
||||||
|
rank_content.add_child(_label("Loading rank progress...", 13, Brand.muted_color(UIThemeService.is_dark_mode())))
|
||||||
|
return
|
||||||
|
var title := _label(str(progression.get("displayName", "Case Scout III")), 23, Brand.PURPLE, true)
|
||||||
|
rank_content.add_child(title)
|
||||||
|
var next_name = progression.get("nextRankName")
|
||||||
|
if next_name == null:
|
||||||
|
rank_content.add_child(_label("Highest division earned", 12, Brand.SUCCESS, true))
|
||||||
|
else:
|
||||||
|
rank_content.add_child(_label("Next: %s %s" % [str(next_name), str(progression.get("nextDivision", ""))], 12, Brand.muted_color(UIThemeService.is_dark_mode())))
|
||||||
|
var requirements: Array = progression.get("nextRequirements", [])
|
||||||
|
for requirement_value in requirements.slice(0, mini(3, requirements.size())):
|
||||||
|
var requirement: Dictionary = requirement_value
|
||||||
|
var row := HBoxContainer.new()
|
||||||
|
var label := _label(str(requirement.get("label", "Progress")), 11, Brand.text_color(UIThemeService.is_dark_mode()))
|
||||||
|
label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
|
row.add_child(label)
|
||||||
|
row.add_child(_label("%d / %d" % [int(requirement.get("current", 0)), int(requirement.get("required", 0))], 11,
|
||||||
|
Brand.SUCCESS if bool(requirement.get("complete", false)) else Brand.ROYAL_BLUE, true))
|
||||||
|
rank_content.add_child(row)
|
||||||
|
var view := Button.new()
|
||||||
|
view.text = "View ladder and records"
|
||||||
|
view.flat = true
|
||||||
|
view.alignment = HORIZONTAL_ALIGNMENT_LEFT
|
||||||
|
view.pressed.connect(func(): main.show_screen("profile"))
|
||||||
|
rank_content.add_child(view)
|
||||||
|
|
||||||
|
|
||||||
func _render_missions() -> void:
|
func _render_missions() -> void:
|
||||||
if mission_content == null:
|
if mission_content == null:
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ var outcome_delete: Button
|
|||||||
var connected_accounts_list: VBoxContainer
|
var connected_accounts_list: VBoxContainer
|
||||||
var return_screen := ""
|
var return_screen := ""
|
||||||
var back_to_session_button: Button
|
var back_to_session_button: Button
|
||||||
|
var progression_content: VBoxContainer
|
||||||
|
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
@@ -95,6 +96,7 @@ func _build() -> void:
|
|||||||
right.add_theme_constant_override("separation", 14)
|
right.add_theme_constant_override("separation", 14)
|
||||||
columns.add_child(right)
|
columns.add_child(right)
|
||||||
right.add_child(_outcome_card())
|
right.add_child(_outcome_card())
|
||||||
|
right.add_child(_progression_card())
|
||||||
right.add_child(_connected_accounts_card())
|
right.add_child(_connected_accounts_card())
|
||||||
right.add_child(_account_actions_card())
|
right.add_child(_account_actions_card())
|
||||||
_refresh_back_to_session_visibility()
|
_refresh_back_to_session_visibility()
|
||||||
@@ -227,6 +229,13 @@ func _outcome_card() -> PanelContainer:
|
|||||||
return _card(body)
|
return _card(body)
|
||||||
|
|
||||||
|
|
||||||
|
func _progression_card() -> PanelContainer:
|
||||||
|
progression_content = VBoxContainer.new()
|
||||||
|
progression_content.add_theme_constant_override("separation", 10)
|
||||||
|
progression_content.add_child(_section_header("Clinical Progression", "Breadth, challenge, and retention"))
|
||||||
|
return _card(progression_content)
|
||||||
|
|
||||||
|
|
||||||
func _account_actions_card() -> PanelContainer:
|
func _account_actions_card() -> PanelContainer:
|
||||||
var body := VBoxContainer.new()
|
var body := VBoxContainer.new()
|
||||||
body.add_theme_constant_override("separation", 9)
|
body.add_theme_constant_override("separation", 9)
|
||||||
@@ -279,9 +288,73 @@ func _render() -> void:
|
|||||||
_set_metric("cases", str(int(data.get("ccsCasesCompleted", 0))))
|
_set_metric("cases", str(int(data.get("ccsCasesCompleted", 0))))
|
||||||
_set_metric("streak", "%d days" % int(data.get("currentStreak", 0)))
|
_set_metric("streak", "%d days" % int(data.get("currentStreak", 0)))
|
||||||
_render_systems(data.get("systems", []))
|
_render_systems(data.get("systems", []))
|
||||||
|
_render_progression(data.get("progression", {}))
|
||||||
_render_outcome()
|
_render_outcome()
|
||||||
|
|
||||||
|
|
||||||
|
func _render_progression(value: Variant) -> void:
|
||||||
|
if progression_content == null:
|
||||||
|
return
|
||||||
|
_clear(progression_content)
|
||||||
|
progression_content.add_child(_section_header("Clinical Progression", "Breadth, challenge, and retention"))
|
||||||
|
var progression: Dictionary = value if typeof(value) == TYPE_DICTIONARY else {}
|
||||||
|
if progression.is_empty():
|
||||||
|
progression_content.add_child(_label("Loading progression...", 13, Brand.muted_color(_dark())))
|
||||||
|
return
|
||||||
|
progression_content.add_child(_label(str(progression.get("displayName", "Case Scout III")), 24, Brand.PURPLE, true))
|
||||||
|
var badges: Array = progression.get("earnedBadges", [])
|
||||||
|
progression_content.add_child(_label("Earned: %s" % ", ".join(PackedStringArray(badges)), 12, Brand.muted_color(_dark())))
|
||||||
|
var ladder := GridContainer.new()
|
||||||
|
ladder.columns = 3
|
||||||
|
ladder.add_theme_constant_override("h_separation", 7)
|
||||||
|
ladder.add_theme_constant_override("v_separation", 7)
|
||||||
|
var rank_names := ["Case Scout", "Pattern Hunter", "Diagnostic Tactician", "Management Strategist", "Clinical Vanguard"]
|
||||||
|
var divisions := ["III", "II", "I"]
|
||||||
|
var earned_ordinal := int(progression.get("rankOrdinal", 0))
|
||||||
|
var earned_dates: Dictionary = progression.get("earnedRanksUtc", {})
|
||||||
|
var ladder_data: Array = progression.get("ladder", [])
|
||||||
|
for ordinal in 15:
|
||||||
|
var earned := ordinal <= earned_ordinal
|
||||||
|
var caption := "%s %s" % [rank_names[floori(float(ordinal) / 3.0)], divisions[ordinal % 3]]
|
||||||
|
var date := str(earned_dates.get(str(ordinal), "")).left(10)
|
||||||
|
if earned and not date.is_empty():
|
||||||
|
caption += "\n%s" % date
|
||||||
|
var cell := _label(caption, 10, Brand.SUCCESS if earned else Brand.muted_color(_dark()), earned)
|
||||||
|
cell.custom_minimum_size = Vector2(105, 38)
|
||||||
|
cell.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||||
|
cell.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||||||
|
if ordinal < ladder_data.size() and typeof(ladder_data[ordinal]) == TYPE_DICTIONARY:
|
||||||
|
cell.tooltip_text = str((ladder_data[ordinal] as Dictionary).get("requirementSummary", ""))
|
||||||
|
ladder.add_child(cell)
|
||||||
|
progression_content.add_child(ladder)
|
||||||
|
var next_name = progression.get("nextRankName")
|
||||||
|
if next_name != null:
|
||||||
|
progression_content.add_child(_label("Next: %s %s" % [str(next_name), str(progression.get("nextDivision", ""))], 14, Brand.text_color(_dark()), true))
|
||||||
|
for requirement_value in progression.get("nextRequirements", []):
|
||||||
|
var requirement: Dictionary = requirement_value
|
||||||
|
var row := HBoxContainer.new()
|
||||||
|
var text := _label(str(requirement.get("label", "Requirement")), 11, Brand.text_color(_dark()))
|
||||||
|
text.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
|
row.add_child(text)
|
||||||
|
row.add_child(_label("%d / %d" % [int(requirement.get("current", 0)), int(requirement.get("required", 0))], 11,
|
||||||
|
Brand.SUCCESS if bool(requirement.get("complete", false)) else Brand.ROYAL_BLUE, true))
|
||||||
|
progression_content.add_child(row)
|
||||||
|
progression_content.add_child(HSeparator.new())
|
||||||
|
progression_content.add_child(_label("Personal records", 15, Brand.text_color(_dark()), true))
|
||||||
|
for record_value in progression.get("records", []):
|
||||||
|
var record: Dictionary = record_value
|
||||||
|
var suffix := "%d%%" % int(record.get("value", 0)) if str(record.get("key", "")) == "timed_block" else str(int(record.get("value", 0)))
|
||||||
|
var title := _label("%s %s" % [str(record.get("label", "Record")), suffix], 12, Brand.text_color(_dark()), true)
|
||||||
|
title.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||||
|
progression_content.add_child(title)
|
||||||
|
var definition := str(record.get("definition", ""))
|
||||||
|
if bool(record.get("trackingFromLaunch", false)):
|
||||||
|
definition += " Tracking since %s." % str(progression.get("trackingStartedUtc", "")).left(10)
|
||||||
|
var detail := _label(definition, 10, Brand.muted_color(_dark()))
|
||||||
|
detail.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||||
|
progression_content.add_child(detail)
|
||||||
|
|
||||||
|
|
||||||
func _render_connected_accounts(provider_values: Variant, available_values: Variant) -> void:
|
func _render_connected_accounts(provider_values: Variant, available_values: Variant) -> void:
|
||||||
if connected_accounts_list == null:
|
if connected_accounts_list == null:
|
||||||
return
|
return
|
||||||
|
|||||||
Reference in New Issue
Block a user