diff --git a/api/QuestionsApi.Tests/CcsCaseTests.cs b/api/QuestionsApi.Tests/CcsCaseTests.cs index 81b6e00..0410b07 100644 --- a/api/QuestionsApi.Tests/CcsCaseTests.cs +++ b/api/QuestionsApi.Tests/CcsCaseTests.cs @@ -8,6 +8,41 @@ namespace QuestionsApi.Tests; 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] public void CcsHistoryCursorRoundTripsTimestampAndId() { diff --git a/api/QuestionsApi/Controllers/CcsController.cs b/api/QuestionsApi/Controllers/CcsController.cs index 2e6c76f..56e4ada 100644 --- a/api/QuestionsApi/Controllers/CcsController.cs +++ b/api/QuestionsApi/Controllers/CcsController.cs @@ -51,6 +51,16 @@ public sealed class CcsController(CcsStore store, CcsRatingStore ratings) : Cont catch (InvalidOperationException exception) { return Conflict(exception.Message); } } + [HttpPost("attempts/{id}/commands/batch")] + public async Task 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")] public async Task Abandon(string id) { diff --git a/api/QuestionsApi/Models/CcsModels.cs b/api/QuestionsApi/Models/CcsModels.cs index f920171..bc2f72d 100644 --- a/api/QuestionsApi/Models/CcsModels.cs +++ b/api/QuestionsApi/Models/CcsModels.cs @@ -17,6 +17,26 @@ public static class CcsAttemptModes 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 All = new HashSet(StringComparer.OrdinalIgnoreCase) + { + Orders, Progress, Vitals, Lab, Imaging, OtherTest, Treatment + }; + public static readonly IReadOnlySet ResultSections = new HashSet(StringComparer.OrdinalIgnoreCase) + { + Lab, Imaging, OtherTest, Treatment + }; +} + public class CcsOrderDefinition { [BsonId] public string Code { get; set; } = ""; @@ -25,6 +45,9 @@ public class CcsOrderDefinition public List Aliases { get; set; } = []; public int DefaultTurnaroundMinutes { get; set; } public List 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; } @@ -57,11 +80,19 @@ public class CcsInitialChart public string ReasonForEncounter { get; set; } = ""; public string Appearance { get; set; } = ""; public string History { get; set; } = ""; + public List HistorySections { get; set; } = []; public Dictionary Vitals { get; set; } = []; public DateTime SimulatedStartUtc { get; set; } = DateTime.UtcNow; public List 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 string Code { get; set; } = ""; @@ -75,6 +106,7 @@ public class CcsCaseOrderDefinition public string OrderCode { get; set; } = ""; public int? TurnaroundMinutes { get; set; } public string Result { get; set; } = ""; + public string ResultSection { get; set; } = ""; public Dictionary VitalPatches { get; set; } = []; } @@ -111,19 +143,22 @@ public class CcsAttempt public string CaseExternalId { get; set; } = ""; [BsonRepresentation(BsonType.ObjectId)] public string CaseRevisionId { 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 int Revision { get; set; } public int SimulatedMinute { get; set; } + [BsonIgnoreIfNull] public bool? OpeningCompleted { get; set; } public string Location { get; set; } = ""; public Dictionary CurrentVitals { get; set; } = []; public List Actions { get; set; } = []; public List PendingResults { get; set; } = []; public List Chart { get; set; } = []; + public List VitalHistory { get; set; } = []; public List ActiveOrderCodes { get; set; } = []; public List ProcessedEventIds { get; set; } = []; public List ProcessedCommandIds { get; set; } = []; public DateTime? DeadlineUtc { get; set; } + public DateTime? HardDeadlineUtc { get; set; } public DateTime? FinalOrdersDeadlineUtc { get; set; } public DateTime CreatedUtc { get; set; } = DateTime.UtcNow; public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow; @@ -144,6 +179,8 @@ public class CcsAttemptAction public class CcsPendingResult { public string OrderCode { get; set; } = ""; + public string Category { get; set; } = ""; + public string Title { get; set; } = ""; public int DueMinute { get; set; } public string Result { get; set; } = ""; public Dictionary VitalPatches { get; set; } = []; @@ -153,9 +190,30 @@ public class CcsChartEntry { public string Category { get; set; } = "progress"; public int SimulatedMinute { get; set; } + public string Code { get; set; } = ""; + public string Title { get; set; } = ""; public string Text { get; set; } = ""; } +public class CcsVitalSnapshot +{ + public int SimulatedMinute { get; set; } + public Dictionary 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 double Percent { get; set; } @@ -194,7 +252,7 @@ public class CcsImportResponse public class CreateCcsAttemptRequest { public string CaseExternalId { get; set; } = ""; - public string Mode { get; set; } = CcsAttemptModes.Practice; + public string Mode { get; set; } = CcsAttemptModes.Timed; } public class CcsCommandRequest @@ -206,6 +264,20 @@ public class CcsCommandRequest public int Minutes { get; set; } } +public class CcsBatchCommandRequest +{ + public string CommandId { get; set; } = ""; + public int ExpectedRevision { get; set; } + public List 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 string ExternalId { get; set; } = ""; @@ -226,8 +298,11 @@ public class CcsAttemptResponse public string State { get; set; } = ""; public int Revision { get; set; } public int SimulatedMinute { get; set; } + public DateTime SimulatedStartUtc { get; set; } + public bool OpeningCompleted { get; set; } public string Location { get; set; } = ""; public DateTime? DeadlineUtc { get; set; } + public DateTime? HardDeadlineUtc { get; set; } public DateTime? FinalOrdersDeadlineUtc { get; set; } public DateTime CreatedUtc { get; set; } public int RealTimeMinutes { get; set; } @@ -236,9 +311,12 @@ public class CcsAttemptResponse public string ReasonForEncounter { get; set; } = ""; public string Appearance { get; set; } = ""; public string History { get; set; } = ""; + public List HistorySections { get; set; } = []; public Dictionary Vitals { get; set; } = []; public List Chart { get; set; } = []; public List ActiveOrderCodes { get; set; } = []; + public List ActiveOrders { get; set; } = []; + public List VitalHistory { get; set; } = []; public List PendingResults { get; set; } = []; public List AvailableExams { get; set; } = []; public List AllowedLocations { get; set; } = []; diff --git a/api/QuestionsApi/Services/CcsCaseValidator.cs b/api/QuestionsApi/Services/CcsCaseValidator.cs index caba486..1993015 100644 --- a/api/QuestionsApi/Services/CcsCaseValidator.cs +++ b/api/QuestionsApi/Services/CcsCaseValidator.cs @@ -4,8 +4,7 @@ namespace QuestionsApi.Services; public static class CcsCaseValidator { - private static readonly HashSet Modes = [CcsAttemptModes.Practice, CcsAttemptModes.Timed]; - private static readonly HashSet CommandTypes = ["exam", "order", "cancel_order", "location", "advance", "interval_history", "end_case", "exit_case"]; + private static readonly HashSet CommandTypes = ["exam", "order", "cancel_order", "location", "advance", "interval_history", "acknowledge_opening", "end_case", "exit_case"]; public static List ValidateImport(CcsImportRequest request, IReadOnlySet existingOrderCodes) { @@ -18,6 +17,8 @@ public static class CcsCaseValidator if (string.IsNullOrWhiteSpace(order.CanonicalName)) errors.Add(prefix + "canonicalName required"); if (string.IsNullOrWhiteSpace(order.Category)) errors.Add(prefix + "category required"); 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) 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) errors.Add(prefix + "event ids must be present and unique"); foreach (var response in item.OrderResponses) + { 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)))) if (!orderCodes.Contains(code)) errors.Add(prefix + $"unknown order code {code}"); var criteriaIds = item.ScoringCriteria.Select(value => value.Id).ToList(); @@ -75,7 +80,8 @@ public static class CcsCaseValidator 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; } @@ -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"; 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; + } } diff --git a/api/QuestionsApi/Services/CcsStore.cs b/api/QuestionsApi/Services/CcsStore.cs index d938022..1d286f9 100644 --- a/api/QuestionsApi/Services/CcsStore.cs +++ b/api/QuestionsApi/Services/CcsStore.cs @@ -115,16 +115,22 @@ public sealed class CcsStore CaseExternalId = definition.ExternalId, CaseRevisionId = definition.Id ?? "", CaseVersion = definition.Version, - Mode = request.Mode, + Mode = CcsAttemptModes.Timed, + OpeningCompleted = false, Location = definition.PresentingLocation, CurrentVitals = new Dictionary(definition.InitialChart.Vitals), + VitalHistory = + [ + new CcsVitalSnapshot { SimulatedMinute = 0, Values = new Dictionary(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, + DeadlineUtc = now.AddMinutes(ActivePhaseMinutes(definition.RealTimeMinutes)), + HardDeadlineUtc = now.AddMinutes(definition.RealTimeMinutes), + RatingEligible = true, Chart = [ - new CcsChartEntry { Category = "progress", Text = definition.InitialChart.ReasonForEncounter }, - new CcsChartEntry { Category = "progress", Text = definition.InitialChart.Appearance } + new CcsChartEntry { Category = CcsChartCategories.Progress, Code = "reason_for_encounter", Title = "Reason for encounter", Text = definition.InitialChart.ReasonForEncounter }, + new CcsChartEntry { Category = CcsChartCategories.Progress, Code = "initial_appearance", Title = "Initial appearance", Text = definition.InitialChart.Appearance } ] }; try { await _attempts.InsertOneAsync(attempt); } @@ -173,7 +179,12 @@ public sealed class CcsStore if (attempt.State == CcsAttemptStates.Active && attempt.DeadlineUtc <= DateTime.UtcNow) { 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++; await SaveAsync(attempt); } @@ -206,9 +217,16 @@ public sealed class CcsStore case "advance": Advance(attempt, definition, command.Minutes); break; case "interval_history": 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); break; + case "acknowledge_opening": + attempt.OpeningCompleted = true; + break; case "end_case": EnterFinalOrders(attempt); break; case "exit_case": await CompleteAsync(attempt); break; } @@ -220,6 +238,63 @@ public sealed class CcsStore return attempt; } + public async Task 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 HistoryAsync(string userId, int limit, string status = "all", string sort = "newest", string? cursor = null) { limit = Math.Clamp(limit, 1, 50); @@ -314,19 +389,49 @@ public sealed class CcsStore { var definition = await DefinitionAsync(attempt); var completed = attempt.State == CcsAttemptStates.Completed; + List 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 vitalHistory = attempt.VitalHistory.Count > 0 + ? attempt.VitalHistory + : [new CcsVitalSnapshot { SimulatedMinute = attempt.SimulatedMinute, Values = new Dictionary(attempt.CurrentVitals) }]; return new CcsAttemptResponse { Id = attempt.Id ?? "", CaseExternalId = attempt.CaseExternalId, CaseTitle = completed ? definition.Title : $"CCS Case {attempt.CaseExternalId}", Mode = attempt.Mode, State = attempt.State, Revision = attempt.Revision, - SimulatedMinute = attempt.SimulatedMinute, Location = attempt.Location, - DeadlineUtc = attempt.DeadlineUtc, FinalOrdersDeadlineUtc = attempt.FinalOrdersDeadlineUtc, + SimulatedMinute = attempt.SimulatedMinute, SimulatedStartUtc = definition.InitialChart.SimulatedStartUtc, + OpeningCompleted = attempt.OpeningCompleted ?? true, Location = attempt.Location, + DeadlineUtc = attempt.DeadlineUtc, HardDeadlineUtc = attempt.HardDeadlineUtc, + FinalOrdersDeadlineUtc = attempt.FinalOrdersDeadlineUtc, CreatedUtc = attempt.CreatedUtc, RealTimeMinutes = definition.RealTimeMinutes, ElapsedSeconds = Math.Max(0, (int)Math.Round(((attempt.CompletedUtc ?? DateTime.UtcNow) - attempt.CreatedUtc).TotalSeconds)), PatientSummary = definition.InitialChart.PatientSummary, ReasonForEncounter = definition.InitialChart.ReasonForEncounter, Appearance = definition.InitialChart.Appearance, History = definition.InitialChart.History, + HistorySections = definition.InitialChart.HistorySections, 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 : [], AllowedLocations = attempt.State == CcsAttemptStates.Active ? definition.AllowedLocations : [], Score = completed ? attempt.Score : null, Rating = completed ? attempt.Rating : null, @@ -342,7 +447,7 @@ public sealed class CcsStore 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(); 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); var response = definition.OrderResponses.FirstOrDefault(value => value.OrderCode.Equals(code, StringComparison.OrdinalIgnoreCase)); 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)) - attempt.PendingResults.Add(new CcsPendingResult { OrderCode = code, DueMinute = attempt.SimulatedMinute + delay, Result = response.Result, VitalPatches = response.VitalPatches }); - attempt.Chart.Add(new CcsChartEntry { Category = "orders", SimulatedMinute = attempt.SimulatedMinute, Text = $"Ordered: {catalog.CanonicalName}" }); - if (delay == 0) ProcessDue(attempt); + attempt.PendingResults.Add(new CcsPendingResult + { + 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) { 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.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)); } @@ -371,7 +489,11 @@ public sealed class CcsStore attempt.Actions.Add(Action("cancel_order", code, attempt.SimulatedMinute, now)); attempt.ActiveOrderCodes.RemoveAll(value => value.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) @@ -379,7 +501,11 @@ public sealed class CcsStore if (!definition.AllowedLocations.Contains(location, StringComparer.OrdinalIgnoreCase)) throw new ArgumentException("Unsupported location."); attempt.Location = location; 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) @@ -403,13 +529,20 @@ public sealed class CcsStore private static void ProcessDue(CcsAttempt attempt) { var due = attempt.PendingResults.Where(value => value.DueMinute <= attempt.SimulatedMinute).ToList(); + var vitalsChanged = false; foreach (var result in due) { 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; + vitalsChanged |= result.VitalPatches.Count > 0; attempt.PendingResults.Remove(result); } + if (vitalsChanged) SnapshotVitals(attempt); } private static bool ProcessEvents(CcsAttempt attempt, CcsCaseRevision definition) @@ -419,13 +552,20 @@ public sealed class CcsStore && value.TriggerMinute <= attempt.SimulatedMinute && value.RequiresOrderCodes.All(activeOrders.Contains) && (value.RequiresAnyOrderCodes.Count == 0 || value.RequiresAnyOrderCodes.Any(activeOrders.Contains))).ToList(); + var vitalsChanged = false; foreach (var item in events) { attempt.ProcessedEventIds.Add(item.Id); 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 (vitalsChanged) SnapshotVitals(attempt); return events.Any(value => !string.IsNullOrWhiteSpace(value.Message) || value.EndCase); } @@ -433,9 +573,21 @@ public sealed class CcsStore { if (attempt.State != CcsAttemptStates.Active) return; attempt.State = CcsAttemptStates.FinalOrders; - 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.Chart.Add(new CcsChartEntry { Category = "progress", SimulatedMinute = attempt.SimulatedMinute, Text = "Case-end instructions: finalize patient care orders." }); + 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; + attempt.FinalOrdersDeadlineUtc = DateTime.UtcNow.Add( + 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) @@ -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 DefinitionAsync(CcsAttempt attempt) => await _cases.Find(value => value.Id == attempt.CaseRevisionId).FirstOrDefaultAsync() ?? throw new InvalidOperationException("The case revision is unavailable."); 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 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(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 DescribeExpected(CcsScoringCriterion criterion, List targets) { diff --git a/content/ccs/dka-emergency-001.json b/content/ccs/dka-emergency-001.json index 65a5bfc..0e023dd 100644 --- a/content/ccs/dka-emergency-001.json +++ b/content/ccs/dka-emergency-001.json @@ -1,25 +1,25 @@ { "orders": [ - { "code": "iv_access", "canonicalName": "Peripheral intravenous access", "category": "procedure", "aliases": ["iv", "intravenous line"], "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"] }, - { "code": "pulse_oximetry", "canonicalName": "Continuous pulse oximetry", "category": "monitoring", "aliases": ["pulse ox", "oxygen saturation"], "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"] }, - { "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": "bmp", "canonicalName": "Basic metabolic panel", "category": "laboratory", "aliases": ["electrolytes", "chemistry", "potassium", "creatinine"], "defaultTurnaroundMinutes": 12, "availableLocations": ["emergency_department", "icu", "ward"] }, - { "code": "venous_blood_gas", "canonicalName": "Venous blood gas", "category": "laboratory", "aliases": ["vbg", "venous ph"], "defaultTurnaroundMinutes": 8, "availableLocations": ["emergency_department", "icu"] }, - { "code": "beta_hydroxybutyrate", "canonicalName": "Serum beta-hydroxybutyrate", "category": "laboratory", "aliases": ["ketones", "serum ketones", "bohb"], "defaultTurnaroundMinutes": 15, "availableLocations": ["emergency_department", "icu", "ward"] }, - { "code": "cbc", "canonicalName": "Complete blood count", "category": "laboratory", "aliases": ["cbc", "blood count"], "defaultTurnaroundMinutes": 12, "availableLocations": [] }, - { "code": "urinalysis", "canonicalName": "Urinalysis", "category": "laboratory", "aliases": ["urine test", "ua"], "defaultTurnaroundMinutes": 15, "availableLocations": [] }, - { "code": "pregnancy_test", "canonicalName": "Serum beta-hCG pregnancy test", "category": "laboratory", "aliases": ["pregnancy test", "hcg"], "defaultTurnaroundMinutes": 15, "availableLocations": [] }, - { "code": "ecg", "canonicalName": "12-lead electrocardiogram", "category": "diagnostic", "aliases": ["ekg", "electrocardiogram"], "defaultTurnaroundMinutes": 10, "availableLocations": ["emergency_department", "icu", "ward"] }, - { "code": "insulin_infusion", "canonicalName": "Regular insulin intravenous infusion", "category": "medication", "aliases": ["insulin drip", "iv insulin", "regular insulin"], "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"] }, - { "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": "sodium_bicarbonate_iv", "canonicalName": "Intravenous sodium bicarbonate", "category": "medication", "aliases": ["bicarbonate", "bicarb"], "defaultTurnaroundMinutes": 0, "availableLocations": ["emergency_department", "icu"] }, - { "code": "ceftriaxone", "canonicalName": "Intravenous ceftriaxone", "category": "medication", "aliases": ["rocephin", "antibiotic"], "defaultTurnaroundMinutes": 0, "availableLocations": [] }, - { "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": "hourly_glucose", "canonicalName": "Capillary glucose every hour", "category": "monitoring", "aliases": ["hourly glucose", "fingerstick glucose", "poc glucose"], "defaultTurnaroundMinutes": 0, "availableLocations": ["emergency_department", "icu"] }, - { "code": "icu_admission", "canonicalName": "Admit to intensive care unit", "category": "disposition", "aliases": ["icu", "intensive care admission"], "defaultTurnaroundMinutes": 0, "availableLocations": ["emergency_department"] } + { "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"], "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"], "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"], "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"], "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"], "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"], "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"], "displayRoute": "Blood", "displayFrequency": "Once", "resultSection": "lab" }, + { "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": [], "displayRoute": "Urine", "displayFrequency": "Once", "resultSection": "lab" }, + { "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"], "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"], "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"], "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"], "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"], "displayRoute": "IV", "displayFrequency": "Once", "resultSection": "treatment" }, + { "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"], "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"], "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"], "displayRoute": "", "displayFrequency": "Once", "resultSection": "treatment" } ], "cases": [ { @@ -36,7 +36,14 @@ "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.", "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", "initialOrderCodes": [] }, diff --git a/docs/CCS_CASE_AUTHORING.md b/docs/CCS_CASE_AUTHORING.md index 2fa2eff..f35ec76 100644 --- a/docs/CCS_CASE_AUTHORING.md +++ b/docs/CCS_CASE_AUTHORING.md @@ -6,16 +6,21 @@ Use `content/ccs/dka-emergency-001.json` as the canonical example. Set `validate ## 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. - `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. - `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. 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. diff --git a/scripts/autoload/ccs_service.gd b/scripts/autoload/ccs_service.gd index ef7d6e0..e664191 100644 --- a/scripts/autoload/ccs_service.gd +++ b/scripts/autoload/ccs_service.gd @@ -54,8 +54,8 @@ func load_cases() -> void: _send("cases", HTTPClient.METHOD_GET, "/cases") -func create_attempt(case_external_id: String, mode: String) -> void: - _send("attempt", HTTPClient.METHOD_POST, "/attempts", {"caseExternalId": case_external_id, "mode": mode}) +func create_attempt(case_external_id: String, _mode: String = "timed") -> void: + _send("attempt", HTTPClient.METHOD_POST, "/attempts", {"caseExternalId": case_external_id, "mode": "timed"}) func load_active() -> void: @@ -77,6 +77,15 @@ func send_command(type: String, code: String = "", minutes: int = 0) -> void: "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: if query.strip_edges().length() < 2: @@ -169,7 +178,20 @@ func abandon() -> void: 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) == "": + raw = attempt.get("deadlineUtc", "") + var value := str(raw) + return 0.0 if value.is_empty() or value == "" 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) == "": + raw = attempt.get("finalOrdersDeadlineUtc", null) + if raw == null or str(raw).is_empty() or str(raw) == "": + raw = attempt.get("deadlineUtc", "") + var value := str(raw) return 0.0 if value.is_empty() or value == "" else float(Time.get_unix_time_from_datetime_string(value)) diff --git a/scripts/ui/ccs_hub.gd b/scripts/ui/ccs_hub.gd index e791a04..7a540a2 100644 --- a/scripts/ui/ccs_hub.gd +++ b/scripts/ui/ccs_hub.gd @@ -398,7 +398,7 @@ func _case_cell(item: Dictionary, dark: bool) -> Control: title.add_theme_color_override("font_color", Brand.text_color(dark)) column.add_child(title) 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) column.add_child(mode) return column diff --git a/scripts/ui/ccs_player.gd b/scripts/ui/ccs_player.gd index 0e59d63..2c025b3 100644 --- a/scripts/ui/ccs_player.gd +++ b/scripts/ui/ccs_player.gd @@ -1,59 +1,99 @@ extends VBoxContainer -const Style := preload("res://scripts/ui/ui_style.gd") const Brand := preload("res://scripts/ui/medmind_style.gd") -const ICON_ACTIVITY := preload("res://assets/icons/lucide/activity.svg") -const ICON_HEART := preload("res://assets/icons/lucide/heart-pulse.svg") -const ICON_LUNGS := preload("res://assets/icons/lucide/lungs.svg") -const ICON_SEARCH := preload("res://assets/icons/lucide/search.svg") const ICON_STETHOSCOPE := preload("res://assets/icons/lucide/stethoscope.svg") -const ICON_THERMOMETER := preload("res://assets/icons/lucide/thermometer.svg") +const ICON_CLIPBOARD := preload("res://assets/icons/lucide/clipboard-plus.svg") +const ICON_CLOCK := preload("res://assets/icons/lucide/clock-3.svg") +const ICON_LOCATION := preload("res://assets/icons/lucide/house.svg") +const ICON_SEARCH := preload("res://assets/icons/lucide/search.svg") const RESPONSIVE_BREAKPOINT := 1050.0 +const CHART_TABS := [ + ["Order Sheet", "orders"], + ["Progress Notes", "progress"], + ["Vital Signs", "vitals"], + ["Lab Reports", "lab"], + ["Imaging", "imaging"], + ["Other Tests", "other_test"], + ["Treatment Record", "treatment"] +] var main: Control var header_panel: PanelContainer var header_layout: BoxContainer -var header_icon_panel: PanelContainer var heading: Label +var allotted_label: Label var toolbar: HFlowContainer -var workspace: BoxContainer +var theme_button: Button +var exit_button: Button +var abandon_button: Button +var action_grid: GridContainer +var action_buttons: Array[Button] = [] +var workspace: PanelContainer var chart_panel: PanelContainer var clerk_panel: PanelContainer -var summary: RichTextLabel -var history: RichTextLabel -var appearance: RichTextLabel -var chart: RichTextLabel -var vitals_flow: HFlowContainer -var orders: VBoxContainer +var tabs: TabContainer +var chart_views := {} +var order_sheet: VBoxContainer +var active_orders: VBoxContainer var search: LineEdit var search_button: Button -var search_results_panel: PanelContainer var search_results: VBoxContainer -var actions: VBoxContainer -var locations: HFlowContainer -var time_row: GridContainer -var status: Label +var staged_orders: VBoxContainer +var commit_orders_button: Button +var clear_orders_button: Button var final_orders_notice: Label -var clock: Label +var simulated_clock: Label +var elapsed_clock: Label +var remaining_clock: Label +var status: Label var timer: Timer -var end_button: Button var report_dialog: Control -var report_button: Button -var app_feedback_button: Button -var leave_button: Button + +var intro_dialog: AcceptDialog +var intro_title: Label +var intro_body: RichTextLabel +var opening_step := 0 +var opening_active := false + +var exam_dialog: ConfirmationDialog +var interval_history_check: CheckBox +var exam_checks := {} +var exam_summary_dialog: ConfirmationDialog +var exam_summary: RichTextLabel +var staged_exam_codes: Array[String] = [] +var staged_interval_history := false +var awaiting_exam_result := false +var findings_dialog: AcceptDialog +var findings_body: RichTextLabel + +var time_dialog: ConfirmationDialog +var time_preset: OptionButton +var custom_target: LineEdit +var time_preview: Label +var location_dialog: ConfirmationDialog +var location_option: OptionButton +var end_dialog: ConfirmationDialog +var final_instructions: AcceptDialog +var abandon_dialog: ConfirmationDialog + +var staged_order_codes: Array[String] = [] +var staged_order_names := {} +var pending_findings_before := 0 var leaving := false var theme_service -var theme_button: Button -var section_labels: Array[Label] = [] var toolbar_buttons: Array[Button] = [] +var clinical_labels: Array[RichTextLabel] = [] func _ready() -> void: theme_service = get_node("/root/UIThemeService") set_anchors_preset(Control.PRESET_FULL_RECT) - add_theme_constant_override("separation", 10) + add_theme_constant_override("separation", 8) _build_header() + _build_actions() _build_workspace() + _build_footer() + _build_dialogs() _build_runtime_services() resized.connect(_update_responsive_layout) if not theme_service.theme_changed.is_connected(_on_theme_changed): @@ -68,252 +108,308 @@ func _build_header() -> void: header_panel = PanelContainer.new() add_child(header_panel) var margin := MarginContainer.new() - margin.add_theme_constant_override("margin_left", 12) - margin.add_theme_constant_override("margin_right", 12) + for side in ["left", "right"]: + margin.add_theme_constant_override("margin_%s" % side, 14) margin.add_theme_constant_override("margin_top", 8) margin.add_theme_constant_override("margin_bottom", 8) header_panel.add_child(margin) header_layout = BoxContainer.new() - header_layout.vertical = false header_layout.add_theme_constant_override("separation", 12) margin.add_child(header_layout) - - var title_row := HBoxContainer.new() - title_row.size_flags_horizontal = Control.SIZE_EXPAND_FILL - title_row.add_theme_constant_override("separation", 10) - header_layout.add_child(title_row) - header_icon_panel = PanelContainer.new() - header_icon_panel.custom_minimum_size = Vector2(38, 38) - title_row.add_child(header_icon_panel) - var header_icon := TextureRect.new() - header_icon.texture = ICON_STETHOSCOPE - header_icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE - header_icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED - header_icon.custom_minimum_size = Vector2(22, 22) - header_icon.modulate = Brand.SKY_BLUE - header_icon_panel.add_child(header_icon) heading = Label.new() heading.text = "CCS Case" heading.size_flags_horizontal = Control.SIZE_EXPAND_FILL - _set_label(heading, 18, Brand.text_color(true)) - title_row.add_child(heading) - + heading.add_theme_font_size_override("font_size", 18) + Brand.apply_font(heading) + header_layout.add_child(heading) + allotted_label = Label.new() + allotted_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + allotted_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL + allotted_label.add_theme_font_size_override("font_size", 17) + Brand.apply_font(allotted_label) + header_layout.add_child(allotted_label) toolbar = HFlowContainer.new() toolbar.size_flags_horizontal = Control.SIZE_SHRINK_END - toolbar.custom_minimum_size.x = 440 - toolbar.add_theme_constant_override("h_separation", 8) - toolbar.add_theme_constant_override("v_separation", 8) + toolbar.add_theme_constant_override("h_separation", 6) header_layout.add_child(toolbar) - var smaller := _toolbar_button("A-", "Decrease case text size") + var smaller := _toolbar_button("A−", "Decrease clinical text size") smaller.pressed.connect(_zoom.bind(-CCSService.TEXT_SCALE_STEP)) - var larger := _toolbar_button("A+", "Increase case text size") + var larger := _toolbar_button("A+", "Increase clinical text size") larger.pressed.connect(_zoom.bind(CCSService.TEXT_SCALE_STEP)) - var clear_highlights := _toolbar_button("Clear", "Clear case highlights") - clear_highlights.pressed.connect(_clear_highlights) - report_button = _toolbar_button("Report", "Report this CCS case") - report_button.pressed.connect(_report) - app_feedback_button = _toolbar_button("Feedback", "Send general feedback or report a bug") - app_feedback_button.pressed.connect(func(): main.open_feedback_dialog()) + var report := _toolbar_button("Report", "Report this CCS case") + report.pressed.connect(_report) theme_button = _toolbar_button("", "Switch color theme") - theme_button.custom_minimum_size = Vector2(44, 38) - UISoundService.set_button_sound(theme_button, "toggle") + theme_button.custom_minimum_size.x = 42 theme_button.pressed.connect(theme_service.toggle_theme) + exit_button = _toolbar_button("Exit Case", "Enter final orders") + exit_button.pressed.connect(_request_end_case) + abandon_button = _toolbar_button("Abandon", "Abandon this attempt without a score") + abandon_button.pressed.connect(func(): abandon_dialog.popup_centered(Vector2i(520, 220))) func _toolbar_button(text_value: String, tooltip: String) -> Button: var button := Button.new() button.text = text_value button.tooltip_text = tooltip - button.custom_minimum_size = Vector2(54, 38) - button.add_theme_font_override("font", Brand.font) - button.add_theme_font_size_override("font_size", 14) + button.custom_minimum_size = Vector2(58, 38) toolbar.add_child(button) toolbar_buttons.append(button) return button +func _build_actions() -> void: + action_grid = GridContainer.new() + action_grid.columns = 4 + action_grid.add_theme_constant_override("h_separation", 8) + action_grid.add_theme_constant_override("v_separation", 8) + add_child(action_grid) + _add_action("History & Physical", "Interval history and physical exam", ICON_STETHOSCOPE, _open_exam) + _add_action("Write Orders / Review Chart", "Place orders and review the patient chart", ICON_CLIPBOARD, _open_order_sheet) + _add_action("Obtain Results / See Patient Later", "Advance simulated case time", ICON_CLOCK, _open_time_dialog) + _add_action("Change Location", "Move the patient to another care setting", ICON_LOCATION, _open_location_dialog) + + +func _add_action(title: String, subtitle: String, icon: Texture2D, callback: Callable) -> void: + var button := Button.new() + button.text = "%s\n%s" % [title, subtitle] + button.icon = icon + button.expand_icon = true + button.add_theme_constant_override("icon_max_width", 38) + button.alignment = HORIZONTAL_ALIGNMENT_LEFT + button.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + button.custom_minimum_size = Vector2(220, 86) + button.size_flags_horizontal = Control.SIZE_EXPAND_FILL + button.pressed.connect(callback) + action_grid.add_child(button) + action_buttons.append(button) + + func _build_workspace() -> void: - var page_scroll := ScrollContainer.new() - page_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL - page_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED - add_child(page_scroll) - workspace = BoxContainer.new() - workspace.size_flags_horizontal = Control.SIZE_EXPAND_FILL + workspace = PanelContainer.new() workspace.size_flags_vertical = Control.SIZE_EXPAND_FILL - workspace.add_theme_constant_override("separation", 10) - page_scroll.add_child(workspace) - _build_chart_panel() - _build_order_clerk() - - -func _build_chart_panel() -> void: - chart_panel = PanelContainer.new() - chart_panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL - chart_panel.size_flags_vertical = Control.SIZE_EXPAND_FILL - workspace.add_child(chart_panel) - var margin := MarginContainer.new() - margin.add_theme_constant_override("margin_left", 18) - margin.add_theme_constant_override("margin_right", 18) - margin.add_theme_constant_override("margin_top", 16) - margin.add_theme_constant_override("margin_bottom", 18) - chart_panel.add_child(margin) - var content := VBoxContainer.new() - content.add_theme_constant_override("separation", 14) - margin.add_child(content) - - summary = _clinical_text("summary", 17) - content.add_child(summary) - vitals_flow = HFlowContainer.new() - vitals_flow.add_theme_constant_override("h_separation", 10) - vitals_flow.add_theme_constant_override("v_separation", 8) - content.add_child(vitals_flow) - content.add_child(HSeparator.new()) - _add_chart_section(content, "History", "history") - history = _clinical_text("history", 16) - content.add_child(history) - _add_chart_section(content, "Appearance", "appearance") - appearance = _clinical_text("appearance", 16) - content.add_child(appearance) - _add_chart_section(content, "Chart", "chart") - chart = _clinical_text("chart", 16) - content.add_child(chart) - - -func _add_chart_section(parent: VBoxContainer, text_value: String, region: String) -> void: - var label := Label.new() - label.name = "%sHeading" % region.capitalize() - label.text = text_value - _set_label(label, 15, Brand.PURPLE) - parent.add_child(label) - section_labels.append(label) - - -func _clinical_text(region: String, font_size: int) -> RichTextLabel: - var label := RichTextLabel.new() - label.bbcode_enabled = true - label.selection_enabled = true - label.fit_content = true - label.scroll_active = false - label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART - label.add_theme_font_override("normal_font", Brand.font) - label.add_theme_font_override("bold_font", Brand.font) - label.add_theme_font_size_override("normal_font_size", font_size) - label.add_theme_font_size_override("bold_font_size", font_size) - label.set_meta("ccs_base_font_size", font_size) - label.set_meta("ccs_region", region) - label.gui_input.connect(_text_input.bind(label)) - return label - - -func _build_order_clerk() -> void: - clerk_panel = PanelContainer.new() - clerk_panel.custom_minimum_size = Vector2(370, 0) - clerk_panel.size_flags_vertical = Control.SIZE_EXPAND_FILL - workspace.add_child(clerk_panel) + add_child(workspace) + chart_panel = workspace + clerk_panel = workspace var margin := MarginContainer.new() margin.add_theme_constant_override("margin_left", 14) margin.add_theme_constant_override("margin_right", 14) - margin.add_theme_constant_override("margin_top", 12) - margin.add_theme_constant_override("margin_bottom", 14) - clerk_panel.add_child(margin) - var clerk := VBoxContainer.new() - clerk.add_theme_constant_override("separation", 8) - margin.add_child(clerk) - var order_title := Label.new() - order_title.text = "Order Clerk" - _set_label(order_title, 18, Brand.PURPLE) - clerk.add_child(order_title) - section_labels.append(order_title) - var search_help := Label.new() - search_help.text = "Search tests, treatments, and procedures" - _set_label(search_help, 12, Brand.muted_color(true)) - search_help.set_meta("ccs_muted_label", true) - clerk.add_child(search_help) + margin.add_theme_constant_override("margin_top", 10) + margin.add_theme_constant_override("margin_bottom", 12) + workspace.add_child(margin) + tabs = TabContainer.new() + tabs.size_flags_vertical = Control.SIZE_EXPAND_FILL + tabs.tab_changed.connect(func(_index: int): _render_chart()) + margin.add_child(tabs) + for definition in CHART_TABS: + var page := VBoxContainer.new() + page.name = str(definition[0]) + page.add_theme_constant_override("separation", 8) + tabs.add_child(page) + if str(definition[1]) == "orders": + _build_order_sheet(page) + else: + var scroll := ScrollContainer.new() + scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL + page.add_child(scroll) + var label := _clinical_text("chart_%s" % definition[1]) + label.size_flags_horizontal = Control.SIZE_EXPAND_FILL + scroll.add_child(label) + chart_views[definition[1]] = label + +func _build_order_sheet(page: VBoxContainer) -> void: + order_sheet = page + final_orders_notice = Label.new() + final_orders_notice.visible = false + final_orders_notice.text = "FINAL ORDERS — Add or cancel orders for the patient’s current condition, then submit." + final_orders_notice.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + page.add_child(final_orders_notice) var search_row := HBoxContainer.new() search_row.add_theme_constant_override("separation", 6) - clerk.add_child(search_row) + page.add_child(search_row) search = LineEdit.new() - search.placeholder_text = "Search..." + search.placeholder_text = "Search tests, treatments, medications, and procedures" search.size_flags_horizontal = Control.SIZE_EXPAND_FILL search.text_submitted.connect(func(_value: String): _submit_search()) search_row.add_child(search) search_button = Button.new() search_button.icon = ICON_SEARCH - search_button.tooltip_text = "Search order catalog" - search_button.custom_minimum_size = Vector2(42, 42) + search_button.tooltip_text = "Search the order catalog" + search_button.custom_minimum_size = Vector2(44, 42) search_button.pressed.connect(_submit_search) search_row.add_child(search_button) - - search_results_panel = PanelContainer.new() - search_results_panel.visible = false - clerk.add_child(search_results_panel) - var results_scroll := ScrollContainer.new() - results_scroll.custom_minimum_size.y = 150 - search_results_panel.add_child(results_scroll) search_results = VBoxContainer.new() - search_results.size_flags_horizontal = Control.SIZE_EXPAND_FILL - search_results.add_theme_constant_override("separation", 5) - results_scroll.add_child(search_results) + search_results.add_theme_constant_override("separation", 4) + page.add_child(search_results) + var staged_title := Label.new() + staged_title.text = "Orders awaiting confirmation" + Brand.apply_font(staged_title) + page.add_child(staged_title) + staged_orders = VBoxContainer.new() + page.add_child(staged_orders) + var staged_actions := HBoxContainer.new() + page.add_child(staged_actions) + commit_orders_button = Button.new() + commit_orders_button.text = "Confirm Orders" + commit_orders_button.pressed.connect(_commit_orders) + staged_actions.add_child(commit_orders_button) + clear_orders_button = Button.new() + clear_orders_button.text = "Clear" + clear_orders_button.pressed.connect(_clear_staged_orders) + staged_actions.add_child(clear_orders_button) + page.add_child(HSeparator.new()) + var active_title := Label.new() + active_title.text = "Order sheet" + active_title.add_theme_font_size_override("font_size", 18) + Brand.apply_font(active_title) + page.add_child(active_title) + var header := HBoxContainer.new() + for item in [["Order", 2.4], ["Route", 0.8], ["Frequency", 1.0], ["Order Time", 1.0], ["Report Time", 1.0], ["", 0.8]]: + var label := Label.new() + label.text = item[0] + label.size_flags_horizontal = Control.SIZE_EXPAND_FILL + label.size_flags_stretch_ratio = item[1] + Brand.apply_font(label) + header.add_child(label) + page.add_child(header) + var active_scroll := ScrollContainer.new() + active_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL + page.add_child(active_scroll) + active_orders = VBoxContainer.new() + active_orders.size_flags_horizontal = Control.SIZE_EXPAND_FILL + active_scroll.add_child(active_orders) - var active_title := _clerk_heading("Active orders") - clerk.add_child(active_title) - orders = VBoxContainer.new() - orders.add_theme_constant_override("separation", 5) - clerk.add_child(orders) - var exam_title := _clerk_heading("Physical examination") - clerk.add_child(exam_title) - actions = VBoxContainer.new() - actions.add_theme_constant_override("separation", 5) - clerk.add_child(actions) - locations = HFlowContainer.new() - locations.add_theme_constant_override("h_separation", 5) - locations.add_theme_constant_override("v_separation", 5) - clerk.add_child(locations) - time_row = GridContainer.new() - time_row.columns = 4 - time_row.add_theme_constant_override("h_separation", 5) - time_row.add_theme_constant_override("v_separation", 5) - clerk.add_child(time_row) - for minutes in [1, 10, 60, 1440]: - var button := Button.new() - button.text = "+%s" % ("1 day" if minutes == 1440 else "%d min" % minutes) - button.size_flags_horizontal = Control.SIZE_EXPAND_FILL - 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" - end_button.pressed.connect(_end_case) - clerk.add_child(end_button) - leave_button = Button.new() - leave_button.text = "Leave case" - leave_button.pressed.connect(_leave) - clerk.add_child(leave_button) - final_orders_notice = Label.new() - final_orders_notice.visible = false - final_orders_notice.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART - clerk.add_child(final_orders_notice) - clock = Label.new() - clock.visible = false - clerk.add_child(clock) + +func _build_footer() -> void: + var footer := HBoxContainer.new() + footer.add_theme_constant_override("separation", 12) + add_child(footer) + simulated_clock = _footer_label(HORIZONTAL_ALIGNMENT_LEFT) + footer.add_child(simulated_clock) + elapsed_clock = _footer_label(HORIZONTAL_ALIGNMENT_CENTER) + footer.add_child(elapsed_clock) + remaining_clock = _footer_label(HORIZONTAL_ALIGNMENT_RIGHT) + footer.add_child(remaining_clock) status = Label.new() status.visible = false status.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART - clerk.add_child(status) + add_child(status) -func _clerk_heading(text_value: String) -> Label: +func _footer_label(alignment: HorizontalAlignment) -> Label: var label := Label.new() - label.text = text_value - _set_label(label, 13, Brand.PURPLE) - section_labels.append(label) + label.horizontal_alignment = alignment + label.size_flags_horizontal = Control.SIZE_EXPAND_FILL + label.add_theme_font_size_override("font_size", 14) + Brand.apply_font(label) return label +func _build_dialogs() -> void: + intro_dialog = AcceptDialog.new() + intro_dialog.close_requested.connect(_accept_opening_step) + intro_dialog.confirmed.connect(_accept_opening_step) + add_child(intro_dialog) + var intro_box := VBoxContainer.new() + intro_box.custom_minimum_size = Vector2(760, 480) + intro_dialog.add_child(intro_box) + intro_title = Label.new() + intro_title.add_theme_font_size_override("font_size", 22) + Brand.apply_font(intro_title) + intro_box.add_child(intro_title) + intro_body = _clinical_text("opening") + intro_body.size_flags_vertical = Control.SIZE_EXPAND_FILL + intro_body.fit_content = false + intro_body.scroll_active = true + intro_box.add_child(intro_body) + + exam_dialog = ConfirmationDialog.new() + exam_dialog.title = "History & Physical" + exam_dialog.ok_button_text = "Review selection" + exam_dialog.confirmed.connect(_review_exam_selection) + add_child(exam_dialog) + var exam_scroll := ScrollContainer.new() + exam_scroll.custom_minimum_size = Vector2(680, 480) + exam_dialog.add_child(exam_scroll) + var exam_box := VBoxContainer.new() + exam_box.size_flags_horizontal = Control.SIZE_EXPAND_FILL + exam_scroll.add_child(exam_box) + var history_help := Label.new() + history_help.text = "Select physical examination components. Findings become available after the confirmed time advance." + history_help.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + exam_box.add_child(history_help) + exam_box.set_meta("exam_container", true) + + exam_summary_dialog = ConfirmationDialog.new() + exam_summary_dialog.title = "Confirm History & Physical" + exam_summary_dialog.confirmed.connect(_commit_exams) + add_child(exam_summary_dialog) + exam_summary = _clinical_text("exam_confirmation") + exam_summary.custom_minimum_size = Vector2(620, 300) + exam_summary_dialog.add_child(exam_summary) + findings_dialog = AcceptDialog.new() + findings_dialog.title = "History and Physical" + add_child(findings_dialog) + findings_body = _clinical_text("exam_findings") + findings_body.custom_minimum_size = Vector2(720, 440) + findings_body.fit_content = false + findings_body.scroll_active = true + findings_dialog.add_child(findings_body) + + time_dialog = ConfirmationDialog.new() + time_dialog.title = "Obtain Results / See Patient Later" + time_dialog.confirmed.connect(_commit_time) + add_child(time_dialog) + var time_box := VBoxContainer.new() + time_box.custom_minimum_size = Vector2(540, 260) + time_dialog.add_child(time_box) + time_preset = OptionButton.new() + for item in [["10 minutes", 10], ["1 hour", 60], ["4 hours", 240], ["12 hours", 720], ["1 day", 1440], ["Custom target", -1]]: + time_preset.add_item(item[0]) + time_preset.set_item_metadata(time_preset.item_count - 1, item[1]) + time_preset.item_selected.connect(_update_time_preview) + time_box.add_child(time_preset) + custom_target = LineEdit.new() + custom_target.placeholder_text = "YYYY-MM-DD HH:MM (case time)" + custom_target.visible = false + custom_target.text_changed.connect(func(_value: String): _update_time_preview(time_preset.selected)) + time_box.add_child(custom_target) + time_preview = Label.new() + time_preview.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + time_box.add_child(time_preview) + + location_dialog = ConfirmationDialog.new() + location_dialog.title = "Change Location" + location_dialog.confirmed.connect(_commit_location) + add_child(location_dialog) + var location_box := VBoxContainer.new() + location_box.custom_minimum_size = Vector2(480, 180) + location_dialog.add_child(location_box) + var location_help := Label.new() + location_help.text = "Select the patient’s destination:" + location_box.add_child(location_help) + location_option = OptionButton.new() + location_box.add_child(location_option) + + end_dialog = ConfirmationDialog.new() + end_dialog.title = "Enter Final Orders?" + end_dialog.dialog_text = "This ends active case management. You will have up to two minutes to finalize or cancel patient-care orders." + end_dialog.ok_button_text = "Enter Final Orders" + end_dialog.confirmed.connect(func(): CCSService.send_command("end_case")) + add_child(end_dialog) + final_instructions = AcceptDialog.new() + final_instructions.title = "Case-end Instructions" + final_instructions.dialog_text = "Finalize care of the patient’s current condition.\n\n• Cancel orders that should stop now.\n• Add orders that should be done now.\n• Add relevant future-care orders.\n\nThen submit final orders before time expires." + final_instructions.confirmed.connect(_open_order_sheet) + add_child(final_instructions) + abandon_dialog = ConfirmationDialog.new() + abandon_dialog.title = "Abandon CCS case?" + abandon_dialog.dialog_text = "This attempt will end without a score and cannot be resumed." + abandon_dialog.ok_button_text = "Abandon Case" + abandon_dialog.confirmed.connect(_confirm_abandon) + add_child(abandon_dialog) + + func _build_runtime_services() -> void: timer = Timer.new() timer.wait_time = 1 @@ -331,7 +427,6 @@ func _build_runtime_services() -> void: func on_show(payload: Variant = null) -> void: _set_status("") leaving = false - leave_button.disabled = false if typeof(payload) == TYPE_STRING and not str(payload).is_empty(): CCSService.load_attempt(str(payload)) _render() @@ -349,46 +444,484 @@ func _render() -> void: if str(attempt.get("state", "")) == "completed": main.show_screen("ccs_review", attempt) return - heading.text = "CCS Case | %s" % _humanize_code(str(attempt.get("location", ""))) - _render_study_text() - _render_vitals() - _render_orders() - _render_commands() + var was_final := exit_button.text == "Submit Final Orders" var final_orders := str(attempt.get("state", "")) == "final_orders" - end_button.text = "Submit final orders" if final_orders else "End case" - time_row.visible = not final_orders + for code in staged_order_codes.duplicate(): + if code in attempt.get("activeOrderCodes", []): + staged_order_codes.erase(code) + staged_order_names.erase(code) + heading.text = "CCS Case | %s" % _humanize_code(str(attempt.get("location", ""))) + var total := int(attempt.get("realTimeMinutes", 20)) + allotted_label.text = ( + "Legacy untimed CCS case" + if str(attempt.get("mode", "timed")) == "practice" + else "Maximum allotted real time: %d minutes + 2 minutes for final orders" % maxi(1, total - 2) + ) + exit_button.text = "Submit Final Orders" if final_orders else "Exit Case" + exit_button.tooltip_text = "Submit final orders and complete the case" if final_orders else "Enter final orders" final_orders_notice.visible = final_orders - final_orders_notice.text = "Finalize or cancel patient-care orders, then submit." + for button in action_buttons: + button.disabled = final_orders + action_buttons[1].disabled = false + _render_active_orders() + _render_staged_orders() + _render_chart() _tick() _apply_text_scale() + if not bool(attempt.get("openingCompleted", true)) and not opening_active: + _start_opening() + if final_orders and not was_final: + final_instructions.popup_centered(Vector2i(820, 500)) + _open_order_sheet() + if awaiting_exam_result and not CCSService.is_loading: + awaiting_exam_result = false + _show_new_findings() -func _render_vitals() -> void: - _clear(vitals_flow) - var vitals: Dictionary = CCSService.attempt.get("vitals", {}) - for key in _ordered_vital_keys(vitals): - var chip := PanelContainer.new() - chip.add_theme_stylebox_override("panel", _vital_chip_box()) - vitals_flow.add_child(chip) +func _start_opening() -> void: + opening_active = true + opening_step = 0 + _show_opening_step() + + +func _show_opening_step() -> void: + var attempt: Dictionary = CCSService.attempt + match opening_step: + 0: + intro_dialog.title = "Case Introduction" + intro_title.text = _simulated_time_text(0) + intro_body.text = "[center][b]%s[/b]\n\n%s[/center]" % [ + _escape_bbcode(_humanize_code(str(attempt.get("location", "")))), + _escape_bbcode(str(attempt.get("reasonForEncounter", ""))) + ] + 1: + intro_dialog.title = "Initial Vital Signs" + intro_title.text = _simulated_time_text(0) + intro_body.text = _vitals_bbcode(attempt.get("vitals", {})) + 2: + intro_dialog.title = "Initial History" + intro_title.text = str(attempt.get("patientSummary", "")) + intro_body.text = _history_bbcode() + intro_dialog.popup_centered(Vector2i(820, 560)) + + +func _accept_opening_step() -> void: + if not opening_active: + return + opening_step += 1 + if opening_step < 3: + call_deferred("_show_opening_step") + else: + opening_active = false + CCSService.send_command("acknowledge_opening") + + +func _open_exam() -> void: + if _is_final_orders(): + return + var container: VBoxContainer + for node in exam_dialog.find_children("*", "VBoxContainer", true, false): + if node.has_meta("exam_container"): + container = node + break + if container == null: + return + for child in container.get_children(): + if child is CheckBox: + child.queue_free() + exam_checks.clear() + interval_history_check = CheckBox.new() + interval_history_check.text = "Interval / follow-up history (1 min)" + container.add_child(interval_history_check) + for raw_exam in CCSService.attempt.get("availableExams", []): + var exam: Dictionary = raw_exam + var check := CheckBox.new() + var minutes := maxi(1, int(exam.get("durationMinutes", 1))) + check.text = "%s (%d min)" % [str(exam.get("name", _humanize_code(str(exam.get("code", ""))))), minutes] + check.set_meta("exam_code", str(exam.get("code", ""))) + check.set_meta("exam_minutes", minutes) + container.add_child(check) + exam_checks[str(exam.get("code", ""))] = check + exam_dialog.popup_centered(Vector2i(740, 600)) + + +func _review_exam_selection() -> void: + staged_exam_codes.clear() + staged_interval_history = interval_history_check != null and interval_history_check.button_pressed + var total := 0 + var lines: Array[String] = [] + if staged_interval_history: + total += 1 + lines.append("• Interval / follow-up history — 1 min") + for code in exam_checks: + var check: CheckBox = exam_checks[code] + if check.button_pressed: + staged_exam_codes.append(code) + var minutes := int(check.get_meta("exam_minutes", 1)) + total += minutes + lines.append("• %s — %d min" % [check.text.get_slice(" (", 0), minutes]) + if staged_exam_codes.is_empty() and not staged_interval_history: + _set_status("Select interval history or at least one examination component.") + return + exam_summary.text = "%s\n\n[b]Total simulated time: %d minutes[/b]\nThis will advance the clock to [b]%s[/b]." % [ + "\n".join(lines), total, _simulated_time_text(int(CCSService.attempt.get("simulatedMinute", 0)) + total) + ] + exam_summary_dialog.popup_centered(Vector2i(680, 420)) + + +func _commit_exams() -> void: + var commands: Array = [] + if staged_interval_history: + commands.append({"type": "interval_history", "code": "", "minutes": 0}) + for code in staged_exam_codes: + commands.append({"type": "exam", "code": code, "minutes": 0}) + pending_findings_before = _chart_entries("progress").size() + awaiting_exam_result = true + CCSService.send_batch(commands) + + +func _show_new_findings() -> void: + var entries := _chart_entries("progress") + var start := mini(pending_findings_before, entries.size()) + var text := "[center][b]%s[/b][/center]\n\n" % _simulated_time_text(int(CCSService.attempt.get("simulatedMinute", 0))) + for index in range(start, entries.size()): + var entry: Dictionary = entries[index] + if str(entry.get("code", "")).is_empty(): + continue + text += "[b]%s[/b]\n%s\n\n" % [_escape_bbcode(str(entry.get("title", ""))), _escape_bbcode(str(entry.get("text", "")))] + findings_body.text = text + findings_dialog.popup_centered(Vector2i(780, 540)) + + +func _open_order_sheet() -> void: + tabs.current_tab = 0 + search.grab_focus() + + +func _submit_search() -> void: + if search.text.strip_edges().length() < 2: + _set_status("Enter at least two characters to search.") + return + _set_status("") + CCSService.search_orders(search.text) + + +func _render_search() -> void: + _clear(search_results) + for raw_order in CCSService.orders: + var order: Dictionary = raw_order + var code := str(order.get("code", "")) + var button := Button.new() + button.text = "%s · %s" % [str(order.get("canonicalName", code)), _humanize_code(str(order.get("category", "")))] + button.alignment = HORIZONTAL_ALIGNMENT_LEFT + button.disabled = code in staged_order_codes or code in CCSService.attempt.get("activeOrderCodes", []) + button.pressed.connect(_stage_order.bind(code)) + search_results.add_child(button) + + +func _stage_order(code: String) -> void: + if code.is_empty() or code in staged_order_codes: + return + staged_order_names[code] = _catalog_name(code) + staged_order_codes.append(code) + search.text = "" + CCSService.orders = [] + _render_search() + _render_staged_orders() + + +func _render_staged_orders() -> void: + _clear(staged_orders) + for code in staged_order_codes: var row := HBoxContainer.new() - row.add_theme_constant_override("separation", 7) - chip.add_child(row) - var icon := TextureRect.new() - icon.texture = _vital_icon(key) - icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE - icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED - icon.custom_minimum_size = Vector2(18, 18) - icon.modulate = _vital_color(key) - row.add_child(icon) + staged_orders.add_child(row) var label := Label.new() - label.text = "%s: %s" % [_vital_label(key), str(vitals[key])] - _set_label(label, 13, Brand.text_color(_is_dark())) + label.text = _catalog_name(code) + label.size_flags_horizontal = Control.SIZE_EXPAND_FILL row.add_child(label) + var remove := Button.new() + remove.text = "Remove" + remove.pressed.connect(_unstage_order.bind(code)) + row.add_child(remove) + commit_orders_button.disabled = staged_order_codes.is_empty() + clear_orders_button.disabled = staged_order_codes.is_empty() + + +func _unstage_order(code: String) -> void: + staged_order_codes.erase(code) + staged_order_names.erase(code) + _render_staged_orders() + + +func _clear_staged_orders() -> void: + staged_order_codes.clear() + staged_order_names.clear() + _render_staged_orders() + + +func _commit_orders() -> void: + if staged_order_codes.is_empty(): + return + var commands: Array = [] + for code in staged_order_codes: + commands.append({"type": "order", "code": code, "minutes": 0}) + CCSService.orders = [] + _render_search() + _render_staged_orders() + CCSService.send_batch(commands) + + +func _render_active_orders() -> void: + _clear(active_orders) + var rows: Array = CCSService.attempt.get("activeOrders", []) + if rows.is_empty(): + var empty := Label.new() + empty.text = "No active orders." + active_orders.add_child(empty) + return + for raw_order in rows: + var order: Dictionary = raw_order + var row := HBoxContainer.new() + row.custom_minimum_size.y = 44 + active_orders.add_child(row) + _add_order_cell(row, str(order.get("name", _humanize_code(str(order.get("code", ""))))), 2.4) + _add_order_cell(row, str(order.get("route", "—")) if not str(order.get("route", "")).is_empty() else "—", 0.8) + _add_order_cell(row, str(order.get("frequency", "—")) if not str(order.get("frequency", "")).is_empty() else "—", 1.0) + _add_order_cell(row, _simulated_time_text(int(order.get("orderedMinute", 0)), false), 1.0) + var report_value: Variant = order.get("reportMinute", null) + var report_text := "—" + if bool(order.get("isPending", false)): + report_text = "Pending" + elif bool(order.get("hasResult", report_value != null)) and report_value != null: + report_text = _simulated_time_text(int(report_value), false) + _add_order_cell(row, report_text, 1.0) + var cancel := Button.new() + cancel.text = "Cancel" + cancel.size_flags_horizontal = Control.SIZE_EXPAND_FILL + cancel.size_flags_stretch_ratio = 0.8 + cancel.pressed.connect(_cancel_order.bind(str(order.get("code", "")))) + row.add_child(cancel) + + +func _add_order_cell(row: HBoxContainer, value: String, ratio: float) -> void: + var label := Label.new() + label.text = value + label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + label.size_flags_horizontal = Control.SIZE_EXPAND_FILL + label.size_flags_stretch_ratio = ratio + row.add_child(label) + + +func _cancel_order(code: String) -> void: + CCSService.send_batch([{"type": "cancel_order", "code": code, "minutes": 0}]) + + +func _open_time_dialog() -> void: + if _is_final_orders(): + return + time_preset.select(0) + custom_target.visible = false + _update_time_preview(0) + time_dialog.popup_centered(Vector2i(600, 350)) + + +func _update_time_preview(index: int) -> void: + var minutes := int(time_preset.get_item_metadata(index)) + custom_target.visible = minutes < 0 + if minutes < 0: + minutes = _custom_target_minutes() + time_preview.text = "Clock will advance to %s." % _simulated_time_text(int(CCSService.attempt.get("simulatedMinute", 0)) + maxi(0, minutes)) + time_dialog.get_ok_button().disabled = minutes <= 0 + + +func _custom_target_minutes() -> int: + var value := custom_target.text.strip_edges().replace(" ", "T") + if value.length() == 16: + value += ":00" + var target := Time.get_unix_time_from_datetime_string(value) + if target <= 0: + return 0 + var start_text := str(CCSService.attempt.get("simulatedStartUtc", "")).trim_suffix("Z") + var start_unix := Time.get_unix_time_from_datetime_string(start_text) + var current_unix := start_unix + int(CCSService.attempt.get("simulatedMinute", 0)) * 60 + return maxi(0, int((target - current_unix) / 60.0)) + + +func _commit_time() -> void: + var minutes := int(time_preset.get_item_metadata(time_preset.selected)) + if minutes < 0: + minutes = _custom_target_minutes() + if minutes > 0: + CCSService.send_command("advance", "", minutes) + + +func _open_location_dialog() -> void: + if _is_final_orders(): + return + location_option.clear() + for raw_location in CCSService.attempt.get("allowedLocations", []): + var location := str(raw_location) + location_option.add_item(_humanize_code(location)) + location_option.set_item_metadata(location_option.item_count - 1, location) + location_dialog.get_ok_button().disabled = location_option.item_count == 0 + location_dialog.popup_centered(Vector2i(540, 280)) + + +func _commit_location() -> void: + if location_option.item_count > 0: + CCSService.send_command("location", str(location_option.get_item_metadata(location_option.selected))) + + +func _request_end_case() -> void: + if _is_final_orders(): + CCSService.send_command("exit_case") + else: + end_dialog.popup_centered(Vector2i(560, 260)) + + +func leave_case() -> void: + abandon_dialog.popup_centered(Vector2i(520, 220)) + + +func _confirm_abandon() -> void: + if leaving: + return + leaving = true + CCSService.abandon() + + +func _on_abandoned() -> void: + if leaving: + leaving = false + main.show_screen("ccs_hub") + + +func _render_chart() -> void: + for definition in CHART_TABS: + var category := str(definition[1]) + if category == "orders": + continue + var label: RichTextLabel = chart_views.get(category) + if label == null: + continue + label.text = _chart_category_bbcode(category) + + +func _chart_category_bbcode(category: String) -> String: + if category == "vitals": + return _vital_history_bbcode() + var entries := _chart_entries(category) + if entries.is_empty(): + return "[center][color=#8490a5]No %s available.[/color][/center]" % str(_tab_title(category)).to_lower() + var text := "" + for raw_entry in entries: + var entry: Dictionary = raw_entry + text += "[b]%s[/b] [color=#8490a5]%s[/color]\n%s\n\n" % [ + _escape_bbcode(str(entry.get("title", _tab_title(category)))), + _escape_bbcode(_simulated_time_text(int(entry.get("simulatedMinute", 0)), false)), + _escape_bbcode(str(entry.get("text", ""))) + ] + return text + + +func _chart_entries(category: String) -> Array: + var result: Array = [] + for raw_entry in CCSService.attempt.get("chart", []): + var entry: Dictionary = raw_entry + var actual := str(entry.get("category", "progress")) + if actual == "exam": + actual = "progress" + elif actual == "results": + actual = "other_test" + if actual == category: + result.append(entry) + return result + + +func _vital_history_bbcode() -> String: + var snapshots: Array = CCSService.attempt.get("vitalHistory", []) + if snapshots.is_empty(): + return _vitals_bbcode(CCSService.attempt.get("vitals", {})) + var text := "" + for raw_snapshot in snapshots: + var snapshot: Dictionary = raw_snapshot + text += "[b]%s[/b]\n%s\n\n" % [ + _escape_bbcode(_simulated_time_text(int(snapshot.get("simulatedMinute", 0)), false)), + _vitals_bbcode(snapshot.get("values", {})) + ] + return text + + +func _vitals_bbcode(vitals: Dictionary) -> String: + var lines: Array[String] = [] + for key in _ordered_vital_keys(vitals): + lines.append("[b]%s:[/b] %s" % [_escape_bbcode(_vital_label(key)), _escape_bbcode(str(vitals[key]))]) + return "\n".join(lines) + + +func _history_bbcode() -> String: + var sections: Array = CCSService.attempt.get("historySections", []) + if sections.is_empty(): + return "[b]History of present illness[/b]\n%s\n\n[b]Initial appearance[/b]\n%s" % [ + _escape_bbcode(str(CCSService.attempt.get("history", ""))), + _escape_bbcode(str(CCSService.attempt.get("appearance", ""))) + ] + var text := "" + for raw_section in sections: + var section: Dictionary = raw_section + text += "[b]%s[/b]\n%s\n\n" % [_escape_bbcode(str(section.get("title", ""))), _escape_bbcode(str(section.get("text", "")))] + return text + + +func _tick() -> void: + if CCSService.attempt.is_empty(): + return + var now := Time.get_unix_time_from_system() + var created_text := str(CCSService.attempt.get("createdUtc", "")).trim_suffix("Z") + var created := Time.get_unix_time_from_datetime_string(created_text) + var elapsed := maxi(0, int(now - created)) + var phase_deadline := CCSService.deadline_unix() + var hard_deadline := CCSService.hard_deadline_unix() + var display_deadline := phase_deadline if _is_final_orders() else hard_deadline + var remaining := maxi(0, int(ceil(display_deadline - now))) + var phase_remaining := maxi(0, int(ceil(phase_deadline - now))) + simulated_clock.text = "Elapsed SIMULATED case time: %s" % _elapsed_simulated_text(int(CCSService.attempt.get("simulatedMinute", 0))) + elapsed_clock.text = "Elapsed REAL time: %s" % _duration_text(elapsed) + remaining_clock.text = ( + "Legacy untimed case" + if hard_deadline <= 0 + else "%s REAL time: %s" % ["Final-order" if _is_final_orders() else "Remaining", _duration_text(remaining)] + ) + if phase_remaining == 0 and phase_deadline > 0 and not CCSService.is_loading: + CCSService.load_attempt(str(CCSService.attempt.get("id", ""))) + + +func _simulated_time_text(minute: int, include_day: bool = true) -> String: + var start_text := str(CCSService.attempt.get("simulatedStartUtc", "")).trim_suffix("Z") + var unix := Time.get_unix_time_from_datetime_string(start_text) + if unix <= 0: + return "Minute %d" % minute + var value := Time.get_datetime_dict_from_unix_time(unix + minute * 60) + var hour := int(value.hour) + var suffix := "AM" if hour < 12 else "PM" + var display_hour := hour % 12 + if display_hour == 0: + display_hour = 12 + var day_number := 1 + int(floor(float(minute) / 1440.0)) + return "%s%d:%02d %s" % ["Day %d @ " % day_number if include_day else "", display_hour, int(value.minute), suffix] + + +func _elapsed_simulated_text(minutes: int) -> String: + return "%d Days %02d Hrs %02d Mins" % [minutes / 1440, (minutes % 1440) / 60, minutes % 60] + + +func _duration_text(seconds: int) -> String: + return "%02d:%02d" % [seconds / 60, seconds % 60] func _ordered_vital_keys(vitals: Dictionary) -> Array[String]: var ordered: Array[String] = [] - for key in ["temperature", "bloodPressure", "pulse", "respirations", "oxygenSaturation"]: + for key in ["temperature", "bloodPressure", "pulse", "respirations", "oxygenSaturation", "height", "weight", "bmi"]: if vitals.has(key): ordered.append(key) var remaining: Array[String] = [] @@ -405,201 +938,39 @@ func _vital_label(key: String) -> String: match key: "bloodPressure": return "Blood Pressure" "oxygenSaturation": return "Oxygen Saturation" + "bmi": return "Body Mass Index" _: return _humanize_code(key) -func _vital_icon(key: String) -> Texture2D: - match key: - "temperature": return ICON_THERMOMETER - "bloodPressure", "pulse": return ICON_HEART - "respirations": return ICON_LUNGS - _: return ICON_ACTIVITY - - -func _vital_color(key: String) -> Color: - match key: - "temperature", "oxygenSaturation": return Brand.SKY_BLUE - "bloodPressure", "pulse": return Color("fb7185") - _: return Brand.PURPLE - - -func _render_orders() -> void: - _clear(orders) - var active_codes: Array = CCSService.attempt.get("activeOrderCodes", []) - if active_codes.is_empty(): - var empty := Label.new() - empty.text = "No active orders" - _set_label(empty, 12, Brand.muted_color(_is_dark())) - orders.add_child(empty) - return - for raw_code in active_codes: - var code := str(raw_code) - var row := HBoxContainer.new() - row.add_theme_constant_override("separation", 6) - orders.add_child(row) - var label := Label.new() - label.text = _humanize_code(code) - label.size_flags_horizontal = Control.SIZE_EXPAND_FILL - label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART - _set_label(label, 13, Brand.text_color(_is_dark())) - row.add_child(label) - var cancel := Button.new() - cancel.text = "Cancel" - cancel.pressed.connect(CCSService.send_command.bind("cancel_order", code, 0)) - _style_secondary_button(cancel) - row.add_child(cancel) - - -func _render_commands() -> void: - _clear(actions) - for exam in CCSService.attempt.get("availableExams", []): - if typeof(exam) != TYPE_DICTIONARY: - continue - var button := Button.new() - button.text = str(exam.get("name", _humanize_code(str(exam.get("code", "Exam"))))) - button.pressed.connect(CCSService.send_command.bind("exam", str(exam.get("code", "")), 0)) - _style_secondary_button(button) - actions.add_child(button) - _clear(locations) - for raw_location in CCSService.attempt.get("allowedLocations", []): - var location := str(raw_location) - var button := Button.new() - button.text = "Move to %s" % _humanize_code(location) - button.custom_minimum_size.x = 150 - button.size_flags_horizontal = Control.SIZE_EXPAND_FILL - button.pressed.connect(CCSService.send_command.bind("location", location, 0)) - _style_secondary_button(button) - locations.add_child(button) - _style_command_buttons() - - -func _submit_search() -> void: - if search.text.strip_edges().length() < 2: - _set_status("Enter at least two characters to search.") - return - _set_status("") - CCSService.search_orders(search.text) - - -func _render_search() -> void: - _clear(search_results) - search_results_panel.visible = not CCSService.orders.is_empty() - for order in CCSService.orders: - if typeof(order) != TYPE_DICTIONARY: - continue - var button := Button.new() - button.text = "%s (%s)" % [order.get("canonicalName", ""), _humanize_code(str(order.get("category", "")))] - button.alignment = HORIZONTAL_ALIGNMENT_LEFT - button.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART - button.pressed.connect(_place_order.bind(str(order.get("code", "")))) - _style_secondary_button(button) - search_results.add_child(button) - - -func _place_order(code: String) -> void: - CCSService.send_command("order", code) - search.text = "" - CCSService.orders = [] - _render_search() - - -func _tick() -> void: - var deadline := CCSService.deadline_unix() - var final_orders := str(CCSService.attempt.get("state", "")) == "final_orders" - clock.visible = final_orders and deadline > 0 - if deadline <= 0: - clock.text = "" - return - var remaining := maxi(0, int(ceil(deadline - Time.get_unix_time_from_system()))) - if final_orders: - clock.text = "Final orders: %02d:%02d remaining" % [floori(float(remaining) / 60.0), remaining % 60] - if remaining == 0 and not CCSService.is_loading: - CCSService.load_attempt(str(CCSService.attempt.get("id", ""))) - - -func leave_case() -> void: - _leave() - - -func _leave() -> void: - if leaving or CCSService.attempt.is_empty(): - return - leaving = true - leave_button.disabled = true - _set_status("Leaving case...", false) - CCSService.abandon() - - -func _on_abandoned() -> void: - if not leaving: - return - leaving = false - main.show_screen("ccs_hub") - - -func _request_failed(message: String) -> void: - if not visible: - return - _set_status(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") - - -func _clear(parent: Node) -> void: - for child in parent.get_children(): - parent.remove_child(child) - child.queue_free() - - -func _chart_text(entries: Array) -> String: - var lines: Array[String] = [] - for entry in entries: - if typeof(entry) != TYPE_DICTIONARY: - continue - var category := str(entry.get("category", "note")) - var category_label := "In Progress" if category == "progress" else _humanize_code(category) - lines.append("T+%d (%s): %s" % [entry.get("simulatedMinute", 0), category_label, entry.get("text", "")]) - return "\n\n".join(lines) - - -func _summary_plain() -> String: - var attempt := CCSService.attempt - return "%s\n%s" % [attempt.get("patientSummary", ""), attempt.get("reasonForEncounter", "")] - - -func _render_study_text() -> void: - var attempt_id := str(CCSService.attempt.get("id", "")) - summary.text = CCSService.highlighted_text(attempt_id, "summary", _summary_plain()) - history.text = CCSService.highlighted_text(attempt_id, "history", str(CCSService.attempt.get("history", ""))) - appearance.text = CCSService.highlighted_text(attempt_id, "appearance", str(CCSService.attempt.get("appearance", ""))) - chart.text = CCSService.highlighted_text(attempt_id, "chart", _chart_text(CCSService.attempt.get("chart", []))) +func _clinical_text(region: String) -> RichTextLabel: + var label := RichTextLabel.new() + label.bbcode_enabled = true + label.selection_enabled = true + label.fit_content = true + label.scroll_active = false + label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + label.add_theme_font_override("normal_font", Brand.font) + label.add_theme_font_override("bold_font", Brand.font) + label.set_meta("ccs_base_font_size", 16) + label.set_meta("ccs_region", region) + label.gui_input.connect(_text_input.bind(label)) + clinical_labels.append(label) + return label func _text_input(event: InputEvent, label: RichTextLabel) -> void: - var mouse := event as InputEventMouseButton - if mouse != null and mouse.button_index == MOUSE_BUTTON_LEFT and not mouse.pressed: + if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT and not event.pressed: call_deferred("_commit_selection", label) func _commit_selection(label: RichTextLabel) -> void: - var selected := label.get_selected_text().strip_edges() - if selected.is_empty(): + var text := label.get_selected_text() + if text.is_empty(): return - var start := label.get_selection_from() - var end := label.get_selection_to() - CCSService.toggle_highlight( - str(CCSService.attempt.get("id", "")), - str(label.get_meta("ccs_region", "")), - mini(start, end), - maxi(start, end) - ) - label.deselect() - _render_study_text() + var source := label.get_parsed_text() + var start := source.find(text) + if start >= 0: + CCSService.toggle_highlight(str(CCSService.attempt.get("id", "")), str(label.get_meta("ccs_region", "")), start, start + text.length()) func _zoom(delta: float) -> void: @@ -608,183 +979,102 @@ func _zoom(delta: float) -> void: func _apply_text_scale() -> void: - for label in [summary, history, appearance, chart]: - var base_size := int(label.get_meta("ccs_base_font_size", 16)) - var font_size := int(round(float(base_size) * CCSService.text_scale)) - 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(CCSService.attempt.get("id", ""))) - _render_study_text() + for label in clinical_labels: + var size := maxi(12, int(round(float(label.get_meta("ccs_base_font_size", 16)) * CCSService.text_scale))) + label.add_theme_font_size_override("normal_font_size", size) + label.add_theme_font_size_override("bold_font_size", size) func _report() -> void: - report_dialog.show_for(str(CCSService.attempt.get("id", ""))) + if report_dialog != null: + report_dialog.open(CCSService.attempt) func _report_submitted(_id: String) -> void: - report_button.tooltip_text = "Report submitted; report another issue" - _style_outline_button(report_button, Brand.SUCCESS) + _set_status("Case report submitted.", false) + + +func _request_failed(message: String) -> void: + if visible: + awaiting_exam_result = false + _set_status(message) func _set_status(message: String, is_error: bool = true) -> void: - if status == null: - return status.text = message status.visible = not message.is_empty() - status.add_theme_color_override("font_color", Color("fb7185") if is_error else Brand.SKY_BLUE) + status.add_theme_color_override("font_color", Color("ff6b7f") if is_error else Brand.SUCCESS) + + +func _catalog_name(code: String) -> String: + if staged_order_names.has(code): + return str(staged_order_names[code]) + for raw_order in CCSService.orders: + var order: Dictionary = raw_order + if str(order.get("code", "")) == code: + return str(order.get("canonicalName", _humanize_code(code))) + return _humanize_code(code) + + +func _tab_title(category: String) -> String: + for definition in CHART_TABS: + if str(definition[1]) == category: + return str(definition[0]) + return _humanize_code(category) func _humanize_code(value: String) -> String: - return value.replace("_", " ").replace("-", " ").capitalize() + return value.replace("_", " ").capitalize() + + +func _escape_bbcode(value: String) -> String: + return value.replace("[", "[lb]").replace("]", "[rb]") + + +func _is_final_orders() -> bool: + return str(CCSService.attempt.get("state", "")) == "final_orders" + + +func _clear(parent: Node) -> void: + for child in parent.get_children(): + child.queue_free() func _update_responsive_layout() -> void: - if workspace == null or header_layout == null: + if action_grid == null: return var narrow := size.x > 0 and size.x < RESPONSIVE_BREAKPOINT - workspace.vertical = narrow header_layout.vertical = narrow - toolbar.custom_minimum_size.x = 0 if narrow else 440 + action_grid.columns = 2 if narrow else 4 toolbar.size_flags_horizontal = Control.SIZE_EXPAND_FILL if narrow else Control.SIZE_SHRINK_END - chart_panel.custom_minimum_size.x = 0 if narrow else 600 - clerk_panel.custom_minimum_size.x = 0 if narrow else 370 - clerk_panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL if narrow else Control.SIZE_SHRINK_END + for button in action_buttons: + button.custom_minimum_size.x = 0 if narrow else 220 queue_sort() -func _on_theme_changed(_is_dark_mode: bool) -> void: - call_deferred("_finish_theme_change") - - -func _finish_theme_change() -> void: - _apply_theme() - if visible and not CCSService.attempt.is_empty(): - _render() +func _on_theme_changed(_dark: bool) -> void: + call_deferred("_apply_theme") func _apply_theme() -> void: - var dark := _is_dark() if header_panel == null: return + var dark: bool = theme_service != null and theme_service.is_dark_mode() header_panel.add_theme_stylebox_override("panel", Brand.content_card_box(dark, false)) - chart_panel.add_theme_stylebox_override("panel", Brand.content_card_box(dark, false)) - clerk_panel.add_theme_stylebox_override("panel", Brand.content_card_box(dark, false)) - header_icon_panel.add_theme_stylebox_override("panel", Brand.icon_circle_box(dark, Brand.PURPLE, 6, true)) + workspace.add_theme_stylebox_override("panel", Brand.content_card_box(dark, false)) heading.add_theme_color_override("font_color", Brand.text_color(dark)) - for label in section_labels: - label.add_theme_color_override("font_color", Brand.PURPLE if dark else Brand.ROYAL_BLUE) - for node in find_children("*", "Label", true, false): - var label := node as Label - if label.has_meta("ccs_muted_label"): - label.add_theme_color_override("font_color", Brand.muted_color(dark)) - for label in [summary, history, appearance, chart]: + allotted_label.add_theme_color_override("font_color", Brand.ROYAL_BLUE if not dark else Brand.SKY_BLUE) + for label in [simulated_clock, elapsed_clock, remaining_clock]: + label.add_theme_color_override("font_color", Brand.text_color(dark)) + final_orders_notice.add_theme_color_override("font_color", Brand.AMBER) + for button in toolbar_buttons + action_buttons: + button.add_theme_font_override("font", Brand.font) + button.add_theme_color_override("font_color", Brand.text_color(dark)) + button.add_theme_color_override("font_hover_color", Brand.text_color(dark)) + button.add_theme_stylebox_override("normal", Brand.content_card_box(dark, false)) + button.add_theme_stylebox_override("hover", Brand.content_card_box(dark, true)) + for label in clinical_labels: label.add_theme_color_override("default_color", Brand.text_color(dark)) - search.add_theme_font_override("font", Brand.font) - search.add_theme_color_override("font_color", Brand.text_color(dark)) - search.add_theme_color_override("font_placeholder_color", Brand.muted_color(dark)) - search.add_theme_stylebox_override("normal", _compact_field_box(dark)) - search.add_theme_stylebox_override("focus", _compact_field_box(dark, true)) - search_results_panel.add_theme_stylebox_override("panel", Brand.content_card_box(dark, false)) - for button in toolbar_buttons: - _style_secondary_button(button) - _style_icon_button(search_button) - _style_outline_button(end_button, Brand.AMBER) - _style_outline_button(leave_button, Color("fb7185")) - for button in time_row.get_children(): - if button is Button: - _style_secondary_button(button) Brand.set_theme_toggle_icon(theme_button, dark) theme_button.tooltip_text = "Switch to light mode" if dark else "Switch to dark mode" - final_orders_notice.add_theme_color_override("font_color", Brand.AMBER) - clock.add_theme_color_override("font_color", Brand.AMBER) - _render_vitals() - - -func _style_command_buttons() -> void: - for container in [actions, locations]: - for child in container.get_children(): - if child is Button: - _style_secondary_button(child) - var reassess := time_row.get_parent().get_child(time_row.get_index() + 1) as Button - if reassess != null: - _style_secondary_button(reassess) - - -func _style_secondary_button(button: Button) -> void: - var dark := _is_dark() - button.add_theme_font_override("font", Brand.font) - button.add_theme_font_size_override("font_size", 13) - button.add_theme_color_override("font_color", Brand.text_color(dark)) - button.add_theme_color_override("font_hover_color", Brand.text_color(dark)) - button.add_theme_stylebox_override("normal", _compact_button_box(dark)) - button.add_theme_stylebox_override("hover", _compact_button_box(dark, true)) - button.add_theme_stylebox_override("pressed", _compact_button_box(dark, true)) - - -func _style_icon_button(button: Button) -> void: - _style_secondary_button(button) - button.add_theme_constant_override("icon_max_width", 20) - var color := Brand.SKY_BLUE if _is_dark() else Brand.ROYAL_BLUE - for color_name in ["icon_normal_color", "icon_hover_color", "icon_pressed_color", "icon_focus_color"]: - button.add_theme_color_override(color_name, color) - - -func _style_outline_button(button: Button, accent: Color) -> void: - var dark := _is_dark() - var normal := _compact_button_box(dark) - normal.border_color = accent - var hover := normal.duplicate() as StyleBoxFlat - hover.bg_color = Color(accent, 0.1) - button.add_theme_font_override("font", Brand.font) - button.add_theme_font_size_override("font_size", 13) - button.add_theme_color_override("font_color", accent) - button.add_theme_color_override("font_hover_color", accent) - button.add_theme_stylebox_override("normal", normal) - button.add_theme_stylebox_override("hover", hover) - button.add_theme_stylebox_override("pressed", hover) - - -func _compact_field_box(dark: bool, focused: bool = false) -> StyleBoxFlat: - var box := StyleBoxFlat.new() - box.bg_color = Color("0d172a") if dark else Brand.LIGHT_SURFACE - box.border_color = Brand.ROYAL_BLUE if focused else (Color("33415d") if dark else Brand.LIGHT_BORDER) - box.set_border_width_all(1) - box.set_corner_radius_all(6) - box.content_margin_left = 12 - box.content_margin_right = 12 - box.content_margin_top = 8 - box.content_margin_bottom = 8 - return box - - -func _compact_button_box(dark: bool, hover: bool = false) -> StyleBoxFlat: - var box := _compact_field_box(dark, hover) - box.bg_color = Color("111b30") if dark else Brand.LIGHT_SURFACE - return box - - -func _vital_chip_box() -> StyleBoxFlat: - var dark := _is_dark() - var box := StyleBoxFlat.new() - box.bg_color = Color("111b30") if dark else Color("f8faff") - box.border_color = Color("33415d") if dark else Brand.LIGHT_BORDER - box.set_border_width_all(1) - box.set_corner_radius_all(6) - box.content_margin_left = 10 - box.content_margin_right = 10 - box.content_margin_top = 8 - box.content_margin_bottom = 8 - return box - - -func _set_label(label: Label, font_size: int, color: Color) -> void: - label.add_theme_font_override("font", Brand.font) - label.add_theme_font_size_override("font_size", font_size) - label.add_theme_color_override("font_color", color) - - -func _is_dark() -> bool: - return theme_service != null and theme_service.is_dark_mode() + _apply_text_scale() diff --git a/scripts/ui/main.gd b/scripts/ui/main.gd index 369c05d..3a64801 100644 --- a/scripts/ui/main.gd +++ b/scripts/ui/main.gd @@ -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"] if menu_exit_button.visible: 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 "Leave (timer continues)" if BlockSessionService.session_kind == "simulation" else "Exit Run") diff --git a/tests/ccs_player_test.gd b/tests/ccs_player_test.gd index 1dc10e5..6ec725e 100644 --- a/tests/ccs_player_test.gd +++ b/tests/ccs_player_test.gd @@ -1,8 +1,5 @@ extends SceneTree -const Style := preload("res://scripts/ui/ui_style.gd") -const Brand := preload("res://scripts/ui/medmind_style.gd") - var failures := 0 var ccs_service @@ -13,8 +10,6 @@ func _initialize() -> void: func _run() -> void: 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_orders: Array = ccs_service.orders.duplicate(true) var prior_scale: float = ccs_service.text_scale @@ -33,58 +28,42 @@ func _run() -> void: player._render() await process_frame - _assert(player.heading.text == "CCS Case | Emergency Department", "CCS header should show the readable location without a case id") - _assert(player._ordered_vital_keys(ccs_service.attempt.vitals) == ["temperature", "bloodPressure", "pulse", "respirations", "oxygenSaturation"], "vital chips should use the clinical priority order") - _assert(player.vitals_flow.get_child_count() == 5, "every supplied vital should render as a chip") - _assert(player.search_button.icon != null, "Order Clerk search should use a bundled icon rather than a font glyph") - _assert(player.summary.get_meta("ccs_region", "") == "summary", "summary highlighting should use its own region") - _assert(player.history.get_meta("ccs_region", "") == "history", "history highlighting should use its own region") - _assert(player.appearance.get_meta("ccs_region", "") == "appearance", "appearance highlighting should use its own region") - _assert(player.chart.get_meta("ccs_region", "") == "chart", "chart highlighting should use its own region") - _assert(not player.workspace.vertical, "wide CCS screens should use chart and Order Clerk columns") - _assert(player.orders.get_child_count() == 1, "active orders should render in the Order Clerk") - var active_order_row := player.orders.get_child(0) as HBoxContainer - 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") - + _assert(player.heading.text == "CCS Case | Emergency Department", "header should show the readable location") + _assert(player.action_buttons.size() == 4, "CCS workspace should expose four persistent clinical tasks") + _assert(player.tabs.get_tab_count() == 7, "CCS chart should expose seven predictable tabs") + _assert(player.tabs.get_tab_title(0) == "Order Sheet", "the first chart tab should be the order sheet") + _assert(player.allotted_label.text.contains("18 minutes + 2 minutes"), "20-minute cases should reserve two final-order minutes") + _assert(player.active_orders.get_child_count() == 1, "active orders should render as structured rows") + _assert(player._ordered_vital_keys(ccs_service.attempt.vitals) == ["temperature", "bloodPressure", "pulse", "respirations", "oxygenSaturation"], "vitals should use clinical priority order") + _assert(player._chart_entries("lab").size() == 1, "categorized results should render in their assigned tab") + _assert(player._simulated_time_text(60).contains("10:00 AM"), "simulated time should derive from the case start") + _assert(not player.opening_active, "completed opening flows should not replay on resume") + _assert(not player.final_orders_notice.visible, "active cases should not show final-order instructions") 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", "") ProjectSettings.set_setting("step3/profile_api_url", "http://127.0.0.1:1") player._zoom(ccs_service.TEXT_SCALE_STEP) 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.search.get_theme_font_size("font_size") == clerk_font_size, "text zoom should not resize Order Clerk controls") + _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, "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._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.finalOrdersDeadlineUtc = Time.get_datetime_string_from_unix_time(Time.get_unix_time_from_system() + 90.0, true) player._render() - _assert(player.final_orders_notice.visible and player.clock.visible, "final-orders mode should show instructions and its deadline") - _assert(not player.time_row.visible, "time advancement should be unavailable during final orders") + _assert(player.final_orders_notice.visible, "final-orders mode should show instructions") + _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() await process_frame ccs_service.attempt = prior_attempt @@ -94,14 +73,24 @@ func _run() -> void: func _sample_attempt() -> Dictionary: + var now := Time.get_unix_time_from_system() return { "id": "attempt-1", "state": "active", + "revision": 1, + "openingCompleted": true, "location": "emergency_department", "patientSummary": "24-year-old woman with type 1 diabetes mellitus", "reasonForEncounter": "Two days of vomiting and progressive lethargy.", "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.", + "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": { "oxygenSaturation": "98% on room air", "pulse": "118/min", @@ -109,14 +98,22 @@ func _sample_attempt() -> Dictionary: "respirations": "28/min", "bloodPressure": "88/56 mm Hg" }, - "chart": [{"simulatedMinute": 0, "category": "progress", "text": "Initial evaluation is in progress."}], - "activeOrderCodes": ["normal_saline_bolus"], - "availableExams": [ - {"code": "general", "name": "General and hydration examination"}, - {"code": "neurologic", "name": "Neurologic examination"} + "vitalHistory": [{"simulatedMinute": 0, "values": {"pulse": "118/min"}}], + "chart": [ + {"simulatedMinute": 0, "category": "progress", "code": "reason", "title": "Reason for encounter", "text": "Initial evaluation."}, + {"simulatedMinute": 12, "category": "lab", "code": "bmp", "title": "Basic metabolic panel", "text": "Glucose 580 mg/dL."} ], - "allowedLocations": ["emergency_department", "icu"], - "deadlineUtc": Time.get_datetime_string_from_unix_time(Time.get_unix_time_from_system() + 600.0, true) + "activeOrderCodes": ["normal_saline_bolus"], + "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"] } diff --git a/tests/smoke/uat-smoke.spec.mjs b/tests/smoke/uat-smoke.spec.mjs index a2a7265..a96ba2e 100644 --- a/tests/smoke/uat-smoke.spec.mjs +++ b/tests/smoke/uat-smoke.spec.mjs @@ -161,15 +161,40 @@ test("public API and disposable learner journey", async () => { expect(Array.isArray(cases)).toBeTruthy(); expect(cases.length).toBeGreaterThan(0); 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; expect(activeCcsAttempt).toBeTruthy(); expect(attempt.caseExternalId).toBeTruthy(); expect(attempt.state).toBe("active"); - await call(`/api/ccs/attempts/${activeCcsAttempt}/abandon`, { - method: "POST", token, expected: [204] + const { data: opened } = await call(`/api/ccs/attempts/${activeCcsAttempt}/commands`, { + 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 = ""; // Flashcards: create a deck and card, study and grade it, verify it drops out of the due