Complete CCS results and rating flow
This commit is contained in:
@@ -45,9 +45,9 @@ public sealed class CcsCaseTests
|
||||
var definition = ValidImport().Cases[0];
|
||||
definition.ScoringCriteria =
|
||||
[
|
||||
new() { Id = "lab", ActionType = "order", TargetCode = "bmp", Points = 5, LatestMinute = 10, Rationale = "Check potassium." },
|
||||
new() { Id = "insulin", ActionType = "order", TargetCode = "insulin", AfterTargetCode = "bmp", Points = 10, Rationale = "Start after potassium assessment." },
|
||||
new() { Id = "bicarb", ActionType = "order", TargetCode = "bicarb", Points = -4, Rationale = "Avoid routine bicarbonate." }
|
||||
new() { Id = "lab", Title = "Check electrolytes", ActionType = "order", TargetCode = "bmp", Points = 5, LatestMinute = 10, Rationale = "Check potassium." },
|
||||
new() { Id = "insulin", Title = "Start insulin safely", ActionType = "order", TargetCode = "insulin", AfterTargetCode = "bmp", Points = 10, Rationale = "Start after potassium assessment." },
|
||||
new() { Id = "bicarb", Title = "Avoid bicarbonate", ActionType = "order", TargetCode = "bicarb", Points = -4, Rationale = "Avoid routine bicarbonate." }
|
||||
];
|
||||
var attempt = new CcsAttempt { Actions =
|
||||
[
|
||||
@@ -70,6 +70,31 @@ public sealed class CcsCaseTests
|
||||
Assert.Empty(CcsCaseValidator.ValidateImport(ValidImport(), new HashSet<string>()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CcsExpectedScoreUsesSeparateUserAndCaseElo()
|
||||
{
|
||||
Assert.Equal(0.5, CcsRatingStore.ExpectedScore(1000, 1000), 6);
|
||||
Assert.True(CcsRatingStore.ExpectedScore(1200, 1000) > 0.75);
|
||||
Assert.True(CcsRatingStore.ExpectedScore(800, 1000) < 0.25);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScoringReviewIncludesExpectedAndObservedEvidence()
|
||||
{
|
||||
var definition = ValidImport().Cases[0];
|
||||
var attempt = new CcsAttempt
|
||||
{
|
||||
Actions = [new() { Type = "order", Code = "bmp", SimulatedMinute = 4 }]
|
||||
};
|
||||
|
||||
var criterion = CcsStore.CalculateScore(attempt, definition).Criteria.Single();
|
||||
|
||||
Assert.Equal("Check electrolytes", criterion.Title);
|
||||
Assert.Contains("Bmp", criterion.ExpectedAction, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Equal("bmp", criterion.ObservedAction);
|
||||
Assert.Equal(4, criterion.ObservedMinute);
|
||||
}
|
||||
|
||||
private static CcsImportRequest ValidImport() => new()
|
||||
{
|
||||
Orders =
|
||||
@@ -87,7 +112,7 @@ public sealed class CcsCaseTests
|
||||
IsPublished = true, RealTimeMinutes = 20, Difficulty = 2,
|
||||
InitialChart = new() { PatientSummary = "Adult with diabetes", ReasonForEncounter = "Vomiting" },
|
||||
OrderResponses = [new() { OrderCode = "bmp", Result = "Potassium 4.9 mEq/L" }],
|
||||
ScoringCriteria = [new() { Id = "lab", ActionType = "order", TargetCode = "bmp", Points = 5 }],
|
||||
ScoringCriteria = [new() { Id = "lab", Title = "Check electrolytes", ActionType = "order", TargetCode = "bmp", Points = 5 }],
|
||||
References = [new() { Title = "Consensus report", Publisher = "Example Society", Url = "https://example.org/consensus", Supports = "Case management and scoring", AccessedOn = "2026-07-15", Reviewer = "Question bank editorial review" }]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -9,7 +9,7 @@ namespace QuestionsApi.Controllers;
|
||||
[ApiController]
|
||||
[Route("api/ccs")]
|
||||
[Authorize(Roles = "USER,SUPER")]
|
||||
public sealed class CcsController(CcsStore store) : ControllerBase
|
||||
public sealed class CcsController(CcsStore store, CcsRatingStore ratings) : ControllerBase
|
||||
{
|
||||
[HttpGet("cases")]
|
||||
public async Task<IActionResult> Cases() => Ok(await store.ListCasesAsync());
|
||||
@@ -59,6 +59,9 @@ public sealed class CcsController(CcsStore store) : ControllerBase
|
||||
[HttpGet("prediction")]
|
||||
public async Task<IActionResult> Prediction() => Ok(await store.PredictionAsync(UserId()));
|
||||
|
||||
[HttpGet("ratings/me")]
|
||||
public async Task<IActionResult> Rating() => Ok(await ratings.GetProfileAsync(UserId()));
|
||||
|
||||
[HttpPost("admin/import")]
|
||||
[Authorize(Roles = "SUPER")]
|
||||
public async Task<IActionResult> Import(CcsImportRequest request)
|
||||
|
||||
@@ -92,6 +92,7 @@ public class CcsCaseEvent
|
||||
public class CcsScoringCriterion
|
||||
{
|
||||
public string Id { get; set; } = "";
|
||||
public string Title { get; set; } = "";
|
||||
public string ActionType { get; set; } = "order";
|
||||
public string TargetCode { get; set; } = "";
|
||||
public List<string> TargetCodes { get; set; } = [];
|
||||
@@ -127,6 +128,8 @@ public class CcsAttempt
|
||||
public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow;
|
||||
public DateTime? CompletedUtc { get; set; }
|
||||
public CcsCaseScore? Score { get; set; }
|
||||
public bool RatingEligible { get; set; }
|
||||
public CcsRatingChange? Rating { get; set; }
|
||||
}
|
||||
|
||||
public class CcsAttemptAction
|
||||
@@ -163,6 +166,11 @@ public class CcsCaseScore
|
||||
public class CcsCriterionResult
|
||||
{
|
||||
public string CriterionId { get; set; } = "";
|
||||
public string Title { get; set; } = "";
|
||||
public string ActionType { get; set; } = "";
|
||||
public string ExpectedAction { get; set; } = "";
|
||||
public string ObservedAction { get; set; } = "";
|
||||
public int? ObservedMinute { get; set; }
|
||||
public string Status { get; set; } = "missed";
|
||||
public int Points { get; set; }
|
||||
public string Rationale { get; set; } = "";
|
||||
@@ -231,6 +239,7 @@ public class CcsAttemptResponse
|
||||
public List<CcsExamDefinition> AvailableExams { get; set; } = [];
|
||||
public List<string> AllowedLocations { get; set; } = [];
|
||||
public CcsCaseScore? Score { get; set; }
|
||||
public CcsRatingChange? Rating { get; set; }
|
||||
public List<QuestionReference> References { get; set; } = [];
|
||||
}
|
||||
|
||||
@@ -242,9 +251,57 @@ public class CcsHistoryItem
|
||||
public string Mode { get; set; } = "";
|
||||
public string State { get; set; } = "";
|
||||
public double? Score { get; set; }
|
||||
public CcsRatingChange? Rating { get; set; }
|
||||
public DateTime CreatedUtc { get; set; }
|
||||
}
|
||||
|
||||
public class CcsUserRating
|
||||
{
|
||||
[BsonId, BsonRepresentation(BsonType.ObjectId)] public string? Id { get; set; }
|
||||
public string UserId { get; set; } = "";
|
||||
public int Elo { get; set; } = 1000;
|
||||
public int RatedCaseCount { get; set; }
|
||||
public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public class CcsCaseRating
|
||||
{
|
||||
[BsonId, BsonRepresentation(BsonType.ObjectId)] public string? Id { get; set; }
|
||||
public string CaseExternalId { get; set; } = "";
|
||||
public int Elo { get; set; } = 1000;
|
||||
public int RatedAttemptCount { get; set; }
|
||||
public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public class CcsRatedCaseResult
|
||||
{
|
||||
[BsonId, BsonRepresentation(BsonType.ObjectId)] public string? Id { get; set; }
|
||||
public string UserId { get; set; } = "";
|
||||
public string CaseExternalId { get; set; } = "";
|
||||
[BsonRepresentation(BsonType.ObjectId)] public string AttemptId { get; set; } = "";
|
||||
public DateTime CreatedUtc { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public class CcsRatingChange
|
||||
{
|
||||
public bool Rated { get; set; }
|
||||
public string Reason { get; set; } = "";
|
||||
public int UserEloBefore { get; set; }
|
||||
public int UserElo { get; set; }
|
||||
public int UserEloDelta { get; set; }
|
||||
public int CaseEloBefore { get; set; }
|
||||
public int CaseElo { get; set; }
|
||||
public int CaseEloDelta { get; set; }
|
||||
public double ActualScore { get; set; }
|
||||
public DateTime? AppliedUtc { get; set; }
|
||||
}
|
||||
|
||||
public class CcsRatingProfileResponse
|
||||
{
|
||||
public int Elo { get; set; } = 1000;
|
||||
public int RatedCaseCount { get; set; }
|
||||
}
|
||||
|
||||
public class CcsPredictionResponse
|
||||
{
|
||||
public double? CcsScore { get; set; }
|
||||
|
||||
@@ -18,6 +18,7 @@ builder.Services.AddSingleton<QuestionMediaStore>();
|
||||
builder.Services.AddSingleton<QuestionReportStore>();
|
||||
builder.Services.AddSingleton<ExamSimulationStore>();
|
||||
builder.Services.AddSingleton<CcsStore>();
|
||||
builder.Services.AddSingleton<CcsRatingStore>();
|
||||
builder.Services.AddSingleton<CcsCaseReportStore>();
|
||||
builder.Services.AddSingleton<ServiceMetrics>();
|
||||
builder.Services.AddHostedService<AnswerStatisticsMaintenanceService>();
|
||||
|
||||
@@ -58,6 +58,7 @@ public static class CcsCaseValidator
|
||||
errors.Add(prefix + "scoring criterion ids must be present and unique");
|
||||
foreach (var criterion in item.ScoringCriteria)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(criterion.Title)) errors.Add(prefix + $"criterion {criterion.Id} requires a title");
|
||||
var targets = criterion.TargetCodes.Count > 0 ? criterion.TargetCodes : [criterion.TargetCode];
|
||||
if (criterion.Points == 0) errors.Add(prefix + $"criterion {criterion.Id} points cannot be zero");
|
||||
if (targets.Any(string.IsNullOrWhiteSpace)) errors.Add(prefix + $"criterion {criterion.Id} requires a target code");
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
using MongoDB.Driver;
|
||||
using QuestionsApi.Models;
|
||||
|
||||
namespace QuestionsApi.Services;
|
||||
|
||||
public sealed class CcsRatingStore
|
||||
{
|
||||
private const int BaseElo = 1000;
|
||||
private const int MinimumElo = 100;
|
||||
private readonly IMongoCollection<CcsUserRating> _users;
|
||||
private readonly IMongoCollection<CcsCaseRating> _cases;
|
||||
private readonly IMongoCollection<CcsRatedCaseResult> _results;
|
||||
|
||||
public CcsRatingStore(MongoDatabaseProvider mongo)
|
||||
{
|
||||
_users = mongo.Database.GetCollection<CcsUserRating>("CcsUserRatings");
|
||||
_cases = mongo.Database.GetCollection<CcsCaseRating>("CcsCaseRatings");
|
||||
_results = mongo.Database.GetCollection<CcsRatedCaseResult>("CcsRatedCaseResults");
|
||||
_users.Indexes.CreateOne(new CreateIndexModel<CcsUserRating>(
|
||||
Builders<CcsUserRating>.IndexKeys.Ascending(value => value.UserId), new CreateIndexOptions { Unique = true }));
|
||||
_cases.Indexes.CreateOne(new CreateIndexModel<CcsCaseRating>(
|
||||
Builders<CcsCaseRating>.IndexKeys.Ascending(value => value.CaseExternalId), new CreateIndexOptions { Unique = true }));
|
||||
_results.Indexes.CreateOne(new CreateIndexModel<CcsRatedCaseResult>(
|
||||
Builders<CcsRatedCaseResult>.IndexKeys.Ascending(value => value.UserId).Ascending(value => value.CaseExternalId),
|
||||
new CreateIndexOptions { Unique = true }));
|
||||
}
|
||||
|
||||
public async Task<CcsRatingProfileResponse> GetProfileAsync(string userId)
|
||||
{
|
||||
var rating = await GetOrCreateUserAsync(userId);
|
||||
return new CcsRatingProfileResponse { Elo = rating.Elo, RatedCaseCount = rating.RatedCaseCount };
|
||||
}
|
||||
|
||||
public async Task<CcsRatingChange> ApplyAsync(CcsAttempt attempt)
|
||||
{
|
||||
var actual = Math.Clamp((attempt.Score?.Percent ?? 0) / 100.0, 0, 1);
|
||||
if (!attempt.RatingEligible || attempt.Mode != CcsAttemptModes.Timed)
|
||||
return Unrated("not_eligible", actual);
|
||||
|
||||
try
|
||||
{
|
||||
await _results.InsertOneAsync(new CcsRatedCaseResult
|
||||
{
|
||||
UserId = attempt.UserId,
|
||||
CaseExternalId = attempt.CaseExternalId,
|
||||
AttemptId = attempt.Id ?? ""
|
||||
});
|
||||
}
|
||||
catch (MongoWriteException exception) when (exception.WriteError?.Category == ServerErrorCategory.DuplicateKey)
|
||||
{
|
||||
return Unrated("repeat_case", actual);
|
||||
}
|
||||
|
||||
var user = await GetOrCreateUserAsync(attempt.UserId);
|
||||
var caseRating = await GetOrCreateCaseAsync(attempt.CaseExternalId);
|
||||
var expected = ExpectedScore(user.Elo, caseRating.Elo);
|
||||
var userDelta = (int)Math.Round(UserK(user.RatedCaseCount) * (actual - expected), MidpointRounding.AwayFromZero);
|
||||
var caseDelta = (int)Math.Round(CaseK(caseRating.RatedAttemptCount) * (expected - actual), MidpointRounding.AwayFromZero);
|
||||
var updatedUser = await UpdateUserAsync(user, userDelta);
|
||||
var updatedCase = await UpdateCaseAsync(caseRating, caseDelta);
|
||||
return new CcsRatingChange
|
||||
{
|
||||
Rated = true,
|
||||
Reason = "first_timed_attempt",
|
||||
UserEloBefore = user.Elo,
|
||||
UserElo = updatedUser.Elo,
|
||||
UserEloDelta = updatedUser.Elo - user.Elo,
|
||||
CaseEloBefore = caseRating.Elo,
|
||||
CaseElo = updatedCase.Elo,
|
||||
CaseEloDelta = updatedCase.Elo - caseRating.Elo,
|
||||
ActualScore = actual,
|
||||
AppliedUtc = DateTime.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
public static double ExpectedScore(int userElo, int caseElo) =>
|
||||
1.0 / (1.0 + Math.Pow(10.0, (caseElo - userElo) / 400.0));
|
||||
|
||||
private static int UserK(int count) => count < 20 ? 40 : 24;
|
||||
private static int CaseK(int count) => count < 20 ? 32 : 24;
|
||||
|
||||
private static CcsRatingChange Unrated(string reason, double actual) => new()
|
||||
{
|
||||
Rated = false, Reason = reason, ActualScore = actual
|
||||
};
|
||||
|
||||
private async Task<CcsUserRating> GetOrCreateUserAsync(string userId)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _users.UpdateOneAsync(value => value.UserId == userId,
|
||||
Builders<CcsUserRating>.Update
|
||||
.SetOnInsert(value => value.UserId, userId)
|
||||
.SetOnInsert(value => value.Elo, BaseElo)
|
||||
.SetOnInsert(value => value.RatedCaseCount, 0)
|
||||
.SetOnInsert(value => value.UpdatedUtc, DateTime.UtcNow),
|
||||
new UpdateOptions { IsUpsert = true });
|
||||
}
|
||||
catch (MongoWriteException exception) when (exception.WriteError?.Category == ServerErrorCategory.DuplicateKey)
|
||||
{
|
||||
// Another API replica created the initial rating first.
|
||||
}
|
||||
return await _users.Find(value => value.UserId == userId).FirstAsync();
|
||||
}
|
||||
|
||||
private async Task<CcsCaseRating> GetOrCreateCaseAsync(string externalId)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _cases.UpdateOneAsync(value => value.CaseExternalId == externalId,
|
||||
Builders<CcsCaseRating>.Update
|
||||
.SetOnInsert(value => value.CaseExternalId, externalId)
|
||||
.SetOnInsert(value => value.Elo, BaseElo)
|
||||
.SetOnInsert(value => value.RatedAttemptCount, 0)
|
||||
.SetOnInsert(value => value.UpdatedUtc, DateTime.UtcNow),
|
||||
new UpdateOptions { IsUpsert = true });
|
||||
}
|
||||
catch (MongoWriteException exception) when (exception.WriteError?.Category == ServerErrorCategory.DuplicateKey)
|
||||
{
|
||||
// Another API replica created the initial rating first.
|
||||
}
|
||||
return await _cases.Find(value => value.CaseExternalId == externalId).FirstAsync();
|
||||
}
|
||||
|
||||
private async Task<CcsUserRating> UpdateUserAsync(CcsUserRating current, int delta)
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
var nextElo = Math.Max(MinimumElo, current.Elo + delta);
|
||||
var updated = await _users.FindOneAndUpdateAsync(
|
||||
value => value.UserId == current.UserId && value.Elo == current.Elo && value.RatedCaseCount == current.RatedCaseCount,
|
||||
Builders<CcsUserRating>.Update.Set(value => value.Elo, nextElo)
|
||||
.Inc(value => value.RatedCaseCount, 1).Set(value => value.UpdatedUtc, DateTime.UtcNow),
|
||||
new FindOneAndUpdateOptions<CcsUserRating> { ReturnDocument = ReturnDocument.After });
|
||||
if (updated is not null) return updated;
|
||||
current = await _users.Find(value => value.UserId == current.UserId).FirstAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<CcsCaseRating> UpdateCaseAsync(CcsCaseRating current, int delta)
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
var nextElo = Math.Max(MinimumElo, current.Elo + delta);
|
||||
var updated = await _cases.FindOneAndUpdateAsync(
|
||||
value => value.CaseExternalId == current.CaseExternalId && value.Elo == current.Elo && value.RatedAttemptCount == current.RatedAttemptCount,
|
||||
Builders<CcsCaseRating>.Update.Set(value => value.Elo, nextElo)
|
||||
.Inc(value => value.RatedAttemptCount, 1).Set(value => value.UpdatedUtc, DateTime.UtcNow),
|
||||
new FindOneAndUpdateOptions<CcsCaseRating> { ReturnDocument = ReturnDocument.After });
|
||||
if (updated is not null) return updated;
|
||||
current = await _cases.Find(value => value.CaseExternalId == current.CaseExternalId).FirstAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,13 +9,15 @@ public sealed class CcsStore
|
||||
private readonly IMongoCollection<CcsOrderDefinition> _orders;
|
||||
private readonly IMongoCollection<CcsAttempt> _attempts;
|
||||
private readonly ExamSimulationStore _examSimulations;
|
||||
private readonly CcsRatingStore _ratings;
|
||||
|
||||
public CcsStore(MongoDatabaseProvider mongo, ExamSimulationStore examSimulations)
|
||||
public CcsStore(MongoDatabaseProvider mongo, ExamSimulationStore examSimulations, CcsRatingStore ratings)
|
||||
{
|
||||
_cases = mongo.Database.GetCollection<CcsCaseRevision>("CcsCaseRevisions");
|
||||
_orders = mongo.Database.GetCollection<CcsOrderDefinition>("CcsOrderCatalog");
|
||||
_attempts = mongo.Database.GetCollection<CcsAttempt>("CcsAttempts");
|
||||
_examSimulations = examSimulations;
|
||||
_ratings = ratings;
|
||||
_cases.Indexes.CreateOne(new CreateIndexModel<CcsCaseRevision>(
|
||||
Builders<CcsCaseRevision>.IndexKeys.Ascending(value => value.ExternalId).Ascending(value => value.Version),
|
||||
new CreateIndexOptions { Unique = true }));
|
||||
@@ -108,6 +110,7 @@ public sealed class CcsStore
|
||||
CurrentVitals = new Dictionary<string, string>(definition.InitialChart.Vitals),
|
||||
ActiveOrderCodes = definition.InitialChart.InitialOrderCodes.Distinct(StringComparer.OrdinalIgnoreCase).ToList(),
|
||||
DeadlineUtc = request.Mode == CcsAttemptModes.Timed ? now.AddMinutes(definition.RealTimeMinutes) : null,
|
||||
RatingEligible = request.Mode == CcsAttemptModes.Timed,
|
||||
Chart =
|
||||
[
|
||||
new CcsChartEntry { Category = "progress", Text = definition.InitialChart.ReasonForEncounter },
|
||||
@@ -181,7 +184,7 @@ public sealed class CcsStore
|
||||
{
|
||||
Id = value.Id ?? "", CaseExternalId = value.CaseExternalId,
|
||||
CaseTitle = definitions.GetValueOrDefault(value.CaseRevisionId)?.Title ?? value.CaseExternalId,
|
||||
Mode = value.Mode, State = value.State, Score = value.Score?.Percent, CreatedUtc = value.CreatedUtc
|
||||
Mode = value.Mode, State = value.State, Score = value.Score?.Percent, Rating = value.Rating, CreatedUtc = value.CreatedUtc
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
@@ -227,7 +230,8 @@ public sealed class CcsStore
|
||||
PendingResults = attempt.PendingResults.Select(value => new CcsPendingResult { OrderCode = value.OrderCode, DueMinute = value.DueMinute }).ToList(),
|
||||
AvailableExams = attempt.State == CcsAttemptStates.Active ? definition.Exams : [],
|
||||
AllowedLocations = attempt.State == CcsAttemptStates.Active ? definition.AllowedLocations : [],
|
||||
Score = completed ? attempt.Score : null, References = completed ? definition.References : []
|
||||
Score = completed ? attempt.Score : null, Rating = completed ? attempt.Rating : null,
|
||||
References = completed ? definition.References : []
|
||||
};
|
||||
}
|
||||
|
||||
@@ -341,6 +345,7 @@ public sealed class CcsStore
|
||||
attempt.State = CcsAttemptStates.Completed;
|
||||
attempt.CompletedUtc = DateTime.UtcNow;
|
||||
attempt.Score = CalculateScore(attempt, definition);
|
||||
attempt.Rating = await _ratings.ApplyAsync(attempt);
|
||||
attempt.UpdatedUtc = DateTime.UtcNow;
|
||||
await SaveAsync(attempt);
|
||||
}
|
||||
@@ -367,6 +372,11 @@ public sealed class CcsStore
|
||||
results.Add(new CcsCriterionResult
|
||||
{
|
||||
CriterionId = criterion.Id,
|
||||
Title = string.IsNullOrWhiteSpace(criterion.Title) ? Humanize(criterion.Id) : criterion.Title,
|
||||
ActionType = criterion.ActionType,
|
||||
ExpectedAction = DescribeExpected(criterion, targets),
|
||||
ObservedAction = action?.Code ?? "Not performed",
|
||||
ObservedMinute = action?.SimulatedMinute,
|
||||
Status = met ? criterion.Points < 0 ? "harmful" : "credited" : criterion.Points > 0 ? "missed" : "avoided",
|
||||
Points = awarded, Rationale = criterion.Rationale
|
||||
});
|
||||
@@ -384,6 +394,16 @@ public sealed class CcsStore
|
||||
|
||||
private Task SaveAsync(CcsAttempt attempt) => _attempts.ReplaceOneAsync(value => value.Id == attempt.Id, attempt);
|
||||
private static CcsAttemptAction Action(string type, string code, int minute, DateTime now) => new() { Type = type, Code = code, SimulatedMinute = minute, RealUtc = now };
|
||||
private static string Humanize(string value) => string.Join(' ', value.Split('_', StringSplitOptions.RemoveEmptyEntries).Select(word => char.ToUpperInvariant(word[0]) + word[1..]));
|
||||
private static string DescribeExpected(CcsScoringCriterion criterion, List<string> targets)
|
||||
{
|
||||
var text = $"{Humanize(criterion.ActionType)}: {string.Join(" or ", targets.Select(Humanize))}";
|
||||
if (criterion.EarliestMinute is not null && criterion.LatestMinute is not null) text += $" between minute {criterion.EarliestMinute} and {criterion.LatestMinute}";
|
||||
else if (criterion.EarliestMinute is not null) text += $" at or after minute {criterion.EarliestMinute}";
|
||||
else if (criterion.LatestMinute is not null) text += $" by minute {criterion.LatestMinute}";
|
||||
if (!string.IsNullOrWhiteSpace(criterion.AfterTargetCode)) text += $" after {Humanize(criterion.AfterTargetCode)}";
|
||||
return text;
|
||||
}
|
||||
private static CcsCaseSummary ToSummary(CcsCaseRevision value) => new()
|
||||
{
|
||||
ExternalId = value.ExternalId, Title = value.Title, System = value.System,
|
||||
|
||||
@@ -68,22 +68,22 @@
|
||||
{ "id": "metabolic_improvement", "triggerMinute": 270, "requiresOrderCodes": ["insulin_infusion", "potassium_chloride_iv", "dextrose_half_normal_saline", "serial_bmp"], "message": "Glucose and acidosis continue to improve without hypoglycemia or hypokalemia. Case-end instructions are available.", "vitalPatches": { "bloodPressure": "112/70 mm Hg", "pulse": "88/min", "respirations": "18/min" }, "endCase": true }
|
||||
],
|
||||
"scoringCriteria": [
|
||||
{ "id": "assess_hydration", "actionType": "exam", "targetCode": "general_exam", "points": 4, "latestMinute": 10, "afterTargetCode": "", "rationale": "Early assessment should identify severe volume depletion and altered mentation." },
|
||||
{ "id": "iv_access", "actionType": "order", "targetCode": "iv_access", "points": 4, "latestMinute": 5, "afterTargetCode": "", "rationale": "Prompt intravenous access is needed for fluid resuscitation and treatment." },
|
||||
{ "id": "cardiac_monitor", "actionType": "order", "targetCode": "cardiac_monitor", "points": 3, "latestMinute": 10, "afterTargetCode": "", "rationale": "Potassium shifts during DKA treatment create arrhythmia risk." },
|
||||
{ "id": "fluid_resuscitation", "actionType": "order", "targetCode": "", "targetCodes": ["normal_saline_bolus", "balanced_crystalloid_bolus"], "points": 12, "latestMinute": 10, "afterTargetCode": "", "rationale": "Immediate isotonic crystalloid treats severe intravascular depletion and hypotension." },
|
||||
{ "id": "electrolytes", "actionType": "order", "targetCode": "bmp", "points": 8, "latestMinute": 10, "afterTargetCode": "", "rationale": "Electrolytes, renal function, glucose, bicarbonate, and anion gap guide diagnosis and safe treatment." },
|
||||
{ "id": "ketone_confirmation", "actionType": "order", "targetCode": "beta_hydroxybutyrate", "points": 5, "latestMinute": 20, "afterTargetCode": "", "rationale": "Direct beta-hydroxybutyrate measurement confirms and monitors clinically important ketonemia." },
|
||||
{ "id": "acid_base", "actionType": "order", "targetCode": "venous_blood_gas", "points": 5, "latestMinute": 20, "afterTargetCode": "", "rationale": "Venous pH establishes the severity of metabolic acidosis without requiring an arterial sample." },
|
||||
{ "id": "pregnancy", "actionType": "order", "targetCode": "pregnancy_test", "points": 3, "latestMinute": 30, "afterTargetCode": "", "rationale": "Pregnancy status affects the differential diagnosis, monitoring, and disposition in a reproductive-age patient." },
|
||||
{ "id": "insulin_after_potassium", "actionType": "order", "targetCode": "insulin_infusion", "points": 15, "latestMinute": 30, "afterTargetCode": "bmp", "rationale": "Insulin suppresses ketogenesis but should follow assessment of serum potassium." },
|
||||
{ "id": "hourly_glucose", "actionType": "order", "targetCode": "hourly_glucose", "points": 5, "latestMinute": 35, "afterTargetCode": "insulin_infusion", "rationale": "Frequent glucose monitoring is required during continuous insulin therapy." },
|
||||
{ "id": "serial_electrolytes", "actionType": "order", "targetCode": "serial_bmp", "points": 8, "latestMinute": 40, "afterTargetCode": "insulin_infusion", "rationale": "Serial chemistry identifies falling potassium and tracks resolution of acidosis." },
|
||||
{ "id": "potassium_replacement", "actionType": "order", "targetCode": "potassium_chloride_iv", "points": 10, "earliestMinute": 12, "latestMinute": 260, "afterTargetCode": "bmp", "rationale": "Total-body potassium is depleted and replacement is needed as serum potassium falls during therapy." },
|
||||
{ "id": "dextrose_with_continued_insulin", "actionType": "order", "targetCode": "dextrose_half_normal_saline", "points": 10, "earliestMinute": 240, "latestMinute": 300, "afterTargetCode": "insulin_infusion", "rationale": "When glucose approaches 200 mg/dL before ketoacidosis resolves, add dextrose so insulin can continue safely." },
|
||||
{ "id": "icu_disposition", "actionType": "order", "targetCode": "icu_admission", "points": 8, "latestMinute": 60, "afterTargetCode": "", "rationale": "Severe DKA with hypotension and altered mentation requires closely monitored inpatient care." },
|
||||
{ "id": "avoid_bicarbonate", "actionType": "order", "targetCode": "sodium_bicarbonate_iv", "points": -10, "afterTargetCode": "", "rationale": "Routine bicarbonate provides no benefit at pH 7.08 and may worsen potassium shifts and other complications." },
|
||||
{ "id": "avoid_empiric_antibiotic", "actionType": "order", "targetCode": "ceftriaxone", "points": -4, "afterTargetCode": "", "rationale": "No history, examination, or urine findings support bacterial infection requiring empiric ceftriaxone." }
|
||||
{ "id": "assess_hydration", "title": "Assess hydration and mental status", "actionType": "exam", "targetCode": "general_exam", "points": 4, "latestMinute": 10, "afterTargetCode": "", "rationale": "Early assessment should identify severe volume depletion and altered mentation." },
|
||||
{ "id": "iv_access", "title": "Establish intravenous access", "actionType": "order", "targetCode": "iv_access", "points": 4, "latestMinute": 5, "afterTargetCode": "", "rationale": "Prompt intravenous access is needed for fluid resuscitation and treatment." },
|
||||
{ "id": "cardiac_monitor", "title": "Start cardiac monitoring", "actionType": "order", "targetCode": "cardiac_monitor", "points": 3, "latestMinute": 10, "afterTargetCode": "", "rationale": "Potassium shifts during DKA treatment create arrhythmia risk." },
|
||||
{ "id": "fluid_resuscitation", "title": "Begin isotonic fluid resuscitation", "actionType": "order", "targetCode": "", "targetCodes": ["normal_saline_bolus", "balanced_crystalloid_bolus"], "points": 12, "latestMinute": 10, "afterTargetCode": "", "rationale": "Immediate isotonic crystalloid treats severe intravascular depletion and hypotension." },
|
||||
{ "id": "electrolytes", "title": "Check electrolytes and anion gap", "actionType": "order", "targetCode": "bmp", "points": 8, "latestMinute": 10, "afterTargetCode": "", "rationale": "Electrolytes, renal function, glucose, bicarbonate, and anion gap guide diagnosis and safe treatment." },
|
||||
{ "id": "ketone_confirmation", "title": "Measure serum beta-hydroxybutyrate", "actionType": "order", "targetCode": "beta_hydroxybutyrate", "points": 5, "latestMinute": 20, "afterTargetCode": "", "rationale": "Direct beta-hydroxybutyrate measurement confirms and monitors clinically important ketonemia." },
|
||||
{ "id": "acid_base", "title": "Assess acid-base status", "actionType": "order", "targetCode": "venous_blood_gas", "points": 5, "latestMinute": 20, "afterTargetCode": "", "rationale": "Venous pH establishes the severity of metabolic acidosis without requiring an arterial sample." },
|
||||
{ "id": "pregnancy", "title": "Check pregnancy status", "actionType": "order", "targetCode": "pregnancy_test", "points": 3, "latestMinute": 30, "afterTargetCode": "", "rationale": "Pregnancy status affects the differential diagnosis, monitoring, and disposition in a reproductive-age patient." },
|
||||
{ "id": "insulin_after_potassium", "title": "Start insulin after assessing potassium", "actionType": "order", "targetCode": "insulin_infusion", "points": 15, "latestMinute": 30, "afterTargetCode": "bmp", "rationale": "Insulin suppresses ketogenesis but should follow assessment of serum potassium." },
|
||||
{ "id": "hourly_glucose", "title": "Monitor glucose hourly", "actionType": "order", "targetCode": "hourly_glucose", "points": 5, "latestMinute": 35, "afterTargetCode": "insulin_infusion", "rationale": "Frequent glucose monitoring is required during continuous insulin therapy." },
|
||||
{ "id": "serial_electrolytes", "title": "Monitor serial electrolytes", "actionType": "order", "targetCode": "serial_bmp", "points": 8, "latestMinute": 40, "afterTargetCode": "insulin_infusion", "rationale": "Serial chemistry identifies falling potassium and tracks resolution of acidosis." },
|
||||
{ "id": "potassium_replacement", "title": "Replace potassium during treatment", "actionType": "order", "targetCode": "potassium_chloride_iv", "points": 10, "earliestMinute": 12, "latestMinute": 260, "afterTargetCode": "bmp", "rationale": "Total-body potassium is depleted and replacement is needed as serum potassium falls during therapy." },
|
||||
{ "id": "dextrose_with_continued_insulin", "title": "Add dextrose while continuing insulin", "actionType": "order", "targetCode": "dextrose_half_normal_saline", "points": 10, "earliestMinute": 240, "latestMinute": 300, "afterTargetCode": "insulin_infusion", "rationale": "When glucose approaches 200 mg/dL before ketoacidosis resolves, add dextrose so insulin can continue safely." },
|
||||
{ "id": "icu_disposition", "title": "Admit for intensive monitoring", "actionType": "order", "targetCode": "icu_admission", "points": 8, "latestMinute": 60, "afterTargetCode": "", "rationale": "Severe DKA with hypotension and altered mentation requires closely monitored inpatient care." },
|
||||
{ "id": "avoid_bicarbonate", "title": "Avoid unnecessary bicarbonate", "actionType": "order", "targetCode": "sodium_bicarbonate_iv", "points": -10, "afterTargetCode": "", "rationale": "Routine bicarbonate provides no benefit at pH 7.08 and may worsen potassium shifts and other complications." },
|
||||
{ "id": "avoid_empiric_antibiotic", "title": "Avoid unsupported empiric antibiotics", "actionType": "order", "targetCode": "ceftriaxone", "points": -4, "afterTargetCode": "", "rationale": "No history, examination, or urine findings support bacterial infection requiring empiric ceftriaxone." }
|
||||
],
|
||||
"references": [
|
||||
{ "title": "Hyperglycemic Crises in Adults With Diabetes: A Consensus Report", "publisher": "American Diabetes Association, EASD, AACE, JBDS-IP, and Diabetes Technology Society", "url": "https://diabetesjournals.org/care/article/47/8/1257/156808/Hyperglycemic-Crises-in-Adults-With-Diabetes-A", "supports": "DKA diagnosis, fluid resuscitation, insulin and potassium sequencing, dextrose addition, monitoring, bicarbonate avoidance, and resolution criteria", "accessedOn": "2026-07-15", "reviewer": "Question bank editorial review" },
|
||||
|
||||
@@ -8,6 +8,8 @@ signal attempt_changed
|
||||
signal orders_changed
|
||||
signal history_changed
|
||||
signal prediction_changed
|
||||
signal rating_changed
|
||||
signal attempt_abandoned
|
||||
signal report_submitted(report_id: String)
|
||||
signal report_failed(message: String)
|
||||
signal request_failed(message: String)
|
||||
@@ -23,6 +25,7 @@ var attempt := {}
|
||||
var orders: Array = []
|
||||
var history: Array = []
|
||||
var prediction := {}
|
||||
var rating := {"elo": 1000, "ratedCaseCount": 0}
|
||||
var is_loading := false
|
||||
var pending_action := ""
|
||||
var queue: Array = []
|
||||
@@ -78,6 +81,10 @@ func load_prediction() -> void:
|
||||
_send("prediction", HTTPClient.METHOD_GET, "/prediction")
|
||||
|
||||
|
||||
func load_rating() -> void:
|
||||
_send("rating", HTTPClient.METHOD_GET, "/ratings/me")
|
||||
|
||||
|
||||
func submit_report(attempt_id: String, category: String, details: String) -> void:
|
||||
_send("report", HTTPClient.METHOD_POST, "/reports", {"attemptId": attempt_id, "category": category, "details": details})
|
||||
|
||||
@@ -89,12 +96,21 @@ func adjust_text_scale(delta: float) -> void:
|
||||
func toggle_highlight(attempt_id: String, region_id: String, start: int, end: int) -> void:
|
||||
var key := "%s:%s" % [attempt_id, region_id]
|
||||
var ranges: Array = highlights.get(key, [])
|
||||
var removed := false
|
||||
var changed := false
|
||||
for index in range(ranges.size() - 1, -1, -1):
|
||||
var item: Dictionary = ranges[index]
|
||||
if start < int(item.get("end", 0)) and end > int(item.get("start", 0)):
|
||||
ranges.remove_at(index); removed = true
|
||||
if not removed: ranges.append({"start": start, "end": end})
|
||||
var highlight_start := int(item.get("start", 0))
|
||||
var highlight_end := int(item.get("end", 0))
|
||||
if start < highlight_end and end > highlight_start:
|
||||
ranges.remove_at(index)
|
||||
if highlight_start < start:
|
||||
ranges.insert(index, {"start": highlight_start, "end": start})
|
||||
index += 1
|
||||
if end < highlight_end:
|
||||
ranges.insert(index, {"start": end, "end": highlight_end})
|
||||
changed = true
|
||||
if not changed:
|
||||
ranges.append({"start": start, "end": end})
|
||||
highlights[key] = ranges
|
||||
|
||||
|
||||
@@ -124,7 +140,7 @@ func abandon() -> void:
|
||||
|
||||
func deadline_unix() -> float:
|
||||
var value := str(attempt.get("finalOrdersDeadlineUtc", attempt.get("deadlineUtc", "")))
|
||||
return 0.0 if value.is_empty() or value == "<null>" else Time.get_unix_time_from_datetime_string(value)
|
||||
return 0.0 if value.is_empty() or value == "<null>" else float(Time.get_unix_time_from_datetime_string(value))
|
||||
|
||||
|
||||
func _send(action: String, method: int, suffix: String, payload: Dictionary = {}) -> void:
|
||||
@@ -160,7 +176,8 @@ func _on_request_completed(result: int, response_code: int, _headers: PackedStri
|
||||
return
|
||||
if response_code == 204:
|
||||
attempt = {}
|
||||
attempt_changed.emit()
|
||||
if action == "abandon": attempt_abandoned.emit()
|
||||
else: attempt_changed.emit()
|
||||
return
|
||||
var parsed: Variant = JSON.parse_string(body.get_string_from_utf8())
|
||||
if action in ["cases", "orders", "history"] and typeof(parsed) != TYPE_ARRAY:
|
||||
@@ -174,6 +191,7 @@ func _on_request_completed(result: int, response_code: int, _headers: PackedStri
|
||||
"orders": orders = parsed; orders_changed.emit()
|
||||
"history": history = parsed; history_changed.emit()
|
||||
"prediction": prediction = parsed; prediction_changed.emit()
|
||||
"rating": rating = parsed; rating_changed.emit()
|
||||
"report": report_submitted.emit(str(parsed.get("id", "")))
|
||||
_:
|
||||
attempt = parsed
|
||||
|
||||
@@ -4,19 +4,22 @@ const Style := preload("res://scripts/ui/ui_style.gd")
|
||||
var main: Control
|
||||
var list: VBoxContainer
|
||||
var prediction: Label
|
||||
var rating: Label
|
||||
|
||||
func _ready() -> void:
|
||||
set_anchors_preset(Control.PRESET_FULL_RECT); add_theme_constant_override("separation", 12)
|
||||
var back := Button.new(); back.text = "< Profile"; back.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN; back.pressed.connect(func(): main.show_screen("profile")); add_child(back)
|
||||
var title := Label.new(); title.text = "CCS Case History"; Style.set_label(title, 32, Style.TEXT); add_child(title)
|
||||
prediction = Label.new(); prediction.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART; Style.set_label(prediction, 16, Style.CYAN); add_child(prediction)
|
||||
rating = Label.new(); Style.set_label(rating, 18, Style.TEXT); add_child(rating)
|
||||
var scroll := ScrollContainer.new(); scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL; add_child(scroll)
|
||||
list = VBoxContainer.new(); list.size_flags_horizontal = Control.SIZE_EXPAND_FILL; list.add_theme_constant_override("separation", 8); scroll.add_child(list)
|
||||
CCSService.history_changed.connect(_render)
|
||||
CCSService.prediction_changed.connect(_render_prediction)
|
||||
CCSService.rating_changed.connect(_render_rating)
|
||||
|
||||
func on_show(_payload: Variant = null) -> void:
|
||||
CCSService.load_history(); CCSService.load_prediction()
|
||||
CCSService.load_history(); CCSService.load_prediction(); CCSService.load_rating()
|
||||
|
||||
func _render() -> void:
|
||||
for child in list.get_children(): child.queue_free()
|
||||
@@ -25,7 +28,9 @@ func _render() -> void:
|
||||
for item in CCSService.history:
|
||||
var button := Button.new(); button.alignment = HORIZONTAL_ALIGNMENT_LEFT
|
||||
var score: Variant = item.get("score", null)
|
||||
button.text = "%s\n%s | %s%s" % [item.get("caseTitle", "Case"), str(item.get("mode", "")).capitalize(), str(item.get("state", "")).capitalize(), " | %d%%" % int(round(float(score))) if score != null else ""]
|
||||
var rating_change: Dictionary = item.get("rating", {}) if typeof(item.get("rating", {})) == TYPE_DICTIONARY else {}
|
||||
var rating_text := " | CCS ELO %+d" % rating_change.get("userEloDelta", 0) if bool(rating_change.get("rated", false)) else ""
|
||||
button.text = "%s\n%s | %s%s%s" % [item.get("caseTitle", "Case"), str(item.get("mode", "")).capitalize(), str(item.get("state", "")).capitalize(), " | %d%%" % int(round(float(score))) if score != null else "", rating_text]
|
||||
button.disabled = str(item.get("state", "")) == "abandoned"
|
||||
button.pressed.connect(_open.bind(str(item.get("id", "")), str(item.get("state", "")))); list.add_child(button)
|
||||
|
||||
@@ -39,3 +44,6 @@ func _render_prediction() -> void:
|
||||
elif data.get("ccsScore", null) != null:
|
||||
prediction.text = "CCS performance: %.0f%% from %d first timed cases. Complete a Day 1 simulation for a combined prediction." % [data.get("ccsScore", 0), data.get("contributingCases", 0)]
|
||||
else: prediction.text = "Complete timed CCS cases to begin estimating CCS performance."
|
||||
|
||||
func _render_rating() -> void:
|
||||
rating.text = "CCS ELO %d | %d rated cases" % [CCSService.rating.get("elo", 1000), CCSService.rating.get("ratedCaseCount", 0)]
|
||||
|
||||
@@ -17,6 +17,8 @@ var time_row: HBoxContainer
|
||||
var report_dialog: Control
|
||||
var report_button: Button
|
||||
var workspace: HSplitContainer
|
||||
var leave_button: Button
|
||||
var leaving := false
|
||||
|
||||
func _ready() -> void:
|
||||
set_anchors_preset(Control.PRESET_FULL_RECT); add_theme_constant_override("separation", 8)
|
||||
@@ -46,17 +48,20 @@ func _ready() -> void:
|
||||
var button := Button.new(); button.text = "+%s" % ("1 day" if minutes == 1440 else "%d min" % minutes); button.pressed.connect(CCSService.send_command.bind("advance", "", minutes)); time_row.add_child(button)
|
||||
var reassess := Button.new(); reassess.text = "Interval history"; reassess.pressed.connect(CCSService.send_command.bind("interval_history", "", 0)); clerk.add_child(reassess)
|
||||
end_button = Button.new(); end_button.text = "End case"; Style.style_chip(end_button, Style.AMBER); end_button.pressed.connect(_end_case); clerk.add_child(end_button)
|
||||
var leave := Button.new(); leave.text = "Leave case"; Style.style_chip(leave, Style.RED); leave.pressed.connect(_leave); clerk.add_child(leave)
|
||||
leave_button = Button.new(); leave_button.text = "Leave case"; Style.style_chip(leave_button, Style.RED); leave_button.pressed.connect(_leave); clerk.add_child(leave_button)
|
||||
status = Label.new(); Style.set_label(status, 14, Style.RED); add_child(status)
|
||||
timer = Timer.new(); timer.wait_time = 1; timer.timeout.connect(_tick); add_child(timer)
|
||||
CCSService.attempt_changed.connect(_render)
|
||||
CCSService.attempt_abandoned.connect(_on_abandoned)
|
||||
CCSService.orders_changed.connect(_render_search)
|
||||
CCSService.request_failed.connect(func(message: String): status.text = message)
|
||||
CCSService.request_failed.connect(_request_failed)
|
||||
CCSService.report_submitted.connect(_report_submitted)
|
||||
report_dialog = preload("res://scripts/ui/ccs_report_dialog.gd").new(); add_child(report_dialog)
|
||||
|
||||
func on_show(payload: Variant = null) -> void:
|
||||
status.text = ""
|
||||
leaving = false
|
||||
leave_button.disabled = false
|
||||
if typeof(payload) == TYPE_STRING and not str(payload).is_empty(): CCSService.load_attempt(str(payload))
|
||||
_render(); timer.start()
|
||||
|
||||
@@ -101,8 +106,27 @@ func _tick() -> void:
|
||||
clock.text = "%02d:%02d | Case time %d min" % [floori(float(remaining) / 60.0), remaining % 60, int(CCSService.attempt.get("simulatedMinute", 0))]
|
||||
if remaining == 0 and not CCSService.is_loading: CCSService.load_attempt(str(CCSService.attempt.get("id", "")))
|
||||
|
||||
func leave_case() -> void:
|
||||
_leave()
|
||||
|
||||
func _leave() -> void:
|
||||
CCSService.abandon(); main.show_screen("ccs_hub")
|
||||
if leaving or CCSService.attempt.is_empty(): return
|
||||
leaving = true
|
||||
leave_button.disabled = true
|
||||
status.text = "Leaving case..."
|
||||
CCSService.abandon()
|
||||
|
||||
func _on_abandoned() -> void:
|
||||
if not leaving: return
|
||||
leaving = false
|
||||
main.show_screen("selection", "step3")
|
||||
|
||||
func _request_failed(message: String) -> void:
|
||||
if not visible: return
|
||||
status.text = message
|
||||
if leaving:
|
||||
leaving = false
|
||||
leave_button.disabled = false
|
||||
|
||||
func _end_case() -> void:
|
||||
CCSService.send_command("exit_case" if str(CCSService.attempt.get("state", "")) == "final_orders" else "end_case")
|
||||
@@ -149,9 +173,9 @@ func _zoom(delta: float) -> void:
|
||||
CCSService.adjust_text_scale(delta); _apply_text_scale()
|
||||
|
||||
func _apply_text_scale() -> void:
|
||||
var size := int(round(18.0 * CCSService.text_scale))
|
||||
var font_size := int(round(18.0 * CCSService.text_scale))
|
||||
for label in [summary, chart]:
|
||||
label.add_theme_font_size_override("normal_font_size", size); label.add_theme_font_size_override("bold_font_size", size)
|
||||
label.add_theme_font_size_override("normal_font_size", font_size); label.add_theme_font_size_override("bold_font_size", font_size)
|
||||
_scale_controls(workspace)
|
||||
|
||||
func _scale_controls(node: Node) -> void:
|
||||
|
||||
@@ -9,6 +9,8 @@ var references: VBoxContainer
|
||||
var report_dialog: Control
|
||||
var report_button: Button
|
||||
var current_attempt := {}
|
||||
var status: Label
|
||||
var starting_case := false
|
||||
|
||||
func _ready() -> void:
|
||||
set_anchors_preset(Control.PRESET_FULL_RECT); add_theme_constant_override("separation", 10)
|
||||
@@ -26,13 +28,17 @@ func _ready() -> void:
|
||||
CCSService.attempt_changed.connect(_refresh_loaded_attempt); CCSService.report_submitted.connect(_report_submitted)
|
||||
report_dialog = preload("res://scripts/ui/ccs_report_dialog.gd").new(); add_child(report_dialog)
|
||||
var row := HBoxContainer.new(); add_child(row)
|
||||
var cases := Button.new(); cases.text = "More cases"; Style.style_primary_button(cases); cases.pressed.connect(func(): main.show_screen("ccs_hub")); row.add_child(cases)
|
||||
var history := Button.new(); history.text = "Case history"; history.pressed.connect(func(): main.show_screen("ccs_history")); row.add_child(history)
|
||||
var cases := Button.new(); cases.text = "Another Case"; Style.style_primary_button(cases); cases.pressed.connect(_another_case); row.add_child(cases)
|
||||
var back := Button.new(); back.text = "Back to Step 3 Modes"; back.pressed.connect(func(): main.show_screen("selection", "step3")); row.add_child(back)
|
||||
status = Label.new(); Style.set_label(status, 14, Style.RED); row.add_child(status)
|
||||
CCSService.request_failed.connect(_request_failed)
|
||||
|
||||
func _study_label(region: String) -> RichTextLabel:
|
||||
var label := RichTextLabel.new(); label.bbcode_enabled = true; label.fit_content = true; label.selection_enabled = true; label.set_meta("ccs_region", region); label.gui_input.connect(_text_input.bind(label)); return label
|
||||
|
||||
func on_show(payload: Variant = null) -> void:
|
||||
starting_case = false
|
||||
if status != null: status.text = ""
|
||||
if typeof(payload) == TYPE_STRING: CCSService.load_attempt(str(payload)); return
|
||||
current_attempt = payload if typeof(payload) == TYPE_DICTIONARY else CCSService.attempt
|
||||
if current_attempt.is_empty(): return
|
||||
@@ -41,9 +47,17 @@ func on_show(payload: Variant = null) -> void:
|
||||
_render_text(); _render_references(); _apply_text_scale()
|
||||
|
||||
func _score_plain() -> String:
|
||||
var lines: Array[String] = ["Scoring review"]
|
||||
var score: Dictionary = current_attempt.get("score", {})
|
||||
var lines: Array[String] = ["Case result: %d/%d points (%.0f%%)" % [score.get("earnedPoints", 0), score.get("possiblePoints", 0), score.get("percent", 0)]]
|
||||
var rating: Dictionary = current_attempt.get("rating", {})
|
||||
if bool(rating.get("rated", false)):
|
||||
lines.append("CCS ELO: %d (%+d)" % [rating.get("userElo", 1000), rating.get("userEloDelta", 0)])
|
||||
else:
|
||||
lines.append("CCS ELO unchanged: this case was not rating-eligible.")
|
||||
for criterion in current_attempt.get("score", {}).get("criteria", []):
|
||||
lines.append("%s %s%d\n%s" % [str(criterion.get("status", "missed")).capitalize(), "+" if int(criterion.get("points", 0)) >= 0 else "", criterion.get("points", 0), criterion.get("rationale", "")])
|
||||
var observed := str(criterion.get("observedAction", "Not performed"))
|
||||
if criterion.get("observedMinute", null) != null: observed += " at minute %d" % criterion.get("observedMinute", 0)
|
||||
lines.append("%s: %s %s%d\nExpected: %s\nObserved: %s\n%s" % [criterion.get("title", criterion.get("criterionId", "Criterion")), str(criterion.get("status", "missed")).capitalize(), "+" if int(criterion.get("points", 0)) >= 0 else "", criterion.get("points", 0), criterion.get("expectedAction", ""), observed, criterion.get("rationale", "")])
|
||||
return "\n\n".join(lines)
|
||||
|
||||
func _timeline_plain() -> String:
|
||||
@@ -76,8 +90,8 @@ func _zoom(delta: float) -> void:
|
||||
CCSService.adjust_text_scale(delta); _apply_text_scale(); _render_references()
|
||||
|
||||
func _apply_text_scale() -> void:
|
||||
var size := int(round(18.0 * CCSService.text_scale))
|
||||
for label in [score_body, timeline_body]: label.add_theme_font_size_override("normal_font_size", size); label.add_theme_font_size_override("bold_font_size", size)
|
||||
var font_size := int(round(18.0 * CCSService.text_scale))
|
||||
for label in [score_body, timeline_body]: label.add_theme_font_size_override("normal_font_size", font_size); label.add_theme_font_size_override("bold_font_size", font_size)
|
||||
|
||||
func _clear_highlights() -> void:
|
||||
CCSService.clear_highlights(str(current_attempt.get("id", ""))); _render_text()
|
||||
@@ -85,4 +99,18 @@ func _clear_highlights() -> void:
|
||||
func _report() -> void: report_dialog.show_for(str(current_attempt.get("id", "")))
|
||||
func _report_submitted(_id: String) -> void: Style.style_chip(report_button, Style.RED); report_button.tooltip_text = "Report submitted; report another issue"
|
||||
func _refresh_loaded_attempt() -> void:
|
||||
if visible and str(CCSService.attempt.get("state", "")) == "completed": on_show(CCSService.attempt)
|
||||
if not visible: return
|
||||
if starting_case and str(CCSService.attempt.get("state", "")) in ["active", "final_orders"]:
|
||||
starting_case = false; main.show_screen("ccs_player"); return
|
||||
if str(CCSService.attempt.get("state", "")) == "completed": on_show(CCSService.attempt)
|
||||
|
||||
func _another_case() -> void:
|
||||
if starting_case: return
|
||||
starting_case = true
|
||||
status.text = "Loading a random timed case..."
|
||||
CCSService.create_attempt("", "timed")
|
||||
|
||||
func _request_failed(message: String) -> void:
|
||||
if not visible or not starting_case: return
|
||||
starting_case = false
|
||||
status.text = message
|
||||
|
||||
+7
-3
@@ -317,11 +317,11 @@ func _toggle_app_menu() -> void:
|
||||
app_menu.visible = true
|
||||
app_menu.size = app_menu.custom_minimum_size
|
||||
app_menu.position = (get_viewport_rect().size - app_menu.size) * 0.5
|
||||
menu_exit_button.visible = current_screen_name == "player" and BlockSessionService.block_started
|
||||
menu_exit_button.visible = (current_screen_name == "player" and BlockSessionService.block_started) or current_screen_name == "ccs_player"
|
||||
if menu_exit_button.visible:
|
||||
menu_exit_button.text = "Leave (timer continues)" if BlockSessionService.session_kind == "simulation" else "Exit Run"
|
||||
menu_exit_button.text = "Leave Case" if current_screen_name == "ccs_player" else "Leave (timer continues)" if BlockSessionService.session_kind == "simulation" else "Exit Run"
|
||||
Style.style_chip(menu_exit_button, Style.AMBER)
|
||||
BlockSessionService.pause_current_question_timer()
|
||||
if current_screen_name == "player": BlockSessionService.pause_current_question_timer()
|
||||
|
||||
|
||||
func _close_app_menu() -> void:
|
||||
@@ -332,6 +332,10 @@ func _close_app_menu() -> void:
|
||||
|
||||
func _exit_run() -> void:
|
||||
_close_app_menu()
|
||||
if current_screen_name == "ccs_player":
|
||||
var ccs_player = screens.get("ccs_player")
|
||||
if ccs_player != null and ccs_player.has_method("leave_case"): ccs_player.leave_case()
|
||||
return
|
||||
if BlockSessionService.block_started and BlockSessionService.session_kind != "simulation":
|
||||
BlockSessionService.finish_block()
|
||||
elif BlockSessionService.session_kind == "simulation":
|
||||
|
||||
@@ -12,6 +12,7 @@ var outcome_consent: CheckBox
|
||||
var outcome_status: Label
|
||||
var outcome_delete_button: Button
|
||||
var outcome_grid: GridContainer
|
||||
var ccs_rating_label: Label
|
||||
|
||||
func _ready() -> void:
|
||||
set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
@@ -29,6 +30,9 @@ func _ready() -> void:
|
||||
content.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
content.add_theme_constant_override("separation", 14)
|
||||
scroll.add_child(content)
|
||||
ccs_rating_label = Label.new()
|
||||
Style.set_label(ccs_rating_label, 20, Style.CYAN)
|
||||
content.add_child(ccs_rating_label)
|
||||
|
||||
var panel := PanelContainer.new()
|
||||
panel.add_theme_stylebox_override("panel", Style.panel_box(Color("172337"), Color("405475")))
|
||||
@@ -80,12 +84,15 @@ func _ready() -> void:
|
||||
ExamSimulationService.outcome_changed.connect(_refresh_outcome)
|
||||
if not ExamSimulationService.request_failed.is_connected(_on_outcome_request_failed):
|
||||
ExamSimulationService.request_failed.connect(_on_outcome_request_failed)
|
||||
if not CCSService.rating_changed.is_connected(_refresh_ccs_rating):
|
||||
CCSService.rating_changed.connect(_refresh_ccs_rating)
|
||||
resized.connect(_refresh_outcome_layout)
|
||||
_refresh_outcome_layout()
|
||||
|
||||
|
||||
func on_show(_payload: Variant = null) -> void:
|
||||
_reset_qbank_confirmation()
|
||||
_refresh_ccs_rating()
|
||||
var profile := ProfileService.get_profile()
|
||||
var text := "[b]Overall ELO %d[/b]\n%d rated questions\nCurrent streak: %d\nBest streak: %d\nToday: %d/%d questions\n\n[b]Category ELO[/b]\n" % [
|
||||
QuestionBankService.overall_elo,
|
||||
@@ -123,10 +130,15 @@ func on_show(_payload: Variant = null) -> void:
|
||||
block.get("total", 0)
|
||||
]
|
||||
profile_text.text = text
|
||||
CCSService.load_rating()
|
||||
outcome_status.text = "Loading official outcome..."
|
||||
ExamSimulationService.load_outcome()
|
||||
|
||||
|
||||
func _refresh_ccs_rating() -> void:
|
||||
ccs_rating_label.text = "CCS ELO %d | %d rated cases" % [CCSService.rating.get("elo", 1000), CCSService.rating.get("ratedCaseCount", 0)]
|
||||
|
||||
|
||||
func _build_outcome_panel(parent: VBoxContainer) -> void:
|
||||
var panel := PanelContainer.new()
|
||||
panel.add_theme_stylebox_override("panel", Style.panel_box(Color("172337"), Color("405475")))
|
||||
|
||||
Reference in New Issue
Block a user