Rebuild CCS case workflow

This commit is contained in:
hz
2026-07-23 04:43:02 -05:00
parent 489b9c3d7c
commit 8e216eac32
13 changed files with 1374 additions and 690 deletions
+35
View File
@@ -8,6 +8,41 @@ namespace QuestionsApi.Tests;
public sealed class CcsCaseTests public sealed class CcsCaseTests
{ {
[Fact]
public void NewCcsAttemptsAreTimedOnly()
{
Assert.Null(CcsCaseValidator.ValidateCreate(new CreateCcsAttemptRequest()));
Assert.Equal("new CCS attempts must be timed",
CcsCaseValidator.ValidateCreate(new CreateCcsAttemptRequest { Mode = CcsAttemptModes.Practice }));
}
[Fact]
public void CcsBatchCommandsAreBoundedAndHomogeneous()
{
var valid = new CcsBatchCommandRequest
{
CommandId = "batch-1",
Commands =
[
new() { Type = "exam", Code = "general" },
new() { Type = "exam", Code = "cardiac" }
]
};
Assert.Null(CcsCaseValidator.ValidateBatch(valid));
valid.Commands.Add(new() { Type = "order", Code = "bmp" });
Assert.Contains("cannot be mixed", CcsCaseValidator.ValidateBatch(valid), StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void CcsTimingReservesTwoMinutesWithoutExtendingTheHardDeadline()
{
Assert.Equal(18, CcsStore.ActivePhaseMinutes(20));
Assert.Equal(8, CcsStore.ActivePhaseMinutes(10));
var now = new DateTime(2026, 7, 23, 12, 0, 0, DateTimeKind.Utc);
Assert.Equal(now.AddMinutes(2), CcsStore.FinalOrdersDeadline(now, now.AddMinutes(5)));
Assert.Equal(now.AddMinutes(1), CcsStore.FinalOrdersDeadline(now, now.AddMinutes(1)));
}
[Fact] [Fact]
public void CcsHistoryCursorRoundTripsTimestampAndId() public void CcsHistoryCursorRoundTripsTimestampAndId()
{ {
@@ -51,6 +51,16 @@ public sealed class CcsController(CcsStore store, CcsRatingStore ratings) : Cont
catch (InvalidOperationException exception) { return Conflict(exception.Message); } catch (InvalidOperationException exception) { return Conflict(exception.Message); }
} }
[HttpPost("attempts/{id}/commands/batch")]
public async Task<IActionResult> BatchCommand(string id, CcsBatchCommandRequest request)
{
var attempt = await store.GetOwnedAsync(id, UserId());
if (attempt is null) return NotFound();
try { return Ok(await store.ToResponseAsync(await store.ApplyBatchCommandAsync(attempt, request))); }
catch (ArgumentException exception) { return BadRequest(exception.Message); }
catch (InvalidOperationException exception) { return Conflict(exception.Message); }
}
[HttpPost("attempts/{id}/abandon")] [HttpPost("attempts/{id}/abandon")]
public async Task<IActionResult> Abandon(string id) public async Task<IActionResult> Abandon(string id)
{ {
+80 -2
View File
@@ -17,6 +17,26 @@ public static class CcsAttemptModes
public const string Timed = "timed"; public const string Timed = "timed";
} }
public static class CcsChartCategories
{
public const string Orders = "orders";
public const string Progress = "progress";
public const string Vitals = "vitals";
public const string Lab = "lab";
public const string Imaging = "imaging";
public const string OtherTest = "other_test";
public const string Treatment = "treatment";
public static readonly IReadOnlySet<string> All = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
Orders, Progress, Vitals, Lab, Imaging, OtherTest, Treatment
};
public static readonly IReadOnlySet<string> ResultSections = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
Lab, Imaging, OtherTest, Treatment
};
}
public class CcsOrderDefinition public class CcsOrderDefinition
{ {
[BsonId] public string Code { get; set; } = ""; [BsonId] public string Code { get; set; } = "";
@@ -25,6 +45,9 @@ public class CcsOrderDefinition
public List<string> Aliases { get; set; } = []; public List<string> Aliases { get; set; } = [];
public int DefaultTurnaroundMinutes { get; set; } public int DefaultTurnaroundMinutes { get; set; }
public List<string> AvailableLocations { get; set; } = []; public List<string> AvailableLocations { get; set; } = [];
public string DisplayRoute { get; set; } = "";
public string DisplayFrequency { get; set; } = "";
public string ResultSection { get; set; } = "";
public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow; public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow;
} }
@@ -57,11 +80,19 @@ public class CcsInitialChart
public string ReasonForEncounter { get; set; } = ""; public string ReasonForEncounter { get; set; } = "";
public string Appearance { get; set; } = ""; public string Appearance { get; set; } = "";
public string History { get; set; } = ""; public string History { get; set; } = "";
public List<CcsNarrativeSection> HistorySections { get; set; } = [];
public Dictionary<string, string> Vitals { get; set; } = []; public Dictionary<string, string> Vitals { get; set; } = [];
public DateTime SimulatedStartUtc { get; set; } = DateTime.UtcNow; public DateTime SimulatedStartUtc { get; set; } = DateTime.UtcNow;
public List<string> InitialOrderCodes { get; set; } = []; public List<string> InitialOrderCodes { get; set; } = [];
} }
public class CcsNarrativeSection
{
public string Code { get; set; } = "";
public string Title { get; set; } = "";
public string Text { get; set; } = "";
}
public class CcsExamDefinition public class CcsExamDefinition
{ {
public string Code { get; set; } = ""; public string Code { get; set; } = "";
@@ -75,6 +106,7 @@ public class CcsCaseOrderDefinition
public string OrderCode { get; set; } = ""; public string OrderCode { get; set; } = "";
public int? TurnaroundMinutes { get; set; } public int? TurnaroundMinutes { get; set; }
public string Result { get; set; } = ""; public string Result { get; set; } = "";
public string ResultSection { get; set; } = "";
public Dictionary<string, string> VitalPatches { get; set; } = []; public Dictionary<string, string> VitalPatches { get; set; } = [];
} }
@@ -111,19 +143,22 @@ public class CcsAttempt
public string CaseExternalId { get; set; } = ""; public string CaseExternalId { get; set; } = "";
[BsonRepresentation(BsonType.ObjectId)] public string CaseRevisionId { get; set; } = ""; [BsonRepresentation(BsonType.ObjectId)] public string CaseRevisionId { get; set; } = "";
public int CaseVersion { get; set; } public int CaseVersion { get; set; }
public string Mode { get; set; } = CcsAttemptModes.Practice; public string Mode { get; set; } = CcsAttemptModes.Timed;
public string State { get; set; } = CcsAttemptStates.Active; public string State { get; set; } = CcsAttemptStates.Active;
public int Revision { get; set; } public int Revision { get; set; }
public int SimulatedMinute { get; set; } public int SimulatedMinute { get; set; }
[BsonIgnoreIfNull] public bool? OpeningCompleted { get; set; }
public string Location { get; set; } = ""; public string Location { get; set; } = "";
public Dictionary<string, string> CurrentVitals { get; set; } = []; public Dictionary<string, string> CurrentVitals { get; set; } = [];
public List<CcsAttemptAction> Actions { get; set; } = []; public List<CcsAttemptAction> Actions { get; set; } = [];
public List<CcsPendingResult> PendingResults { get; set; } = []; public List<CcsPendingResult> PendingResults { get; set; } = [];
public List<CcsChartEntry> Chart { get; set; } = []; public List<CcsChartEntry> Chart { get; set; } = [];
public List<CcsVitalSnapshot> VitalHistory { get; set; } = [];
public List<string> ActiveOrderCodes { get; set; } = []; public List<string> ActiveOrderCodes { get; set; } = [];
public List<string> ProcessedEventIds { get; set; } = []; public List<string> ProcessedEventIds { get; set; } = [];
public List<string> ProcessedCommandIds { get; set; } = []; public List<string> ProcessedCommandIds { get; set; } = [];
public DateTime? DeadlineUtc { get; set; } public DateTime? DeadlineUtc { get; set; }
public DateTime? HardDeadlineUtc { get; set; }
public DateTime? FinalOrdersDeadlineUtc { get; set; } public DateTime? FinalOrdersDeadlineUtc { get; set; }
public DateTime CreatedUtc { get; set; } = DateTime.UtcNow; public DateTime CreatedUtc { get; set; } = DateTime.UtcNow;
public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow; public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow;
@@ -144,6 +179,8 @@ public class CcsAttemptAction
public class CcsPendingResult public class CcsPendingResult
{ {
public string OrderCode { get; set; } = ""; public string OrderCode { get; set; } = "";
public string Category { get; set; } = "";
public string Title { get; set; } = "";
public int DueMinute { get; set; } public int DueMinute { get; set; }
public string Result { get; set; } = ""; public string Result { get; set; } = "";
public Dictionary<string, string> VitalPatches { get; set; } = []; public Dictionary<string, string> VitalPatches { get; set; } = [];
@@ -153,9 +190,30 @@ public class CcsChartEntry
{ {
public string Category { get; set; } = "progress"; public string Category { get; set; } = "progress";
public int SimulatedMinute { get; set; } public int SimulatedMinute { get; set; }
public string Code { get; set; } = "";
public string Title { get; set; } = "";
public string Text { get; set; } = ""; public string Text { get; set; } = "";
} }
public class CcsVitalSnapshot
{
public int SimulatedMinute { get; set; }
public Dictionary<string, string> Values { get; set; } = [];
}
public class CcsActiveOrder
{
public string Code { get; set; } = "";
public string Name { get; set; } = "";
public string Category { get; set; } = "";
public string Route { get; set; } = "";
public string Frequency { get; set; } = "";
public int OrderedMinute { get; set; }
public int? ReportMinute { get; set; }
public bool IsPending { get; set; }
public bool HasResult { get; set; }
}
public class CcsCaseScore public class CcsCaseScore
{ {
public double Percent { get; set; } public double Percent { get; set; }
@@ -194,7 +252,7 @@ public class CcsImportResponse
public class CreateCcsAttemptRequest public class CreateCcsAttemptRequest
{ {
public string CaseExternalId { get; set; } = ""; public string CaseExternalId { get; set; } = "";
public string Mode { get; set; } = CcsAttemptModes.Practice; public string Mode { get; set; } = CcsAttemptModes.Timed;
} }
public class CcsCommandRequest public class CcsCommandRequest
@@ -206,6 +264,20 @@ public class CcsCommandRequest
public int Minutes { get; set; } public int Minutes { get; set; }
} }
public class CcsBatchCommandRequest
{
public string CommandId { get; set; } = "";
public int ExpectedRevision { get; set; }
public List<CcsBatchCommandItem> Commands { get; set; } = [];
}
public class CcsBatchCommandItem
{
public string Type { get; set; } = "";
public string Code { get; set; } = "";
public int Minutes { get; set; }
}
public class CcsCaseSummary public class CcsCaseSummary
{ {
public string ExternalId { get; set; } = ""; public string ExternalId { get; set; } = "";
@@ -226,8 +298,11 @@ public class CcsAttemptResponse
public string State { get; set; } = ""; public string State { get; set; } = "";
public int Revision { get; set; } public int Revision { get; set; }
public int SimulatedMinute { get; set; } public int SimulatedMinute { get; set; }
public DateTime SimulatedStartUtc { get; set; }
public bool OpeningCompleted { get; set; }
public string Location { get; set; } = ""; public string Location { get; set; } = "";
public DateTime? DeadlineUtc { get; set; } public DateTime? DeadlineUtc { get; set; }
public DateTime? HardDeadlineUtc { get; set; }
public DateTime? FinalOrdersDeadlineUtc { get; set; } public DateTime? FinalOrdersDeadlineUtc { get; set; }
public DateTime CreatedUtc { get; set; } public DateTime CreatedUtc { get; set; }
public int RealTimeMinutes { get; set; } public int RealTimeMinutes { get; set; }
@@ -236,9 +311,12 @@ public class CcsAttemptResponse
public string ReasonForEncounter { get; set; } = ""; public string ReasonForEncounter { get; set; } = "";
public string Appearance { get; set; } = ""; public string Appearance { get; set; } = "";
public string History { get; set; } = ""; public string History { get; set; } = "";
public List<CcsNarrativeSection> HistorySections { get; set; } = [];
public Dictionary<string, string> Vitals { get; set; } = []; public Dictionary<string, string> Vitals { get; set; } = [];
public List<CcsChartEntry> Chart { get; set; } = []; public List<CcsChartEntry> Chart { get; set; } = [];
public List<string> ActiveOrderCodes { get; set; } = []; public List<string> ActiveOrderCodes { get; set; } = [];
public List<CcsActiveOrder> ActiveOrders { get; set; } = [];
public List<CcsVitalSnapshot> VitalHistory { get; set; } = [];
public List<CcsPendingResult> PendingResults { get; set; } = []; public List<CcsPendingResult> PendingResults { get; set; } = [];
public List<CcsExamDefinition> AvailableExams { get; set; } = []; public List<CcsExamDefinition> AvailableExams { get; set; } = [];
public List<string> AllowedLocations { get; set; } = []; public List<string> AllowedLocations { get; set; } = [];
+34 -3
View File
@@ -4,8 +4,7 @@ namespace QuestionsApi.Services;
public static class CcsCaseValidator public static class CcsCaseValidator
{ {
private static readonly HashSet<string> Modes = [CcsAttemptModes.Practice, CcsAttemptModes.Timed]; private static readonly HashSet<string> CommandTypes = ["exam", "order", "cancel_order", "location", "advance", "interval_history", "acknowledge_opening", "end_case", "exit_case"];
private static readonly HashSet<string> CommandTypes = ["exam", "order", "cancel_order", "location", "advance", "interval_history", "end_case", "exit_case"];
public static List<string> ValidateImport(CcsImportRequest request, IReadOnlySet<string> existingOrderCodes) public static List<string> ValidateImport(CcsImportRequest request, IReadOnlySet<string> existingOrderCodes)
{ {
@@ -18,6 +17,8 @@ public static class CcsCaseValidator
if (string.IsNullOrWhiteSpace(order.CanonicalName)) errors.Add(prefix + "canonicalName required"); if (string.IsNullOrWhiteSpace(order.CanonicalName)) errors.Add(prefix + "canonicalName required");
if (string.IsNullOrWhiteSpace(order.Category)) errors.Add(prefix + "category required"); if (string.IsNullOrWhiteSpace(order.Category)) errors.Add(prefix + "category required");
if (order.DefaultTurnaroundMinutes < 0) errors.Add(prefix + "defaultTurnaroundMinutes cannot be negative"); if (order.DefaultTurnaroundMinutes < 0) errors.Add(prefix + "defaultTurnaroundMinutes cannot be negative");
if (!string.IsNullOrWhiteSpace(order.ResultSection) && !CcsChartCategories.ResultSections.Contains(order.ResultSection))
errors.Add(prefix + "resultSection is invalid");
} }
if (request.Orders.Select(order => order.Code).Distinct(StringComparer.OrdinalIgnoreCase).Count() != request.Orders.Count) if (request.Orders.Select(order => order.Code).Distinct(StringComparer.OrdinalIgnoreCase).Count() != request.Orders.Count)
errors.Add("order codes must be unique within an import"); errors.Add("order codes must be unique within an import");
@@ -50,7 +51,11 @@ public static class CcsCaseValidator
if (eventIds.Any(string.IsNullOrWhiteSpace) || eventIds.Distinct(StringComparer.OrdinalIgnoreCase).Count() != eventIds.Count) if (eventIds.Any(string.IsNullOrWhiteSpace) || eventIds.Distinct(StringComparer.OrdinalIgnoreCase).Count() != eventIds.Count)
errors.Add(prefix + "event ids must be present and unique"); errors.Add(prefix + "event ids must be present and unique");
foreach (var response in item.OrderResponses) foreach (var response in item.OrderResponses)
{
if (!orderCodes.Contains(response.OrderCode)) errors.Add(prefix + $"unknown order code {response.OrderCode}"); if (!orderCodes.Contains(response.OrderCode)) errors.Add(prefix + $"unknown order code {response.OrderCode}");
if (!string.IsNullOrWhiteSpace(response.ResultSection) && !CcsChartCategories.ResultSections.Contains(response.ResultSection))
errors.Add(prefix + $"order response {response.OrderCode} resultSection is invalid");
}
foreach (var code in item.InitialChart.InitialOrderCodes.Concat(item.Events.SelectMany(value => value.RequiresOrderCodes.Concat(value.RequiresAnyOrderCodes)))) foreach (var code in item.InitialChart.InitialOrderCodes.Concat(item.Events.SelectMany(value => value.RequiresOrderCodes.Concat(value.RequiresAnyOrderCodes))))
if (!orderCodes.Contains(code)) errors.Add(prefix + $"unknown order code {code}"); if (!orderCodes.Contains(code)) errors.Add(prefix + $"unknown order code {code}");
var criteriaIds = item.ScoringCriteria.Select(value => value.Id).ToList(); var criteriaIds = item.ScoringCriteria.Select(value => value.Id).ToList();
@@ -75,7 +80,8 @@ public static class CcsCaseValidator
public static string? ValidateCreate(CreateCcsAttemptRequest request) public static string? ValidateCreate(CreateCcsAttemptRequest request)
{ {
if (!Modes.Contains(request.Mode)) return "mode must be practice or timed"; if (!request.Mode.Equals(CcsAttemptModes.Timed, StringComparison.OrdinalIgnoreCase))
return "new CCS attempts must be timed";
return null; return null;
} }
@@ -88,4 +94,29 @@ public static class CcsCaseValidator
if (request.Type == "advance" && request.Minutes is < 1 or > 525600) return "advance minutes must be between 1 and 525600"; if (request.Type == "advance" && request.Minutes is < 1 or > 525600) return "advance minutes must be between 1 and 525600";
return null; return null;
} }
public static string? ValidateBatch(CcsBatchCommandRequest request)
{
if (string.IsNullOrWhiteSpace(request.CommandId)) return "commandId required";
if (request.ExpectedRevision < 0) return "expectedRevision cannot be negative";
if (request.Commands.Count is < 1 or > 50) return "commands must contain between 1 and 50 items";
foreach (var item in request.Commands)
{
var error = ValidateCommand(new CcsCommandRequest
{
CommandId = request.CommandId,
ExpectedRevision = request.ExpectedRevision,
Type = item.Type,
Code = item.Code,
Minutes = item.Minutes
});
if (error is not null) return error;
if (item.Type is not ("exam" or "interval_history" or "order" or "cancel_order"))
return "batch commands support history/exams, orders, and order cancellations only";
}
var hasClinicalAssessment = request.Commands.Any(value => value.Type is "exam" or "interval_history");
if (hasClinicalAssessment && request.Commands.Any(value => value.Type is not ("exam" or "interval_history")))
return "history and examinations cannot be mixed with order changes in one batch";
return null;
}
} }
+205 -21
View File
@@ -115,16 +115,22 @@ public sealed class CcsStore
CaseExternalId = definition.ExternalId, CaseExternalId = definition.ExternalId,
CaseRevisionId = definition.Id ?? "", CaseRevisionId = definition.Id ?? "",
CaseVersion = definition.Version, CaseVersion = definition.Version,
Mode = request.Mode, Mode = CcsAttemptModes.Timed,
OpeningCompleted = false,
Location = definition.PresentingLocation, Location = definition.PresentingLocation,
CurrentVitals = new Dictionary<string, string>(definition.InitialChart.Vitals), CurrentVitals = new Dictionary<string, string>(definition.InitialChart.Vitals),
VitalHistory =
[
new CcsVitalSnapshot { SimulatedMinute = 0, Values = new Dictionary<string, string>(definition.InitialChart.Vitals) }
],
ActiveOrderCodes = definition.InitialChart.InitialOrderCodes.Distinct(StringComparer.OrdinalIgnoreCase).ToList(), ActiveOrderCodes = definition.InitialChart.InitialOrderCodes.Distinct(StringComparer.OrdinalIgnoreCase).ToList(),
DeadlineUtc = request.Mode == CcsAttemptModes.Timed ? now.AddMinutes(definition.RealTimeMinutes) : null, DeadlineUtc = now.AddMinutes(ActivePhaseMinutes(definition.RealTimeMinutes)),
RatingEligible = request.Mode == CcsAttemptModes.Timed, HardDeadlineUtc = now.AddMinutes(definition.RealTimeMinutes),
RatingEligible = true,
Chart = Chart =
[ [
new CcsChartEntry { Category = "progress", Text = definition.InitialChart.ReasonForEncounter }, new CcsChartEntry { Category = CcsChartCategories.Progress, Code = "reason_for_encounter", Title = "Reason for encounter", Text = definition.InitialChart.ReasonForEncounter },
new CcsChartEntry { Category = "progress", Text = definition.InitialChart.Appearance } new CcsChartEntry { Category = CcsChartCategories.Progress, Code = "initial_appearance", Title = "Initial appearance", Text = definition.InitialChart.Appearance }
] ]
}; };
try { await _attempts.InsertOneAsync(attempt); } try { await _attempts.InsertOneAsync(attempt); }
@@ -173,7 +179,12 @@ public sealed class CcsStore
if (attempt.State == CcsAttemptStates.Active && attempt.DeadlineUtc <= DateTime.UtcNow) if (attempt.State == CcsAttemptStates.Active && attempt.DeadlineUtc <= DateTime.UtcNow)
{ {
attempt.State = CcsAttemptStates.FinalOrders; attempt.State = CcsAttemptStates.FinalOrders;
attempt.FinalOrdersDeadlineUtc = DateTime.UtcNow.AddMinutes(2); attempt.FinalOrdersDeadlineUtc = FinalOrdersDeadline(DateTime.UtcNow, attempt.HardDeadlineUtc);
attempt.Chart.Add(new CcsChartEntry
{
Category = CcsChartCategories.Progress, Code = "case_end", Title = "Case-end instructions",
SimulatedMinute = attempt.SimulatedMinute, Text = "Finalize patient care orders."
});
attempt.Revision++; attempt.Revision++;
await SaveAsync(attempt); await SaveAsync(attempt);
} }
@@ -206,9 +217,16 @@ public sealed class CcsStore
case "advance": Advance(attempt, definition, command.Minutes); break; case "advance": Advance(attempt, definition, command.Minutes); break;
case "interval_history": case "interval_history":
attempt.Actions.Add(Action("interval_history", "interval_history", attempt.SimulatedMinute, now)); attempt.Actions.Add(Action("interval_history", "interval_history", attempt.SimulatedMinute, now));
attempt.Chart.Add(new CcsChartEntry { Category = "progress", SimulatedMinute = attempt.SimulatedMinute, Text = "The patient was reassessed for interval change." }); attempt.Chart.Add(new CcsChartEntry
{
Category = CcsChartCategories.Progress, Code = "interval_history", Title = "Interval history",
SimulatedMinute = attempt.SimulatedMinute, Text = "The patient was reassessed for interval change."
});
Advance(attempt, definition, 1); Advance(attempt, definition, 1);
break; break;
case "acknowledge_opening":
attempt.OpeningCompleted = true;
break;
case "end_case": EnterFinalOrders(attempt); break; case "end_case": EnterFinalOrders(attempt); break;
case "exit_case": await CompleteAsync(attempt); break; case "exit_case": await CompleteAsync(attempt); break;
} }
@@ -220,6 +238,63 @@ public sealed class CcsStore
return attempt; return attempt;
} }
public async Task<CcsAttempt> ApplyBatchCommandAsync(CcsAttempt attempt, CcsBatchCommandRequest request)
{
var validation = CcsCaseValidator.ValidateBatch(request);
if (validation is not null) throw new ArgumentException(validation);
if (attempt.ProcessedCommandIds.Contains(request.CommandId, StringComparer.Ordinal)) return attempt;
if (attempt.Revision != request.ExpectedRevision) throw new InvalidOperationException("Attempt revision is stale.");
if (attempt.State is CcsAttemptStates.Completed or CcsAttemptStates.Abandoned)
throw new InvalidOperationException("The CCS attempt is closed.");
if (attempt.State == CcsAttemptStates.FinalOrders && request.Commands.Any(value => value.Type is not ("order" or "cancel_order")))
throw new InvalidOperationException("Only final orders may be changed now.");
var definition = await DefinitionAsync(attempt);
var now = DateTime.UtcNow;
if (request.Commands.Any(value => value.Type is "exam" or "interval_history"))
{
var selected = request.Commands.Where(item => item.Type == "exam").Select(item =>
definition.Exams.FirstOrDefault(value => value.Code.Equals(item.Code, StringComparison.OrdinalIgnoreCase))
?? throw new ArgumentException($"Unknown examination component: {item.Code}.")).ToList();
var includeHistory = request.Commands.Any(item => item.Type == "interval_history");
var startingMinute = attempt.SimulatedMinute;
foreach (var exam in selected)
attempt.Actions.Add(Action("exam", exam.Code, startingMinute, now));
if (includeHistory)
attempt.Actions.Add(Action("interval_history", "interval_history", startingMinute, now));
Advance(attempt, definition, selected.Sum(value => Math.Max(1, value.DurationMinutes)) + (includeHistory ? 1 : 0));
if (includeHistory)
attempt.Chart.Add(new CcsChartEntry
{
Category = CcsChartCategories.Progress, Code = "interval_history", Title = "Interval history",
SimulatedMinute = attempt.SimulatedMinute, Text = "The patient was reassessed for interval change."
});
foreach (var exam in selected)
attempt.Chart.Add(new CcsChartEntry
{
Category = CcsChartCategories.Progress, Code = exam.Code, Title = exam.Name,
SimulatedMinute = attempt.SimulatedMinute, Text = exam.Finding
});
}
else
{
foreach (var item in request.Commands)
{
if (item.Type == "order") await ApplyOrderAsync(attempt, definition, item.Code, now, false);
else CancelOrder(attempt, item.Code, now);
}
ProcessDue(attempt);
}
attempt.ProcessedCommandIds.Add(request.CommandId);
if (attempt.ProcessedCommandIds.Count > 100)
attempt.ProcessedCommandIds.RemoveRange(0, attempt.ProcessedCommandIds.Count - 100);
attempt.Revision++;
attempt.UpdatedUtc = now;
await SaveAsync(attempt);
return attempt;
}
public async Task<CcsHistoryPage> HistoryAsync(string userId, int limit, string status = "all", string sort = "newest", string? cursor = null) public async Task<CcsHistoryPage> HistoryAsync(string userId, int limit, string status = "all", string sort = "newest", string? cursor = null)
{ {
limit = Math.Clamp(limit, 1, 50); limit = Math.Clamp(limit, 1, 50);
@@ -314,19 +389,49 @@ public sealed class CcsStore
{ {
var definition = await DefinitionAsync(attempt); var definition = await DefinitionAsync(attempt);
var completed = attempt.State == CcsAttemptStates.Completed; var completed = attempt.State == CcsAttemptStates.Completed;
List<CcsOrderDefinition> activeCatalog = attempt.ActiveOrderCodes.Count == 0
? []
: await _orders.Find(value => attempt.ActiveOrderCodes.Contains(value.Code)).ToListAsync();
var activeOrders = activeCatalog.Select(order =>
{
var orderedMinute = attempt.Actions.LastOrDefault(value =>
value.Type == "order" && value.Code.Equals(order.Code, StringComparison.OrdinalIgnoreCase))?.SimulatedMinute ?? 0;
var pending = attempt.PendingResults.FirstOrDefault(value =>
value.OrderCode.Equals(order.Code, StringComparison.OrdinalIgnoreCase));
var reported = attempt.Chart.LastOrDefault(value =>
value.Code.Equals(order.Code, StringComparison.OrdinalIgnoreCase)
&& value.Category is not CcsChartCategories.Orders);
return new CcsActiveOrder
{
Code = order.Code, Name = order.CanonicalName, Category = order.Category,
Route = order.DisplayRoute, Frequency = order.DisplayFrequency, OrderedMinute = orderedMinute,
ReportMinute = pending is not null ? null : reported?.SimulatedMinute,
IsPending = pending is not null, HasResult = reported is not null
};
}).OrderBy(value => value.OrderedMinute).ThenBy(value => value.Name).ToList();
List<CcsVitalSnapshot> vitalHistory = attempt.VitalHistory.Count > 0
? attempt.VitalHistory
: [new CcsVitalSnapshot { SimulatedMinute = attempt.SimulatedMinute, Values = new Dictionary<string, string>(attempt.CurrentVitals) }];
return new CcsAttemptResponse return new CcsAttemptResponse
{ {
Id = attempt.Id ?? "", CaseExternalId = attempt.CaseExternalId, Id = attempt.Id ?? "", CaseExternalId = attempt.CaseExternalId,
CaseTitle = completed ? definition.Title : $"CCS Case {attempt.CaseExternalId}", CaseTitle = completed ? definition.Title : $"CCS Case {attempt.CaseExternalId}",
Mode = attempt.Mode, State = attempt.State, Revision = attempt.Revision, Mode = attempt.Mode, State = attempt.State, Revision = attempt.Revision,
SimulatedMinute = attempt.SimulatedMinute, Location = attempt.Location, SimulatedMinute = attempt.SimulatedMinute, SimulatedStartUtc = definition.InitialChart.SimulatedStartUtc,
DeadlineUtc = attempt.DeadlineUtc, FinalOrdersDeadlineUtc = attempt.FinalOrdersDeadlineUtc, OpeningCompleted = attempt.OpeningCompleted ?? true, Location = attempt.Location,
DeadlineUtc = attempt.DeadlineUtc, HardDeadlineUtc = attempt.HardDeadlineUtc,
FinalOrdersDeadlineUtc = attempt.FinalOrdersDeadlineUtc,
CreatedUtc = attempt.CreatedUtc, RealTimeMinutes = definition.RealTimeMinutes, CreatedUtc = attempt.CreatedUtc, RealTimeMinutes = definition.RealTimeMinutes,
ElapsedSeconds = Math.Max(0, (int)Math.Round(((attempt.CompletedUtc ?? DateTime.UtcNow) - attempt.CreatedUtc).TotalSeconds)), ElapsedSeconds = Math.Max(0, (int)Math.Round(((attempt.CompletedUtc ?? DateTime.UtcNow) - attempt.CreatedUtc).TotalSeconds)),
PatientSummary = definition.InitialChart.PatientSummary, ReasonForEncounter = definition.InitialChart.ReasonForEncounter, PatientSummary = definition.InitialChart.PatientSummary, ReasonForEncounter = definition.InitialChart.ReasonForEncounter,
Appearance = definition.InitialChart.Appearance, History = definition.InitialChart.History, Appearance = definition.InitialChart.Appearance, History = definition.InitialChart.History,
HistorySections = definition.InitialChart.HistorySections,
Vitals = attempt.CurrentVitals, Chart = attempt.Chart, ActiveOrderCodes = attempt.ActiveOrderCodes, Vitals = attempt.CurrentVitals, Chart = attempt.Chart, ActiveOrderCodes = attempt.ActiveOrderCodes,
PendingResults = attempt.PendingResults.Select(value => new CcsPendingResult { OrderCode = value.OrderCode, DueMinute = value.DueMinute }).ToList(), ActiveOrders = activeOrders, VitalHistory = vitalHistory,
PendingResults = attempt.PendingResults.Select(value => new CcsPendingResult
{
OrderCode = value.OrderCode, DueMinute = value.DueMinute, Category = value.Category, Title = value.Title
}).ToList(),
AvailableExams = attempt.State == CcsAttemptStates.Active ? definition.Exams : [], AvailableExams = attempt.State == CcsAttemptStates.Active ? definition.Exams : [],
AllowedLocations = attempt.State == CcsAttemptStates.Active ? definition.AllowedLocations : [], AllowedLocations = attempt.State == CcsAttemptStates.Active ? definition.AllowedLocations : [],
Score = completed ? attempt.Score : null, Rating = completed ? attempt.Rating : null, Score = completed ? attempt.Score : null, Rating = completed ? attempt.Rating : null,
@@ -342,7 +447,7 @@ public sealed class CcsStore
await SaveAsync(attempt); await SaveAsync(attempt);
} }
private async Task ApplyOrderAsync(CcsAttempt attempt, CcsCaseRevision definition, string code, DateTime now) private async Task ApplyOrderAsync(CcsAttempt attempt, CcsCaseRevision definition, string code, DateTime now, bool processImmediately = true)
{ {
code = code.Trim().ToLowerInvariant(); code = code.Trim().ToLowerInvariant();
var catalog = await _orders.Find(value => value.Code == code).FirstOrDefaultAsync() ?? throw new ArgumentException("Unknown order code."); var catalog = await _orders.Find(value => value.Code == code).FirstOrDefaultAsync() ?? throw new ArgumentException("Unknown order code.");
@@ -352,17 +457,30 @@ public sealed class CcsStore
if (!attempt.ActiveOrderCodes.Contains(code, StringComparer.OrdinalIgnoreCase)) attempt.ActiveOrderCodes.Add(code); if (!attempt.ActiveOrderCodes.Contains(code, StringComparer.OrdinalIgnoreCase)) attempt.ActiveOrderCodes.Add(code);
var response = definition.OrderResponses.FirstOrDefault(value => value.OrderCode.Equals(code, StringComparison.OrdinalIgnoreCase)); var response = definition.OrderResponses.FirstOrDefault(value => value.OrderCode.Equals(code, StringComparison.OrdinalIgnoreCase));
var delay = response?.TurnaroundMinutes ?? catalog.DefaultTurnaroundMinutes; var delay = response?.TurnaroundMinutes ?? catalog.DefaultTurnaroundMinutes;
var resultCategory = ResultCategory(response?.ResultSection, catalog.ResultSection, catalog.Category);
if (response is not null && (!string.IsNullOrWhiteSpace(response.Result) || response.VitalPatches.Count > 0)) if (response is not null && (!string.IsNullOrWhiteSpace(response.Result) || response.VitalPatches.Count > 0))
attempt.PendingResults.Add(new CcsPendingResult { OrderCode = code, DueMinute = attempt.SimulatedMinute + delay, Result = response.Result, VitalPatches = response.VitalPatches }); attempt.PendingResults.Add(new CcsPendingResult
attempt.Chart.Add(new CcsChartEntry { Category = "orders", SimulatedMinute = attempt.SimulatedMinute, Text = $"Ordered: {catalog.CanonicalName}" }); {
if (delay == 0) ProcessDue(attempt); OrderCode = code, Category = resultCategory, Title = catalog.CanonicalName,
DueMinute = attempt.SimulatedMinute + delay, Result = response.Result, VitalPatches = response.VitalPatches
});
attempt.Chart.Add(new CcsChartEntry
{
Category = CcsChartCategories.Orders, Code = code, Title = catalog.CanonicalName,
SimulatedMinute = attempt.SimulatedMinute, Text = $"Ordered: {catalog.CanonicalName}"
});
if (delay == 0 && processImmediately) ProcessDue(attempt);
} }
private static void ApplyExam(CcsAttempt attempt, CcsCaseRevision definition, string code, DateTime now) private static void ApplyExam(CcsAttempt attempt, CcsCaseRevision definition, string code, DateTime now)
{ {
var exam = definition.Exams.FirstOrDefault(value => value.Code.Equals(code, StringComparison.OrdinalIgnoreCase)) ?? throw new ArgumentException("Unknown examination component."); var exam = definition.Exams.FirstOrDefault(value => value.Code.Equals(code, StringComparison.OrdinalIgnoreCase)) ?? throw new ArgumentException("Unknown examination component.");
attempt.Actions.Add(Action("exam", exam.Code, attempt.SimulatedMinute, now)); attempt.Actions.Add(Action("exam", exam.Code, attempt.SimulatedMinute, now));
attempt.Chart.Add(new CcsChartEntry { Category = "exam", SimulatedMinute = attempt.SimulatedMinute, Text = $"{exam.Name}: {exam.Finding}" }); attempt.Chart.Add(new CcsChartEntry
{
Category = CcsChartCategories.Progress, Code = exam.Code, Title = exam.Name,
SimulatedMinute = attempt.SimulatedMinute, Text = exam.Finding
});
Advance(attempt, definition, Math.Max(1, exam.DurationMinutes)); Advance(attempt, definition, Math.Max(1, exam.DurationMinutes));
} }
@@ -371,7 +489,11 @@ public sealed class CcsStore
attempt.Actions.Add(Action("cancel_order", code, attempt.SimulatedMinute, now)); attempt.Actions.Add(Action("cancel_order", code, attempt.SimulatedMinute, now));
attempt.ActiveOrderCodes.RemoveAll(value => value.Equals(code, StringComparison.OrdinalIgnoreCase)); attempt.ActiveOrderCodes.RemoveAll(value => value.Equals(code, StringComparison.OrdinalIgnoreCase));
attempt.PendingResults.RemoveAll(value => value.OrderCode.Equals(code, StringComparison.OrdinalIgnoreCase)); attempt.PendingResults.RemoveAll(value => value.OrderCode.Equals(code, StringComparison.OrdinalIgnoreCase));
attempt.Chart.Add(new CcsChartEntry { Category = "orders", SimulatedMinute = attempt.SimulatedMinute, Text = $"Canceled: {code}" }); attempt.Chart.Add(new CcsChartEntry
{
Category = CcsChartCategories.Orders, Code = code, Title = Humanize(code),
SimulatedMinute = attempt.SimulatedMinute, Text = $"Canceled: {Humanize(code)}"
});
} }
private static void ChangeLocation(CcsAttempt attempt, CcsCaseRevision definition, string location, DateTime now) private static void ChangeLocation(CcsAttempt attempt, CcsCaseRevision definition, string location, DateTime now)
@@ -379,7 +501,11 @@ public sealed class CcsStore
if (!definition.AllowedLocations.Contains(location, StringComparer.OrdinalIgnoreCase)) throw new ArgumentException("Unsupported location."); if (!definition.AllowedLocations.Contains(location, StringComparer.OrdinalIgnoreCase)) throw new ArgumentException("Unsupported location.");
attempt.Location = location; attempt.Location = location;
attempt.Actions.Add(Action("location", location, attempt.SimulatedMinute, now)); attempt.Actions.Add(Action("location", location, attempt.SimulatedMinute, now));
attempt.Chart.Add(new CcsChartEntry { Category = "progress", SimulatedMinute = attempt.SimulatedMinute, Text = $"Patient moved to {location}." }); attempt.Chart.Add(new CcsChartEntry
{
Category = CcsChartCategories.Progress, Code = "location", Title = "Location change",
SimulatedMinute = attempt.SimulatedMinute, Text = $"Patient moved to {Humanize(location)}."
});
} }
private static void Advance(CcsAttempt attempt, CcsCaseRevision definition, int minutes) private static void Advance(CcsAttempt attempt, CcsCaseRevision definition, int minutes)
@@ -403,13 +529,20 @@ public sealed class CcsStore
private static void ProcessDue(CcsAttempt attempt) private static void ProcessDue(CcsAttempt attempt)
{ {
var due = attempt.PendingResults.Where(value => value.DueMinute <= attempt.SimulatedMinute).ToList(); var due = attempt.PendingResults.Where(value => value.DueMinute <= attempt.SimulatedMinute).ToList();
var vitalsChanged = false;
foreach (var result in due) foreach (var result in due)
{ {
if (!string.IsNullOrWhiteSpace(result.Result)) if (!string.IsNullOrWhiteSpace(result.Result))
attempt.Chart.Add(new CcsChartEntry { Category = "results", SimulatedMinute = attempt.SimulatedMinute, Text = result.Result }); attempt.Chart.Add(new CcsChartEntry
{
Category = string.IsNullOrWhiteSpace(result.Category) ? CcsChartCategories.OtherTest : result.Category,
Code = result.OrderCode, Title = result.Title, SimulatedMinute = attempt.SimulatedMinute, Text = result.Result
});
foreach (var patch in result.VitalPatches) attempt.CurrentVitals[patch.Key] = patch.Value; foreach (var patch in result.VitalPatches) attempt.CurrentVitals[patch.Key] = patch.Value;
vitalsChanged |= result.VitalPatches.Count > 0;
attempt.PendingResults.Remove(result); attempt.PendingResults.Remove(result);
} }
if (vitalsChanged) SnapshotVitals(attempt);
} }
private static bool ProcessEvents(CcsAttempt attempt, CcsCaseRevision definition) private static bool ProcessEvents(CcsAttempt attempt, CcsCaseRevision definition)
@@ -419,13 +552,20 @@ public sealed class CcsStore
&& value.TriggerMinute <= attempt.SimulatedMinute && value.TriggerMinute <= attempt.SimulatedMinute
&& value.RequiresOrderCodes.All(activeOrders.Contains) && value.RequiresOrderCodes.All(activeOrders.Contains)
&& (value.RequiresAnyOrderCodes.Count == 0 || value.RequiresAnyOrderCodes.Any(activeOrders.Contains))).ToList(); && (value.RequiresAnyOrderCodes.Count == 0 || value.RequiresAnyOrderCodes.Any(activeOrders.Contains))).ToList();
var vitalsChanged = false;
foreach (var item in events) foreach (var item in events)
{ {
attempt.ProcessedEventIds.Add(item.Id); attempt.ProcessedEventIds.Add(item.Id);
foreach (var patch in item.VitalPatches) attempt.CurrentVitals[patch.Key] = patch.Value; foreach (var patch in item.VitalPatches) attempt.CurrentVitals[patch.Key] = patch.Value;
if (!string.IsNullOrWhiteSpace(item.Message)) attempt.Chart.Add(new CcsChartEntry { Category = "progress", SimulatedMinute = attempt.SimulatedMinute, Text = item.Message }); vitalsChanged |= item.VitalPatches.Count > 0;
if (!string.IsNullOrWhiteSpace(item.Message)) attempt.Chart.Add(new CcsChartEntry
{
Category = CcsChartCategories.Progress, Code = item.Id, Title = "Clinical update",
SimulatedMinute = attempt.SimulatedMinute, Text = item.Message
});
if (item.EndCase) EnterFinalOrders(attempt); if (item.EndCase) EnterFinalOrders(attempt);
} }
if (vitalsChanged) SnapshotVitals(attempt);
return events.Any(value => !string.IsNullOrWhiteSpace(value.Message) || value.EndCase); return events.Any(value => !string.IsNullOrWhiteSpace(value.Message) || value.EndCase);
} }
@@ -433,9 +573,21 @@ public sealed class CcsStore
{ {
if (attempt.State != CcsAttemptStates.Active) return; if (attempt.State != CcsAttemptStates.Active) return;
attempt.State = CcsAttemptStates.FinalOrders; attempt.State = CcsAttemptStates.FinalOrders;
if (attempt.HardDeadlineUtc is not null)
{
attempt.FinalOrdersDeadlineUtc = FinalOrdersDeadline(DateTime.UtcNow, attempt.HardDeadlineUtc);
}
else
{
var remaining = attempt.DeadlineUtc is null ? TimeSpan.FromMinutes(2) : attempt.DeadlineUtc.Value - DateTime.UtcNow; var remaining = attempt.DeadlineUtc is null ? TimeSpan.FromMinutes(2) : attempt.DeadlineUtc.Value - DateTime.UtcNow;
attempt.FinalOrdersDeadlineUtc = DateTime.UtcNow.Add(remaining < TimeSpan.FromMinutes(2) ? TimeSpan.FromSeconds(Math.Max(0, remaining.TotalSeconds)) : TimeSpan.FromMinutes(2)); attempt.FinalOrdersDeadlineUtc = DateTime.UtcNow.Add(
attempt.Chart.Add(new CcsChartEntry { Category = "progress", SimulatedMinute = attempt.SimulatedMinute, Text = "Case-end instructions: finalize patient care orders." }); remaining < TimeSpan.FromMinutes(2) ? TimeSpan.FromSeconds(Math.Max(0, remaining.TotalSeconds)) : TimeSpan.FromMinutes(2));
}
attempt.Chart.Add(new CcsChartEntry
{
Category = CcsChartCategories.Progress, Code = "case_end", Title = "Case-end instructions",
SimulatedMinute = attempt.SimulatedMinute, Text = "Finalize patient care orders."
});
} }
private async Task CompleteAsync(CcsAttempt attempt) private async Task CompleteAsync(CcsAttempt attempt)
@@ -489,12 +641,44 @@ public sealed class CcsStore
}; };
} }
public static int ActivePhaseMinutes(int totalMinutes) => Math.Max(1, totalMinutes - 2);
public static DateTime FinalOrdersDeadline(DateTime now, DateTime? hardDeadline)
{
var proposed = now.AddMinutes(2);
return hardDeadline is null || proposed <= hardDeadline ? proposed : hardDeadline.Value;
}
public async Task<CcsCaseRevision> DefinitionAsync(CcsAttempt attempt) => public async Task<CcsCaseRevision> DefinitionAsync(CcsAttempt attempt) =>
await _cases.Find(value => value.Id == attempt.CaseRevisionId).FirstOrDefaultAsync() await _cases.Find(value => value.Id == attempt.CaseRevisionId).FirstOrDefaultAsync()
?? throw new InvalidOperationException("The case revision is unavailable."); ?? throw new InvalidOperationException("The case revision is unavailable.");
private Task SaveAsync(CcsAttempt attempt) => _attempts.ReplaceOneAsync(value => value.Id == attempt.Id, attempt); 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 CcsAttemptAction Action(string type, string code, int minute, DateTime now) => new() { Type = type, Code = code, SimulatedMinute = minute, RealUtc = now };
private static void SnapshotVitals(CcsAttempt attempt)
{
if (attempt.VitalHistory.Count > 0
&& attempt.VitalHistory[^1].SimulatedMinute == attempt.SimulatedMinute
&& attempt.VitalHistory[^1].Values.OrderBy(value => value.Key).SequenceEqual(attempt.CurrentVitals.OrderBy(value => value.Key)))
return;
attempt.VitalHistory.Add(new CcsVitalSnapshot
{
SimulatedMinute = attempt.SimulatedMinute,
Values = new Dictionary<string, string>(attempt.CurrentVitals)
});
}
private static string ResultCategory(string? responseSection, string catalogSection, string catalogCategory)
{
var explicitSection = string.IsNullOrWhiteSpace(responseSection) ? catalogSection : responseSection;
if (!string.IsNullOrWhiteSpace(explicitSection)) return explicitSection;
return catalogCategory.ToLowerInvariant() switch
{
"laboratory" => CcsChartCategories.Lab,
"imaging" => CcsChartCategories.Imaging,
"medication" or "procedure" or "monitoring" => CcsChartCategories.Treatment,
_ => CcsChartCategories.OtherTest
};
}
private static string Humanize(string value) => string.Join(' ', value.Split('_', StringSplitOptions.RemoveEmptyEntries).Select(word => char.ToUpperInvariant(word[0]) + word[1..])); 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) private static string DescribeExpected(CcsScoringCriterion criterion, List<string> targets)
{ {
+28 -21
View File
@@ -1,25 +1,25 @@
{ {
"orders": [ "orders": [
{ "code": "iv_access", "canonicalName": "Peripheral intravenous access", "category": "procedure", "aliases": ["iv", "intravenous line"], "defaultTurnaroundMinutes": 0, "availableLocations": ["emergency_department", "icu"] }, { "code": "iv_access", "canonicalName": "Peripheral intravenous access", "category": "procedure", "aliases": ["iv", "intravenous line"], "defaultTurnaroundMinutes": 0, "availableLocations": ["emergency_department", "icu"], "displayRoute": "Peripheral IV", "displayFrequency": "Once", "resultSection": "treatment" },
{ "code": "cardiac_monitor", "canonicalName": "Continuous cardiac monitoring", "category": "monitoring", "aliases": ["telemetry", "cardiac monitor"], "defaultTurnaroundMinutes": 0, "availableLocations": ["emergency_department", "icu"] }, { "code": "cardiac_monitor", "canonicalName": "Continuous cardiac monitoring", "category": "monitoring", "aliases": ["telemetry", "cardiac monitor"], "defaultTurnaroundMinutes": 0, "availableLocations": ["emergency_department", "icu"], "displayRoute": "", "displayFrequency": "Continuous", "resultSection": "treatment" },
{ "code": "pulse_oximetry", "canonicalName": "Continuous pulse oximetry", "category": "monitoring", "aliases": ["pulse ox", "oxygen saturation"], "defaultTurnaroundMinutes": 0, "availableLocations": ["emergency_department", "icu"] }, { "code": "pulse_oximetry", "canonicalName": "Continuous pulse oximetry", "category": "monitoring", "aliases": ["pulse ox", "oxygen saturation"], "defaultTurnaroundMinutes": 0, "availableLocations": ["emergency_department", "icu"], "displayRoute": "", "displayFrequency": "Continuous", "resultSection": "treatment" },
{ "code": "normal_saline_bolus", "canonicalName": "0.9% sodium chloride intravenous bolus", "category": "medication", "aliases": ["normal saline", "isotonic saline", "fluid bolus"], "defaultTurnaroundMinutes": 0, "availableLocations": ["emergency_department", "icu"] }, { "code": "normal_saline_bolus", "canonicalName": "0.9% sodium chloride intravenous bolus", "category": "medication", "aliases": ["normal saline", "isotonic saline", "fluid bolus"], "defaultTurnaroundMinutes": 0, "availableLocations": ["emergency_department", "icu"], "displayRoute": "IV", "displayFrequency": "Bolus", "resultSection": "treatment" },
{ "code": "balanced_crystalloid_bolus", "canonicalName": "Balanced crystalloid intravenous bolus", "category": "medication", "aliases": ["lactated ringers", "ringer lactate", "iv fluids"], "defaultTurnaroundMinutes": 0, "availableLocations": ["emergency_department", "icu"] }, { "code": "balanced_crystalloid_bolus", "canonicalName": "Balanced crystalloid intravenous bolus", "category": "medication", "aliases": ["lactated ringers", "ringer lactate", "iv fluids"], "defaultTurnaroundMinutes": 0, "availableLocations": ["emergency_department", "icu"], "displayRoute": "IV", "displayFrequency": "Bolus", "resultSection": "treatment" },
{ "code": "bmp", "canonicalName": "Basic metabolic panel", "category": "laboratory", "aliases": ["electrolytes", "chemistry", "potassium", "creatinine"], "defaultTurnaroundMinutes": 12, "availableLocations": ["emergency_department", "icu", "ward"] }, { "code": "bmp", "canonicalName": "Basic metabolic panel", "category": "laboratory", "aliases": ["electrolytes", "chemistry", "potassium", "creatinine"], "defaultTurnaroundMinutes": 12, "availableLocations": ["emergency_department", "icu", "ward"], "displayRoute": "Blood", "displayFrequency": "Once", "resultSection": "lab" },
{ "code": "venous_blood_gas", "canonicalName": "Venous blood gas", "category": "laboratory", "aliases": ["vbg", "venous ph"], "defaultTurnaroundMinutes": 8, "availableLocations": ["emergency_department", "icu"] }, { "code": "venous_blood_gas", "canonicalName": "Venous blood gas", "category": "laboratory", "aliases": ["vbg", "venous ph"], "defaultTurnaroundMinutes": 8, "availableLocations": ["emergency_department", "icu"], "displayRoute": "Venous blood", "displayFrequency": "Once", "resultSection": "lab" },
{ "code": "beta_hydroxybutyrate", "canonicalName": "Serum beta-hydroxybutyrate", "category": "laboratory", "aliases": ["ketones", "serum ketones", "bohb"], "defaultTurnaroundMinutes": 15, "availableLocations": ["emergency_department", "icu", "ward"] }, { "code": "beta_hydroxybutyrate", "canonicalName": "Serum beta-hydroxybutyrate", "category": "laboratory", "aliases": ["ketones", "serum ketones", "bohb"], "defaultTurnaroundMinutes": 15, "availableLocations": ["emergency_department", "icu", "ward"], "displayRoute": "Blood", "displayFrequency": "Once", "resultSection": "lab" },
{ "code": "cbc", "canonicalName": "Complete blood count", "category": "laboratory", "aliases": ["cbc", "blood count"], "defaultTurnaroundMinutes": 12, "availableLocations": [] }, { "code": "cbc", "canonicalName": "Complete blood count", "category": "laboratory", "aliases": ["cbc", "blood count"], "defaultTurnaroundMinutes": 12, "availableLocations": [], "displayRoute": "Blood", "displayFrequency": "Once", "resultSection": "lab" },
{ "code": "urinalysis", "canonicalName": "Urinalysis", "category": "laboratory", "aliases": ["urine test", "ua"], "defaultTurnaroundMinutes": 15, "availableLocations": [] }, { "code": "urinalysis", "canonicalName": "Urinalysis", "category": "laboratory", "aliases": ["urine test", "ua"], "defaultTurnaroundMinutes": 15, "availableLocations": [], "displayRoute": "Urine", "displayFrequency": "Once", "resultSection": "lab" },
{ "code": "pregnancy_test", "canonicalName": "Serum beta-hCG pregnancy test", "category": "laboratory", "aliases": ["pregnancy test", "hcg"], "defaultTurnaroundMinutes": 15, "availableLocations": [] }, { "code": "pregnancy_test", "canonicalName": "Serum beta-hCG pregnancy test", "category": "laboratory", "aliases": ["pregnancy test", "hcg"], "defaultTurnaroundMinutes": 15, "availableLocations": [], "displayRoute": "Blood", "displayFrequency": "Once", "resultSection": "lab" },
{ "code": "ecg", "canonicalName": "12-lead electrocardiogram", "category": "diagnostic", "aliases": ["ekg", "electrocardiogram"], "defaultTurnaroundMinutes": 10, "availableLocations": ["emergency_department", "icu", "ward"] }, { "code": "ecg", "canonicalName": "12-lead electrocardiogram", "category": "diagnostic", "aliases": ["ekg", "electrocardiogram"], "defaultTurnaroundMinutes": 10, "availableLocations": ["emergency_department", "icu", "ward"], "displayRoute": "", "displayFrequency": "Once", "resultSection": "other_test" },
{ "code": "insulin_infusion", "canonicalName": "Regular insulin intravenous infusion", "category": "medication", "aliases": ["insulin drip", "iv insulin", "regular insulin"], "defaultTurnaroundMinutes": 0, "availableLocations": ["emergency_department", "icu"] }, { "code": "insulin_infusion", "canonicalName": "Regular insulin intravenous infusion", "category": "medication", "aliases": ["insulin drip", "iv insulin", "regular insulin"], "defaultTurnaroundMinutes": 0, "availableLocations": ["emergency_department", "icu"], "displayRoute": "IV", "displayFrequency": "Continuous", "resultSection": "treatment" },
{ "code": "potassium_chloride_iv", "canonicalName": "Potassium chloride added to intravenous fluid", "category": "medication", "aliases": ["potassium replacement", "kcl", "replace potassium"], "defaultTurnaroundMinutes": 0, "availableLocations": ["emergency_department", "icu"] }, { "code": "potassium_chloride_iv", "canonicalName": "Potassium chloride added to intravenous fluid", "category": "medication", "aliases": ["potassium replacement", "kcl", "replace potassium"], "defaultTurnaroundMinutes": 0, "availableLocations": ["emergency_department", "icu"], "displayRoute": "IV", "displayFrequency": "Continuous", "resultSection": "treatment" },
{ "code": "dextrose_half_normal_saline", "canonicalName": "Dextrose 5% in 0.45% sodium chloride", "category": "medication", "aliases": ["d5 half normal", "dextrose fluid", "d5 0.45 saline"], "defaultTurnaroundMinutes": 0, "availableLocations": ["emergency_department", "icu"] }, { "code": "dextrose_half_normal_saline", "canonicalName": "Dextrose 5% in 0.45% sodium chloride", "category": "medication", "aliases": ["d5 half normal", "dextrose fluid", "d5 0.45 saline"], "defaultTurnaroundMinutes": 0, "availableLocations": ["emergency_department", "icu"], "displayRoute": "IV", "displayFrequency": "Continuous", "resultSection": "treatment" },
{ "code": "sodium_bicarbonate_iv", "canonicalName": "Intravenous sodium bicarbonate", "category": "medication", "aliases": ["bicarbonate", "bicarb"], "defaultTurnaroundMinutes": 0, "availableLocations": ["emergency_department", "icu"] }, { "code": "sodium_bicarbonate_iv", "canonicalName": "Intravenous sodium bicarbonate", "category": "medication", "aliases": ["bicarbonate", "bicarb"], "defaultTurnaroundMinutes": 0, "availableLocations": ["emergency_department", "icu"], "displayRoute": "IV", "displayFrequency": "Once", "resultSection": "treatment" },
{ "code": "ceftriaxone", "canonicalName": "Intravenous ceftriaxone", "category": "medication", "aliases": ["rocephin", "antibiotic"], "defaultTurnaroundMinutes": 0, "availableLocations": [] }, { "code": "ceftriaxone", "canonicalName": "Intravenous ceftriaxone", "category": "medication", "aliases": ["rocephin", "antibiotic"], "defaultTurnaroundMinutes": 0, "availableLocations": [], "displayRoute": "IV", "displayFrequency": "Once", "resultSection": "treatment" },
{ "code": "serial_bmp", "canonicalName": "Basic metabolic panel every 4 hours", "category": "laboratory", "aliases": ["repeat electrolytes", "serial chemistry", "monitor potassium"], "defaultTurnaroundMinutes": 0, "availableLocations": ["emergency_department", "icu"] }, { "code": "serial_bmp", "canonicalName": "Basic metabolic panel every 4 hours", "category": "laboratory", "aliases": ["repeat electrolytes", "serial chemistry", "monitor potassium"], "defaultTurnaroundMinutes": 0, "availableLocations": ["emergency_department", "icu"], "displayRoute": "Blood", "displayFrequency": "Every 4 hours", "resultSection": "lab" },
{ "code": "hourly_glucose", "canonicalName": "Capillary glucose every hour", "category": "monitoring", "aliases": ["hourly glucose", "fingerstick glucose", "poc glucose"], "defaultTurnaroundMinutes": 0, "availableLocations": ["emergency_department", "icu"] }, { "code": "hourly_glucose", "canonicalName": "Capillary glucose every hour", "category": "monitoring", "aliases": ["hourly glucose", "fingerstick glucose", "poc glucose"], "defaultTurnaroundMinutes": 0, "availableLocations": ["emergency_department", "icu"], "displayRoute": "Capillary", "displayFrequency": "Every hour", "resultSection": "treatment" },
{ "code": "icu_admission", "canonicalName": "Admit to intensive care unit", "category": "disposition", "aliases": ["icu", "intensive care admission"], "defaultTurnaroundMinutes": 0, "availableLocations": ["emergency_department"] } { "code": "icu_admission", "canonicalName": "Admit to intensive care unit", "category": "disposition", "aliases": ["icu", "intensive care admission"], "defaultTurnaroundMinutes": 0, "availableLocations": ["emergency_department"], "displayRoute": "", "displayFrequency": "Once", "resultSection": "treatment" }
], ],
"cases": [ "cases": [
{ {
@@ -36,7 +36,14 @@
"reasonForEncounter": "Two days of vomiting, abdominal discomfort, and progressive lethargy after stopping insulin when oral intake declined.", "reasonForEncounter": "Two days of vomiting, abdominal discomfort, and progressive lethargy after stopping insulin when oral intake declined.",
"appearance": "She is lethargic but arousable. Mucous membranes are dry, and respirations are deep and rapid.", "appearance": "She is lethargic but arousable. Mucous membranes are dry, and respirations are deep and rapid.",
"history": "She uses basal-bolus insulin and has no known kidney disease. She reports no dysuria, cough, diarrhea, alcohol use, or pregnancy symptoms. She has taken no insulin for approximately 36 hours.", "history": "She uses basal-bolus insulin and has no known kidney disease. She reports no dysuria, cough, diarrhea, alcohol use, or pregnancy symptoms. She has taken no insulin for approximately 36 hours.",
"vitals": { "temperature": "37.3 C (99.1 F)", "bloodPressure": "88/56 mm Hg", "pulse": "118/min", "respirations": "28/min", "oxygenSaturation": "98% on room air" }, "historySections": [
{ "code": "hpi", "title": "History of present illness", "text": "Two days of vomiting, abdominal discomfort, and progressive lethargy followed reduced oral intake. She stopped all insulin approximately 36 hours ago." },
{ "code": "past_medical_history", "title": "Past medical history", "text": "Type 1 diabetes mellitus treated with a basal-bolus insulin regimen. No known kidney disease." },
{ "code": "medications", "title": "Medications", "text": "Basal and mealtime insulin; no doses for approximately 36 hours." },
{ "code": "allergies", "title": "Allergies", "text": "No known drug allergies." },
{ "code": "review_of_systems", "title": "Review of systems", "text": "No dysuria, cough, diarrhea, alcohol use, or pregnancy symptoms." }
],
"vitals": { "temperature": "37.3 C (99.1 F)", "bloodPressure": "88/56 mm Hg", "pulse": "118/min", "respirations": "28/min", "oxygenSaturation": "98% on room air", "height": "165 cm (65 in)", "weight": "62 kg (137 lb)", "bmi": "22.8 kg/m²" },
"simulatedStartUtc": "2026-07-15T08:00:00Z", "simulatedStartUtc": "2026-07-15T08:00:00Z",
"initialOrderCodes": [] "initialOrderCodes": []
}, },
+9 -4
View File
@@ -6,16 +6,21 @@ Use `content/ccs/dka-emergency-001.json` as the canonical example. Set `validate
## Case structure ## Case structure
- `initialChart` defines the opening history, appearance, vitals, location, simulated start, and standing orders. - `initialChart` defines the opening history, appearance, vitals, simulated start, and standing orders. Use ordered `historySections` (`code`, `title`, `text`) for the HPI, medical history, medications, allergies, family/social history, and review of systems. The legacy `history` field remains the fallback.
- `allowedLocations` controls patient movement. - `allowedLocations` controls patient movement.
- `exams` defines selectable examination components and their simulated duration. - `exams` defines selectable examination components and their simulated duration.
- `orderResponses` maps reusable catalog codes to case-specific results, delays, and vital-sign changes. - Catalog orders may define `displayRoute`, `displayFrequency`, and `resultSection`. These are presentation defaults, not user-entered dosing instructions.
- `orderResponses` maps reusable catalog codes to case-specific results, delays, vital-sign changes, and an optional result-section override.
- `events` defines timed progression. `requiresOrderCodes` requires every listed order; `requiresAnyOrderCodes` supports equivalent alternatives. - `events` defines timed progression. `requiresOrderCodes` requires every listed order; `requiresAnyOrderCodes` supports equivalent alternatives.
- `scoringCriteria` awards or subtracts points for timing, sequence, and harmful care. `targetCodes` supports clinically equivalent actions; `afterTargetCode` enforces sequence. - `scoringCriteria` awards or subtracts points for timing, sequence, and harmful care. `targetCodes` supports clinically equivalent actions; `afterTargetCode` enforces sequence.
- `references` stores the source title, publisher, HTTPS URL, supported claims, access date, and reviewer inside each case revision. - `references` stores the source title, publisher, HTTPS URL, supported claims, access date, and reviewer inside each case revision.
Order codes and event/criterion IDs must be stable lowercase identifiers. Case content should not expose the diagnosis in the opening chart or rely on unstructured text matching for scoring. Add aliases to the order catalog so users can find an order using common clinical wording. Order codes and event/criterion IDs must be stable lowercase identifiers. Case content should not expose the diagnosis in the opening chart or rely on unstructured text matching for scoring. Add aliases to the order catalog so users can find an order using common clinical wording.
## Scoring and prediction Valid result sections are `lab`, `imaging`, `other_test`, and `treatment`; progress notes, vital snapshots, and the order sheet are generated by the runtime.
Practice attempts are scored for feedback but do not affect prediction. Only the first completed timed attempt for each distinct case contributes to the CCS estimate. The combined provisional prediction uses the latest Day 1 simulation and a separately reported CCS estimate; it is not presented as an official USMLE scoring formula. ## Timing, scoring, and prediction
New CCS attempts are timed. `realTimeMinutes` is the total wall-clock allowance: the runtime reserves its final two minutes for final orders, so a 20-minute case has 18 minutes of active management plus two minutes of case-end orders. Opening dialogs count against this limit and reconnecting does not pause it.
Only the first completed timed attempt for each distinct case contributes to the CCS estimate. Legacy practice attempts remain reviewable but cannot be newly created. The combined provisional prediction uses the latest Day 1 simulation and a separately reported CCS estimate; it is not presented as an official USMLE scoring formula.
+25 -3
View File
@@ -54,8 +54,8 @@ func load_cases() -> void:
_send("cases", HTTPClient.METHOD_GET, "/cases") _send("cases", HTTPClient.METHOD_GET, "/cases")
func create_attempt(case_external_id: String, mode: String) -> void: func create_attempt(case_external_id: String, _mode: String = "timed") -> void:
_send("attempt", HTTPClient.METHOD_POST, "/attempts", {"caseExternalId": case_external_id, "mode": mode}) _send("attempt", HTTPClient.METHOD_POST, "/attempts", {"caseExternalId": case_external_id, "mode": "timed"})
func load_active() -> void: func load_active() -> void:
@@ -77,6 +77,15 @@ func send_command(type: String, code: String = "", minutes: int = 0) -> void:
"minutes": minutes "minutes": minutes
}) })
func send_batch(commands: Array) -> void:
if attempt.is_empty() or commands.is_empty():
return
_send("attempt", HTTPClient.METHOD_POST, "/attempts/%s/commands/batch" % str(attempt.get("id", "")).uri_encode(), {
"commandId": "%d-%d" % [Time.get_ticks_usec(), randi()],
"expectedRevision": int(attempt.get("revision", 0)),
"commands": commands
})
func search_orders(query: String) -> void: func search_orders(query: String) -> void:
if query.strip_edges().length() < 2: if query.strip_edges().length() < 2:
@@ -169,7 +178,20 @@ func abandon() -> void:
func deadline_unix() -> float: func deadline_unix() -> float:
var value := str(attempt.get("finalOrdersDeadlineUtc", attempt.get("deadlineUtc", ""))) var raw: Variant = attempt.get("finalOrdersDeadlineUtc", null)
if raw == null or str(raw).is_empty() or str(raw) == "<null>":
raw = attempt.get("deadlineUtc", "")
var value := str(raw)
return 0.0 if value.is_empty() or value == "<null>" else float(Time.get_unix_time_from_datetime_string(value))
func hard_deadline_unix() -> float:
var raw: Variant = attempt.get("hardDeadlineUtc", null)
if raw == null or str(raw).is_empty() or str(raw) == "<null>":
raw = attempt.get("finalOrdersDeadlineUtc", null)
if raw == null or str(raw).is_empty() or str(raw) == "<null>":
raw = attempt.get("deadlineUtc", "")
var value := str(raw)
return 0.0 if value.is_empty() or value == "<null>" else float(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))
+1 -1
View File
@@ -398,7 +398,7 @@ func _case_cell(item: Dictionary, dark: bool) -> Control:
title.add_theme_color_override("font_color", Brand.text_color(dark)) title.add_theme_color_override("font_color", Brand.text_color(dark))
column.add_child(title) column.add_child(title)
var mode := Label.new() var mode := Label.new()
mode.text = "Timed case" mode.text = "Legacy practice case" if str(item.get("mode", "timed")) == "practice" else "Timed case"
_style_muted_label(mode, dark, 12) _style_muted_label(mode, dark, 12)
column.add_child(mode) column.add_child(mode)
return column return column
+865 -575
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -905,7 +905,7 @@ func _toggle_app_menu() -> void:
menu_exit_button.visible = (current_screen_name == "player" and BlockSessionService.block_started) or current_screen_name in ["ccs_player", "flashcards_study"] menu_exit_button.visible = (current_screen_name == "player" and BlockSessionService.block_started) or current_screen_name in ["ccs_player", "flashcards_study"]
if menu_exit_button.visible: if menu_exit_button.visible:
menu_exit_button.text = ( menu_exit_button.text = (
"Leave Case" if current_screen_name == "ccs_player" "Abandon CCS Case" if current_screen_name == "ccs_player"
else "Exit Study Session" if current_screen_name == "flashcards_study" else "Exit Study Session" if current_screen_name == "flashcards_study"
else "Leave (timer continues)" if BlockSessionService.session_kind == "simulation" else "Leave (timer continues)" if BlockSessionService.session_kind == "simulation"
else "Exit Run") else "Exit Run")
+50 -53
View File
@@ -1,8 +1,5 @@
extends SceneTree extends SceneTree
const Style := preload("res://scripts/ui/ui_style.gd")
const Brand := preload("res://scripts/ui/medmind_style.gd")
var failures := 0 var failures := 0
var ccs_service var ccs_service
@@ -13,8 +10,6 @@ func _initialize() -> void:
func _run() -> void: func _run() -> void:
ccs_service = root.get_node("CCSService") ccs_service = root.get_node("CCSService")
var theme_service = root.get_node("UIThemeService")
var prior_dark_mode: bool = theme_service.is_dark_mode()
var prior_attempt: Dictionary = ccs_service.attempt.duplicate(true) var prior_attempt: Dictionary = ccs_service.attempt.duplicate(true)
var prior_orders: Array = ccs_service.orders.duplicate(true) var prior_orders: Array = ccs_service.orders.duplicate(true)
var prior_scale: float = ccs_service.text_scale var prior_scale: float = ccs_service.text_scale
@@ -33,58 +28,42 @@ func _run() -> void:
player._render() player._render()
await process_frame await process_frame
_assert(player.heading.text == "CCS Case | Emergency Department", "CCS header should show the readable location without a case id") _assert(player.heading.text == "CCS Case | Emergency Department", "header should show the readable location")
_assert(player._ordered_vital_keys(ccs_service.attempt.vitals) == ["temperature", "bloodPressure", "pulse", "respirations", "oxygenSaturation"], "vital chips should use the clinical priority order") _assert(player.action_buttons.size() == 4, "CCS workspace should expose four persistent clinical tasks")
_assert(player.vitals_flow.get_child_count() == 5, "every supplied vital should render as a chip") _assert(player.tabs.get_tab_count() == 7, "CCS chart should expose seven predictable tabs")
_assert(player.search_button.icon != null, "Order Clerk search should use a bundled icon rather than a font glyph") _assert(player.tabs.get_tab_title(0) == "Order Sheet", "the first chart tab should be the order sheet")
_assert(player.summary.get_meta("ccs_region", "") == "summary", "summary highlighting should use its own region") _assert(player.allotted_label.text.contains("18 minutes + 2 minutes"), "20-minute cases should reserve two final-order minutes")
_assert(player.history.get_meta("ccs_region", "") == "history", "history highlighting should use its own region") _assert(player.active_orders.get_child_count() == 1, "active orders should render as structured rows")
_assert(player.appearance.get_meta("ccs_region", "") == "appearance", "appearance highlighting should use its own region") _assert(player._ordered_vital_keys(ccs_service.attempt.vitals) == ["temperature", "bloodPressure", "pulse", "respirations", "oxygenSaturation"], "vitals should use clinical priority order")
_assert(player.chart.get_meta("ccs_region", "") == "chart", "chart highlighting should use its own region") _assert(player._chart_entries("lab").size() == 1, "categorized results should render in their assigned tab")
_assert(not player.workspace.vertical, "wide CCS screens should use chart and Order Clerk columns") _assert(player._simulated_time_text(60).contains("10:00 AM"), "simulated time should derive from the case start")
_assert(player.orders.get_child_count() == 1, "active orders should render in the Order Clerk") _assert(not player.opening_active, "completed opening flows should not replay on resume")
var active_order_row := player.orders.get_child(0) as HBoxContainer _assert(not player.final_orders_notice.visible, "active cases should not show final-order instructions")
var active_order_label := active_order_row.get_child(0) as Label
_assert(active_order_label.text == "Normal Saline Bolus", "internal order codes should be human readable")
_assert(player.actions.get_child_count() == 2, "available physical examinations should render as actions")
_assert(player.locations.get_child_count() == 2, "allowed locations should render as move actions")
_assert(not player.clock.visible, "the normal active-case countdown should remain hidden")
var clerk_font_size: int = player.search.get_theme_font_size("font_size") var clerk_font_size: int = player.search.get_theme_font_size("font_size")
var chart_font_size: int = player.summary.get_theme_font_size("normal_font_size") var original_size: int = player.clinical_labels[0].get_theme_font_size("normal_font_size")
var prior_profile_api_url: String = ProjectSettings.get_setting("step3/profile_api_url", "") var prior_profile_api_url: String = ProjectSettings.get_setting("step3/profile_api_url", "")
ProjectSettings.set_setting("step3/profile_api_url", "http://127.0.0.1:1") ProjectSettings.set_setting("step3/profile_api_url", "http://127.0.0.1:1")
player._zoom(ccs_service.TEXT_SCALE_STEP) player._zoom(ccs_service.TEXT_SCALE_STEP)
ProjectSettings.set_setting("step3/profile_api_url", prior_profile_api_url) ProjectSettings.set_setting("step3/profile_api_url", prior_profile_api_url)
_assert(player.summary.get_theme_font_size("normal_font_size") > chart_font_size, "text zoom should enlarge clinical chart text") _assert(player.clinical_labels[0].get_theme_font_size("normal_font_size") > original_size, "clinical text zoom should remain available")
_assert(player.search.get_theme_font_size("font_size") == clerk_font_size, "text zoom should not resize Order Clerk controls") _assert(player.search.get_theme_font_size("font_size") == clerk_font_size, "clinical text zoom should not resize order controls")
player._stage_order("bmp")
_assert(player.staged_order_codes == ["bmp"], "orders should stage before submission")
_assert(not player.commit_orders_button.disabled, "staged orders should enable confirmation")
player._clear_staged_orders()
_assert(player.commit_orders_button.disabled, "clearing staged orders should disable confirmation")
player.size = Vector2(800, 900) player.size = Vector2(800, 900)
player._update_responsive_layout() player._update_responsive_layout()
_assert(player.workspace.vertical, "narrow CCS screens should stack the Order Clerk below the chart") _assert(player.action_grid.columns == 2, "narrow CCS screens should use a two-column action workspace")
ccs_service.attempt.state = "final_orders" ccs_service.attempt.state = "final_orders"
ccs_service.attempt.finalOrdersDeadlineUtc = Time.get_datetime_string_from_unix_time(Time.get_unix_time_from_system() + 90.0, true)
player._render() player._render()
_assert(player.final_orders_notice.visible and player.clock.visible, "final-orders mode should show instructions and its deadline") _assert(player.final_orders_notice.visible, "final-orders mode should show instructions")
_assert(not player.time_row.visible, "time advancement should be unavailable during final orders") _assert(player.exit_button.text == "Submit Final Orders", "final-orders mode should expose final submission")
_assert(player.action_buttons[0].disabled and not player.action_buttons[1].disabled, "only order review should remain available in final orders")
theme_service.set_dark_mode(true)
await process_frame
theme_service.set_dark_mode(false)
Style.refresh_tree(player, true)
await process_frame
var light_panel := player.chart_panel.get_theme_stylebox("panel") as StyleBoxFlat
_assert(light_panel.bg_color.is_equal_approx(Brand.content_card_box(false, false).bg_color), "CCS light styles should win after the shell palette translation")
_assert(player.summary.get_theme_color("default_color").is_equal_approx(Brand.text_color(false)), "CCS light chart text should remain readable")
theme_service.set_dark_mode(true)
Style.refresh_tree(player, false)
await process_frame
var dark_panel := player.chart_panel.get_theme_stylebox("panel") as StyleBoxFlat
_assert(dark_panel.bg_color.is_equal_approx(Brand.content_card_box(true, false).bg_color), "CCS dark styles should win after the shell palette translation")
_assert(player.summary.get_theme_color("default_color").is_equal_approx(Brand.text_color(true)), "CCS dark chart text should remain readable")
theme_service.set_dark_mode(prior_dark_mode)
await process_frame
host.queue_free() host.queue_free()
await process_frame await process_frame
ccs_service.attempt = prior_attempt ccs_service.attempt = prior_attempt
@@ -94,14 +73,24 @@ func _run() -> void:
func _sample_attempt() -> Dictionary: func _sample_attempt() -> Dictionary:
var now := Time.get_unix_time_from_system()
return { return {
"id": "attempt-1", "id": "attempt-1",
"state": "active", "state": "active",
"revision": 1,
"openingCompleted": true,
"location": "emergency_department", "location": "emergency_department",
"patientSummary": "24-year-old woman with type 1 diabetes mellitus", "patientSummary": "24-year-old woman with type 1 diabetes mellitus",
"reasonForEncounter": "Two days of vomiting and progressive lethargy.", "reasonForEncounter": "Two days of vomiting and progressive lethargy.",
"history": "She stopped insulin when oral intake declined.", "history": "She stopped insulin when oral intake declined.",
"historySections": [{"code": "hpi", "title": "History of present illness", "text": "She stopped insulin when oral intake declined."}],
"appearance": "She is lethargic but arousable.", "appearance": "She is lethargic but arousable.",
"simulatedStartUtc": "2026-07-15T09:00:00Z",
"simulatedMinute": 0,
"realTimeMinutes": 20,
"createdUtc": Time.get_datetime_string_from_unix_time(now - 60, true),
"deadlineUtc": Time.get_datetime_string_from_unix_time(now + 1020, true),
"hardDeadlineUtc": Time.get_datetime_string_from_unix_time(now + 1140, true),
"vitals": { "vitals": {
"oxygenSaturation": "98% on room air", "oxygenSaturation": "98% on room air",
"pulse": "118/min", "pulse": "118/min",
@@ -109,14 +98,22 @@ func _sample_attempt() -> Dictionary:
"respirations": "28/min", "respirations": "28/min",
"bloodPressure": "88/56 mm Hg" "bloodPressure": "88/56 mm Hg"
}, },
"chart": [{"simulatedMinute": 0, "category": "progress", "text": "Initial evaluation is in progress."}], "vitalHistory": [{"simulatedMinute": 0, "values": {"pulse": "118/min"}}],
"activeOrderCodes": ["normal_saline_bolus"], "chart": [
"availableExams": [ {"simulatedMinute": 0, "category": "progress", "code": "reason", "title": "Reason for encounter", "text": "Initial evaluation."},
{"code": "general", "name": "General and hydration examination"}, {"simulatedMinute": 12, "category": "lab", "code": "bmp", "title": "Basic metabolic panel", "text": "Glucose 580 mg/dL."}
{"code": "neurologic", "name": "Neurologic examination"}
], ],
"allowedLocations": ["emergency_department", "icu"], "activeOrderCodes": ["normal_saline_bolus"],
"deadlineUtc": Time.get_datetime_string_from_unix_time(Time.get_unix_time_from_system() + 600.0, true) "activeOrders": [{
"code": "normal_saline_bolus", "name": "0.9% sodium chloride intravenous bolus",
"category": "medication", "route": "IV", "frequency": "Once",
"orderedMinute": 0, "reportMinute": 0
}],
"availableExams": [
{"code": "general", "name": "General and hydration examination", "durationMinutes": 1},
{"code": "neurologic", "name": "Neurologic examination", "durationMinutes": 1}
],
"allowedLocations": ["emergency_department", "icu"]
} }
+28 -3
View File
@@ -161,15 +161,40 @@ test("public API and disposable learner journey", async () => {
expect(Array.isArray(cases)).toBeTruthy(); expect(Array.isArray(cases)).toBeTruthy();
expect(cases.length).toBeGreaterThan(0); expect(cases.length).toBeGreaterThan(0);
const { data: attempt } = await call("/api/ccs/attempts", { const { data: attempt } = await call("/api/ccs/attempts", {
method: "POST", token, body: { caseExternalId: "", mode: "practice" } method: "POST", token, body: { caseExternalId: "", mode: "timed" }
}); });
activeCcsAttempt = attempt.id; activeCcsAttempt = attempt.id;
expect(activeCcsAttempt).toBeTruthy(); expect(activeCcsAttempt).toBeTruthy();
expect(attempt.caseExternalId).toBeTruthy(); expect(attempt.caseExternalId).toBeTruthy();
expect(attempt.state).toBe("active"); expect(attempt.state).toBe("active");
await call(`/api/ccs/attempts/${activeCcsAttempt}/abandon`, { const { data: opened } = await call(`/api/ccs/attempts/${activeCcsAttempt}/commands`, {
method: "POST", token, expected: [204] method: "POST", token, body: {
commandId: `smoke-opening-${suffix}`, expectedRevision: attempt.revision,
type: "acknowledge_opening", code: "", minutes: 0
}
}); });
expect(opened.openingCompleted).toBe(true);
const exam = opened.availableExams[0];
const { data: examined } = await call(`/api/ccs/attempts/${activeCcsAttempt}/commands/batch`, {
method: "POST", token, body: {
commandId: `smoke-exam-${suffix}`, expectedRevision: opened.revision,
commands: [{ type: "exam", code: exam.code, minutes: 0 }]
}
});
const { data: finalOrders } = await call(`/api/ccs/attempts/${activeCcsAttempt}/commands`, {
method: "POST", token, body: {
commandId: `smoke-final-${suffix}`, expectedRevision: examined.revision,
type: "end_case", code: "", minutes: 0
}
});
expect(finalOrders.state).toBe("final_orders");
const { data: completedCcs } = await call(`/api/ccs/attempts/${activeCcsAttempt}/commands`, {
method: "POST", token, body: {
commandId: `smoke-complete-${suffix}`, expectedRevision: finalOrders.revision,
type: "exit_case", code: "", minutes: 0
}
});
expect(completedCcs.state).toBe("completed");
activeCcsAttempt = ""; activeCcsAttempt = "";
// Flashcards: create a deck and card, study and grade it, verify it drops out of the due // Flashcards: create a deck and card, study and grade it, verify it drops out of the due