Add clinical progression ranks and records

This commit is contained in:
hz
2026-08-01 15:17:42 -05:00
parent bc9f68aaf2
commit 8c5aa17d89
21 changed files with 631 additions and 10 deletions
@@ -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
}); });
} }
+1
View File
@@ -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
+1
View File
@@ -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)
+1 -1
View File
@@ -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", {}))
+3
View File
@@ -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", "")))
+39
View File
@@ -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
+73
View File
@@ -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