diff --git a/api/QuestionsApi.Tests/CcsCaseTests.cs b/api/QuestionsApi.Tests/CcsCaseTests.cs index 7f020f3..ffe2d3e 100644 --- a/api/QuestionsApi.Tests/CcsCaseTests.cs +++ b/api/QuestionsApi.Tests/CcsCaseTests.cs @@ -128,6 +128,11 @@ public sealed class CcsCaseTests Assert.Contains(request.Orders, value => value.Code == "bismuth_quadruple"); Assert.Contains(request.Cases.Single().OrderResponses, value => value.OrderCode == "upper_endoscopy" && value.Result.Contains("duodenal", StringComparison.OrdinalIgnoreCase)); + var endoscopy = request.Cases.Single().OrderResponses.Single(value => value.OrderCode == "upper_endoscopy"); + Assert.Equal("endoscopy_suite", endoscopy.PerformanceLocation); + Assert.True(endoscopy.RequiresPatientVisit); + Assert.All(request.Cases.Single().OrderResponses.Where(value => value.RequiresPatientVisit), + value => Assert.False(string.IsNullOrWhiteSpace(value.PerformanceLocation))); Assert.Contains(request.Cases.Single().ScoringCriteria, value => value.TargetCode == "ibuprofen" && value.Points < 0); } diff --git a/api/QuestionsApi/Models/CcsModels.cs b/api/QuestionsApi/Models/CcsModels.cs index ed82c13..144e423 100644 --- a/api/QuestionsApi/Models/CcsModels.cs +++ b/api/QuestionsApi/Models/CcsModels.cs @@ -164,6 +164,8 @@ public class CcsCaseOrderDefinition { public string OrderCode { get; set; } = ""; public int? TurnaroundMinutes { get; set; } + public string PerformanceLocation { get; set; } = ""; + public bool RequiresPatientVisit { get; set; } public string Result { get; set; } = ""; public string ResultSection { get; set; } = ""; public Dictionary VitalPatches { get; set; } = []; @@ -243,6 +245,8 @@ public class CcsPendingResult public string Category { get; set; } = ""; public string Title { get; set; } = ""; public int DueMinute { get; set; } + public string PerformanceLocation { get; set; } = ""; + public bool RequiresPatientVisit { get; set; } public string Result { get; set; } = ""; public Dictionary VitalPatches { get; set; } = []; } @@ -271,10 +275,22 @@ public class CcsActiveOrder public string Frequency { get; set; } = ""; public int OrderedMinute { get; set; } public int? ReportMinute { get; set; } + public int? DueMinute { get; set; } + public string PerformanceLocation { get; set; } = ""; + public bool RequiresPatientVisit { get; set; } + public string Status { get; set; } = "active"; public bool IsPending { get; set; } public bool HasResult { get; set; } } +public class CcsOrderOption +{ + public string Code { get; set; } = ""; + public int TurnaroundMinutes { get; set; } + public string PerformanceLocation { get; set; } = ""; + public bool RequiresPatientVisit { get; set; } +} + public class CcsCaseScore { public double Percent { get; set; } @@ -385,6 +401,7 @@ public class CcsAttemptResponse public List Chart { get; set; } = []; public List ActiveOrderCodes { get; set; } = []; public List ActiveOrders { get; set; } = []; + public List OrderOptions { get; set; } = []; public List VitalHistory { get; set; } = []; public List PendingResults { get; set; } = []; public List AvailableExams { get; set; } = []; diff --git a/api/QuestionsApi/Services/CcsCaseValidator.cs b/api/QuestionsApi/Services/CcsCaseValidator.cs index 849acfa..6c5441d 100644 --- a/api/QuestionsApi/Services/CcsCaseValidator.cs +++ b/api/QuestionsApi/Services/CcsCaseValidator.cs @@ -60,6 +60,9 @@ public static class CcsCaseValidator foreach (var response in item.OrderResponses) { if (!orderCodes.Contains(response.OrderCode)) errors.Add(prefix + $"unknown order code {response.OrderCode}"); + if (response.TurnaroundMinutes < 0) errors.Add(prefix + $"order response {response.OrderCode} turnaroundMinutes cannot be negative"); + if (response.RequiresPatientVisit && string.IsNullOrWhiteSpace(response.PerformanceLocation)) + errors.Add(prefix + $"order response {response.OrderCode} requires a performanceLocation"); if (!string.IsNullOrWhiteSpace(response.ResultSection) && !CcsChartCategories.ResultSections.Contains(response.ResultSection)) errors.Add(prefix + $"order response {response.OrderCode} resultSection is invalid"); } diff --git a/api/QuestionsApi/Services/CcsStore.cs b/api/QuestionsApi/Services/CcsStore.cs index 69f87cb..16c182d 100644 --- a/api/QuestionsApi/Services/CcsStore.cs +++ b/api/QuestionsApi/Services/CcsStore.cs @@ -411,6 +411,23 @@ public sealed class CcsStore { var definition = await DefinitionAsync(attempt); var completed = attempt.State == CcsAttemptStates.Completed; + var optionCodes = definition.OrderResponses.Select(response => response.OrderCode).ToList(); + List optionCatalog = definition.ExperienceMode == CcsExperienceModes.GraphicalInterview + ? await _orders.Find(value => optionCodes.Contains(value.Code)).ToListAsync() + : []; + var orderOptions = definition.ExperienceMode == CcsExperienceModes.GraphicalInterview + ? definition.OrderResponses.Select(response => + { + var catalog = optionCatalog.FirstOrDefault(value => value.Code.Equals(response.OrderCode, StringComparison.OrdinalIgnoreCase)); + return new CcsOrderOption + { + Code = response.OrderCode, + TurnaroundMinutes = response.TurnaroundMinutes ?? catalog?.DefaultTurnaroundMinutes ?? 0, + PerformanceLocation = PerformanceLocation(response, catalog, attempt.Location), + RequiresPatientVisit = response.RequiresPatientVisit + }; + }).ToList() + : []; List activeCatalog = attempt.ActiveOrderCodes.Count == 0 ? [] : await _orders.Find(value => attempt.ActiveOrderCodes.Contains(value.Code)).ToListAsync(); @@ -423,11 +440,19 @@ public sealed class CcsStore var reported = attempt.Chart.LastOrDefault(value => value.Code.Equals(order.Code, StringComparison.OrdinalIgnoreCase) && value.Category is not CcsChartCategories.Orders); + var response = definition.OrderResponses.FirstOrDefault(value => + value.OrderCode.Equals(order.Code, StringComparison.OrdinalIgnoreCase)); + var performanceLocation = pending?.PerformanceLocation ?? PerformanceLocation(response, order, attempt.Location); + var requiresPatientVisit = pending?.RequiresPatientVisit ?? response?.RequiresPatientVisit == true; 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, + DueMinute = pending?.DueMinute, PerformanceLocation = performanceLocation, + RequiresPatientVisit = requiresPatientVisit, + Status = pending is not null ? (requiresPatientVisit ? "scheduled" : "pending") + : reported is not null ? "completed" : "active", IsPending = pending is not null, HasResult = reported is not null }; }).OrderBy(value => value.OrderedMinute).ThenBy(value => value.Name).ToList(); @@ -469,10 +494,11 @@ public sealed class CcsStore ? [] : definition.InitialChart.HistorySections, MedicalHistory = MedicalHistory(definition), Vitals = attempt.CurrentVitals, Chart = attempt.Chart, ActiveOrderCodes = attempt.ActiveOrderCodes, - ActiveOrders = activeOrders, VitalHistory = vitalHistory, + ActiveOrders = activeOrders, OrderOptions = orderOptions, VitalHistory = vitalHistory, PendingResults = attempt.PendingResults.Select(value => new CcsPendingResult { - OrderCode = value.OrderCode, DueMinute = value.DueMinute, Category = value.Category, Title = value.Title + OrderCode = value.OrderCode, DueMinute = value.DueMinute, Category = value.Category, Title = value.Title, + PerformanceLocation = value.PerformanceLocation, RequiresPatientVisit = value.RequiresPatientVisit }).ToList(), AvailableExams = attempt.State == CcsAttemptStates.Active ? definition.Exams : [], AllowedLocations = attempt.State == CcsAttemptStates.Active ? definition.AllowedLocations : [], @@ -525,23 +551,31 @@ public sealed class CcsStore { code = code.Trim().ToLowerInvariant(); var catalog = await _orders.Find(value => value.Code == code).FirstOrDefaultAsync() ?? throw new ArgumentException("Unknown order code."); - if (catalog.AvailableLocations.Count > 0 && !catalog.AvailableLocations.Contains(attempt.Location, StringComparer.OrdinalIgnoreCase)) + var response = definition.OrderResponses.FirstOrDefault(value => value.OrderCode.Equals(code, StringComparison.OrdinalIgnoreCase)); + if (definition.ExperienceMode == CcsExperienceModes.GraphicalInterview && response is null) + throw new InvalidOperationException("That order is not configured for this case."); + if (response is null && catalog.AvailableLocations.Count > 0 && !catalog.AvailableLocations.Contains(attempt.Location, StringComparer.OrdinalIgnoreCase)) throw new InvalidOperationException("That order is unavailable at the current location."); attempt.Actions.Add(Action("order", code, attempt.SimulatedMinute, now)); 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 performanceLocation = PerformanceLocation(response, catalog, attempt.Location); + var requiresPatientVisit = response?.RequiresPatientVisit == true; 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, Category = resultCategory, Title = catalog.CanonicalName, - DueMinute = attempt.SimulatedMinute + delay, Result = response.Result, VitalPatches = response.VitalPatches + DueMinute = attempt.SimulatedMinute + delay, Result = response.Result, VitalPatches = response.VitalPatches, + PerformanceLocation = performanceLocation, RequiresPatientVisit = requiresPatientVisit }); attempt.Chart.Add(new CcsChartEntry { Category = CcsChartCategories.Orders, Code = code, Title = catalog.CanonicalName, - SimulatedMinute = attempt.SimulatedMinute, Text = $"Ordered: {catalog.CanonicalName}" + SimulatedMinute = attempt.SimulatedMinute, + Text = requiresPatientVisit + ? $"Scheduled: {catalog.CanonicalName} at {Humanize(performanceLocation)}; expected in {delay} minutes." + : $"Ordered: {catalog.CanonicalName}" }); if (delay == 0 && processImmediately) ProcessDue(attempt); } @@ -606,6 +640,13 @@ public sealed class CcsStore var vitalsChanged = false; foreach (var result in due) { + if (result.RequiresPatientVisit) + attempt.Chart.Add(new CcsChartEntry + { + Category = CcsChartCategories.Progress, Code = $"{result.OrderCode}_completed", Title = "Patient returned", + SimulatedMinute = attempt.SimulatedMinute, + Text = $"The patient completed {result.Title} at {Humanize(result.PerformanceLocation)} and returned." + }); if (!string.IsNullOrWhiteSpace(result.Result)) attempt.Chart.Add(new CcsChartEntry { @@ -619,6 +660,13 @@ public sealed class CcsStore if (vitalsChanged) SnapshotVitals(attempt); } + private static string PerformanceLocation(CcsCaseOrderDefinition? response, CcsOrderDefinition? catalog, string currentLocation) + { + if (!string.IsNullOrWhiteSpace(response?.PerformanceLocation)) return response.PerformanceLocation; + if (catalog?.AvailableLocations.Contains(currentLocation, StringComparer.OrdinalIgnoreCase) == true) return currentLocation; + return catalog?.AvailableLocations.FirstOrDefault() ?? currentLocation; + } + private static bool ProcessEvents(CcsAttempt attempt, CcsCaseRevision definition) { var activeOrders = attempt.Actions.Where(value => value.Type == "order").Select(value => value.Code).ToHashSet(StringComparer.OrdinalIgnoreCase); diff --git a/content/ccs/duodenal-ulcer-office-001.json b/content/ccs/duodenal-ulcer-office-001.json index 2287ce0..fd513d9 100644 --- a/content/ccs/duodenal-ulcer-office-001.json +++ b/content/ccs/duodenal-ulcer-office-001.json @@ -138,16 +138,16 @@ { "code": "neuro_psych", "name": "Neuro / Psych", "durationMinutes": 1, "finding": "He is alert and fully oriented with normal speech, strength, sensation, coordination, gait, mood, and affect." } ], "orderResponses": [ - { "orderCode": "cbc", "turnaroundMinutes": 20, "result": "Hemoglobin is 10.4 g/dL, hematocrit 32%, MCV 74 fL, leukocyte count 7,100/mm3, and platelet count 312,000/mm3.", "resultSection": "lab" }, - { "orderCode": "cmp", "turnaroundMinutes": 25, "result": "BUN is 27 mg/dL and creatinine 1.0 mg/dL. Electrolytes, glucose, aminotransferases, bilirubin, and albumin are within reference ranges.", "resultSection": "lab" }, - { "orderCode": "iron_studies", "turnaroundMinutes": 30, "result": "Ferritin is 10 ng/mL, serum iron 31 mcg/dL, and transferrin saturation 9%, supporting iron deficiency.", "resultSection": "lab" }, - { "orderCode": "h_pylori_breath", "turnaroundMinutes": 45, "result": "Urea breath test is positive for Helicobacter pylori.", "resultSection": "other_test" }, - { "orderCode": "upper_endoscopy", "turnaroundMinutes": 60, "result": "Upper endoscopy shows an 8 mm clean-based ulcer in the duodenal bulb without active bleeding. Gastric biopsy is positive for H. pylori and shows no dysplasia or malignancy.", "resultSection": "other_test" }, + { "orderCode": "cbc", "turnaroundMinutes": 20, "performanceLocation": "outpatient_laboratory", "requiresPatientVisit": true, "result": "Hemoglobin is 10.4 g/dL, hematocrit 32%, MCV 74 fL, leukocyte count 7,100/mm3, and platelet count 312,000/mm3.", "resultSection": "lab" }, + { "orderCode": "cmp", "turnaroundMinutes": 25, "performanceLocation": "outpatient_laboratory", "requiresPatientVisit": true, "result": "BUN is 27 mg/dL and creatinine 1.0 mg/dL. Electrolytes, glucose, aminotransferases, bilirubin, and albumin are within reference ranges.", "resultSection": "lab" }, + { "orderCode": "iron_studies", "turnaroundMinutes": 30, "performanceLocation": "outpatient_laboratory", "requiresPatientVisit": true, "result": "Ferritin is 10 ng/mL, serum iron 31 mcg/dL, and transferrin saturation 9%, supporting iron deficiency.", "resultSection": "lab" }, + { "orderCode": "h_pylori_breath", "turnaroundMinutes": 45, "performanceLocation": "office_diagnostic_area", "requiresPatientVisit": true, "result": "Urea breath test is positive for Helicobacter pylori.", "resultSection": "other_test" }, + { "orderCode": "upper_endoscopy", "turnaroundMinutes": 60, "performanceLocation": "endoscopy_suite", "requiresPatientVisit": true, "result": "Upper endoscopy shows an 8 mm clean-based ulcer in the duodenal bulb without active bleeding. Gastric biopsy is positive for H. pylori and shows no dysplasia or malignancy.", "resultSection": "other_test" }, { "orderCode": "omeprazole", "turnaroundMinutes": 0, "result": "Proton-pump inhibitor therapy is started.", "resultSection": "treatment" }, { "orderCode": "bismuth_quadruple", "turnaroundMinutes": 0, "result": "A 14-day optimized bismuth quadruple regimen is prescribed.", "resultSection": "treatment" }, { "orderCode": "h_pylori_test_of_cure", "turnaroundMinutes": 0, "result": "Test-of-cure follow-up is scheduled for at least four weeks after therapy, with PPI held for two weeks before testing.", "resultSection": "treatment" }, { "orderCode": "gastro_follow_up", "turnaroundMinutes": 0, "result": "Gastroenterology follow-up is arranged to review healing, anemia, and eradication testing.", "resultSection": "treatment" }, - { "orderCode": "abdominal_ct", "turnaroundMinutes": 60, "result": "CT of the abdomen and pelvis shows no acute intra-abdominal abnormality.", "resultSection": "imaging" }, + { "orderCode": "abdominal_ct", "turnaroundMinutes": 60, "performanceLocation": "imaging_center", "requiresPatientVisit": true, "result": "CT of the abdomen and pelvis shows no acute intra-abdominal abnormality.", "resultSection": "imaging" }, { "orderCode": "clarithromycin_triple", "turnaroundMinutes": 0, "result": "Empiric clarithromycin-based therapy is prescribed without susceptibility confirmation.", "resultSection": "treatment" }, { "orderCode": "ibuprofen", "turnaroundMinutes": 0, "result": "Ibuprofen is prescribed despite suspected peptic ulcer disease and possible gastrointestinal bleeding.", "resultSection": "treatment" } ], diff --git a/scripts/ui/ccs_cases_2/doctor_office.gd b/scripts/ui/ccs_cases_2/doctor_office.gd index 57f3cce..23b0dfb 100644 --- a/scripts/ui/ccs_cases_2/doctor_office.gd +++ b/scripts/ui/ccs_cases_2/doctor_office.gd @@ -88,10 +88,16 @@ var orders_status: Label var order_search_timer: Timer var staged_order_codes: Array[String] = [] var order_names := {} +var order_options := {} var final_orders_mode := false var active_order_codes: Array = [] var current_order_location := "" +var current_order_minute := 0 var known_order_result_count := -1 +var known_visit_completions: Array[String] = [] +var visit_completion_tracking_ready := false +var patient_update_panel: PanelContainer +var patient_update_text: Label var experience_mode := "management" var pending_choices: Array = [] var available_choices: Array = [] @@ -132,6 +138,7 @@ func _ready() -> void: _style_desktop_shortcut(orders_launcher) _build_medical_history_tabs() _build_orders_window() + _build_patient_update() _resize_computer_windows() _update_clock() _schedule_patient_blink() @@ -315,6 +322,8 @@ func complete_response_transition() -> void: func _input(event: InputEvent) -> void: + if patient_update_panel != null and patient_update_panel.visible: + return if computer_overlay.visible or (physical_exam_panel != null and physical_exam_panel.visible): return if _event_targets_computer_launcher(event): @@ -370,6 +379,9 @@ func is_computer_open() -> bool: func close_transient_overlay() -> bool: + if patient_update_panel != null and patient_update_panel.visible: + patient_update_panel.visible = false + return true if physical_exam_panel != null and physical_exam_panel.visible: if physical_exam_result_button.visible: _close_exam_result() @@ -591,22 +603,29 @@ func _queue_live_order_search(query: String) -> void: func render_order_search(rows: Array) -> void: _clear_dynamic_children(orders_search_results) - var available_count := 0 + var result_count := 0 for raw_order in rows: var order: Dictionary = raw_order - var locations: Array = order.get("availableLocations", []) - if not locations.is_empty() and current_order_location not in locations: - continue - available_count += 1 + result_count += 1 var code := str(order.get("code", "")) order_names[code] = str(order.get("canonicalName", code.capitalize())) + var option: Dictionary = order_options.get(code, {}) + var configured := not option.is_empty() var button := Button.new() - button.text = "%s · %s" % [order_names[code], str(order.get("category", "")).capitalize()] + var detail := str(order.get("category", "")).capitalize() + if configured: + var location := _humanize_order_location(str(option.get("performanceLocation", current_order_location))) + var delay := int(option.get("turnaroundMinutes", 0)) + detail += " - %s%s" % [location, " - %d min" % delay if delay > 0 else ""] + else: + detail += " - Unavailable in this case" + button.text = "%s\n%s" % [order_names[code], detail] button.alignment = HORIZONTAL_ALIGNMENT_LEFT - button.disabled = code in staged_order_codes or code in active_order_codes + button.disabled = not configured or code in staged_order_codes or code in active_order_codes + button.tooltip_text = "This order is not configured for this case." if not configured else "" button.pressed.connect(_stage_order.bind(code)) orders_search_results.add_child(button) - orders_status.text = "" if available_count > 0 else "No matching orders are available at this location." + orders_status.text = "" if result_count > 0 else "No matching orders found." func _stage_order(code: String) -> void: @@ -622,7 +641,9 @@ func _render_staged_orders() -> void: var row := HBoxContainer.new() orders_staged.add_child(row) var label := Label.new() - label.text = str(order_names.get(code, code.capitalize())) + var option: Dictionary = order_options.get(code, {}) + var location := _humanize_order_location(str(option.get("performanceLocation", current_order_location))) + label.text = "%s - %s" % [str(order_names.get(code, code.capitalize())), location] label.size_flags_horizontal = Control.SIZE_EXPAND_FILL row.add_child(label) var remove := Button.new() @@ -647,12 +668,18 @@ func configure_orders(attempt: Dictionary) -> void: if orders_window == null: return current_order_location = str(attempt.get("location", "")) + current_order_minute = int(attempt.get("simulatedMinute", 0)) + order_options.clear() + for raw_option in attempt.get("orderOptions", []): + var option: Dictionary = raw_option + order_options[str(option.get("code", ""))] = option active_order_codes = attempt.get("activeOrderCodes", []).duplicate() for code in attempt.get("activeOrderCodes", []): staged_order_codes.erase(str(code)) _render_staged_orders() _render_active_orders(attempt.get("activeOrders", [])) _render_order_results(attempt.get("chart", [])) + _track_patient_visit_completions(attempt.get("chart", [])) var result_count := _order_result_count(attempt.get("chart", [])) if known_order_result_count >= 0 and result_count > known_order_result_count and not orders_window.visible: orders_launcher.get_node("Label").text = "Orders •" @@ -676,13 +703,13 @@ func show_order_action_complete(action: String) -> void: match action: "orders": orders_tabs.current_tab = 1 - orders_status.text = "Orders confirmed. Use Obtain Results for pending tests." + orders_status.text = "Orders confirmed and automatically scheduled. Use Obtain Results to advance time." "cancel_order": orders_tabs.current_tab = 1 orders_status.text = "Order cancelled." "advance": orders_tabs.current_tab = 2 - orders_status.text = "The chart has been updated with all results now available." + orders_status.text = "Simulated time advanced. Newly available results are shown here." func _render_active_orders(rows: Array) -> void: @@ -697,7 +724,12 @@ func _render_active_orders(rows: Array) -> void: var row := HBoxContainer.new() orders_active.add_child(row) var label := Label.new() - label.text = "%s — %s, %s" % [str(order.get("name", "Order")), str(order.get("route", "")), str(order.get("frequency", ""))] + var status := str(order.get("status", "active")).capitalize() + var location := _humanize_order_location(str(order.get("performanceLocation", current_order_location))) + var due_text := "" + if order.get("dueMinute", null) != null: + due_text = " - due in %d min" % maxi(0, int(order.get("dueMinute", 0)) - current_order_minute) + label.text = "%s - %s at %s%s" % [str(order.get("name", "Order")), status, location, due_text] label.size_flags_horizontal = Control.SIZE_EXPAND_FILL row.add_child(label) var cancel := Button.new() @@ -723,6 +755,66 @@ func _order_result_count(chart: Array) -> int: return count +func _humanize_order_location(value: String) -> String: + if value.is_empty(): + return "Current location" + return value.replace("_", " ").capitalize() + + +func _build_patient_update() -> void: + patient_update_panel = PanelContainer.new() + patient_update_panel.name = "PatientProcedureUpdate" + patient_update_panel.visible = false + patient_update_panel.z_index = 100 + patient_update_panel.position = Vector2(400, 300) + patient_update_panel.custom_minimum_size = Vector2(800, 0) + add_child(patient_update_panel) + var margin := MarginContainer.new() + margin.add_theme_constant_override("margin_left", 24) + margin.add_theme_constant_override("margin_right", 24) + margin.add_theme_constant_override("margin_top", 18) + margin.add_theme_constant_override("margin_bottom", 18) + patient_update_panel.add_child(margin) + var content := VBoxContainer.new() + content.add_theme_constant_override("separation", 12) + margin.add_child(content) + var heading := Label.new() + heading.text = "Patient Update" + heading.add_theme_font_size_override("font_size", 24) + content.add_child(heading) + patient_update_text = Label.new() + patient_update_text.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + patient_update_text.add_theme_font_size_override("font_size", 18) + content.add_child(patient_update_text) + var dismiss := Button.new() + dismiss.text = "Continue" + dismiss.custom_minimum_size = Vector2(140, 44) + dismiss.size_flags_horizontal = Control.SIZE_SHRINK_END + dismiss.pressed.connect(func() -> void: patient_update_panel.visible = false) + content.add_child(dismiss) + + +func _track_patient_visit_completions(chart: Array) -> void: + var current: Array[String] = [] + var new_messages: Array[String] = [] + for raw_entry in chart: + var entry: Dictionary = raw_entry + var code := str(entry.get("code", "")) + if str(entry.get("category", "")) != "progress" or not code.ends_with("_completed"): + continue + var key := "%s:%d" % [code, int(entry.get("simulatedMinute", 0))] + current.append(key) + if visit_completion_tracking_ready and key not in known_visit_completions: + new_messages.append(str(entry.get("text", "The patient completed a scheduled procedure and returned."))) + known_visit_completions = current + if not visit_completion_tracking_ready: + visit_completion_tracking_ready = true + return + if not new_messages.is_empty(): + patient_update_text.text = "\n".join(new_messages) + "\nNew results are available in the computer." + patient_update_panel.visible = true + + func set_orders_pending(pending: bool, message: String = "") -> void: orders_confirm_button.disabled = pending or staged_order_codes.is_empty() orders_advance_button.disabled = pending diff --git a/tests/ccs_cases_2_ui_test.gd b/tests/ccs_cases_2_ui_test.gd index e681b88..7a5e45d 100644 --- a/tests/ccs_cases_2_ui_test.gd +++ b/tests/ccs_cases_2_ui_test.gd @@ -107,16 +107,28 @@ func _run() -> void: {"code": "cbc", "canonicalName": "Complete blood count", "category": "laboratory", "availableLocations": []}, {"code": "venous_blood_gas", "canonicalName": "Venous blood gas", "category": "laboratory", "availableLocations": ["emergency_department", "icu"]} ]) - _assert(screen.active_scene.orders_search_results.get_child_count() == 1, "order search should hide catalog entries unavailable at the current location") + _assert(screen.active_scene.orders_search_results.get_child_count() == 2, "order search should keep unavailable catalog entries visible") + _assert(not (screen.active_scene.orders_search_results.get_child(0) as Button).disabled, "case-authored orders should remain selectable across performance locations") + _assert((screen.active_scene.orders_search_results.get_child(1) as Button).disabled, "orders not authored for this case should explain their unavailability") (screen.active_scene.orders_search_results.get_child(0) as Button).pressed.emit() _assert(screen.active_scene.staged_order_codes == ["cbc"] and not screen.active_scene.orders_confirm_button.disabled, "catalog results should stage for confirmation") screen.active_scene.configure_orders({ "state": "active", "activeOrderCodes": ["cbc"], - "activeOrders": [{"code": "cbc", "name": "Complete blood count", "route": "Blood", "frequency": "Once"}], + "orderOptions": [{"code": "cbc", "performanceLocation": "outpatient_laboratory", "turnaroundMinutes": 20, "requiresPatientVisit": true}], + "activeOrders": [{"code": "cbc", "name": "Complete blood count", "status": "completed", "performanceLocation": "outpatient_laboratory"}], "chart": [{"category": "lab", "code": "cbc", "title": "Complete blood count", "text": "Hemoglobin 10.4 g/dL."}] }) _assert(screen.active_scene.staged_order_codes.is_empty(), "confirmed active orders should leave the staged list") _assert(screen.active_scene.orders_results.text.contains("Hemoglobin 10.4"), "returned results should render in the Orders results tab") + screen.active_scene.configure_orders({ + "state": "active", "activeOrderCodes": ["cbc"], "orderOptions": [], "activeOrders": [], + "chart": [ + {"category": "progress", "code": "cbc_completed", "simulatedMinute": 20, "text": "The patient completed Complete blood count at Outpatient Laboratory and returned."}, + {"category": "lab", "code": "cbc", "title": "Complete blood count", "text": "Hemoglobin 10.4 g/dL."} + ] + }) + _assert(screen.active_scene.patient_update_panel.visible and screen.active_scene.patient_update_text.text.contains("returned"), "completed off-site work should produce an in-scene patient update") + screen.active_scene.patient_update_panel.visible = false screen.active_scene.show_order_action_complete("orders") _assert(screen.active_scene.orders_tabs.current_tab == 1 and screen.active_scene.orders_status.text.begins_with("Orders confirmed"), "successful submission should explain completion and open Active Orders") screen.active_scene.show_order_action_complete("advance") @@ -351,6 +363,7 @@ func _sample_attempt(completed: bool) -> Dictionary: "openingCompleted": completed, "experienceMode": "graphical_interview", "availableExams": _sample_exams(), "chart": [], + "orderOptions": [{"code": "cbc", "performanceLocation": "outpatient_laboratory", "turnaroundMinutes": 20, "requiresPatientVisit": true}], "interview": {"currentNodeId": node.id, "currentNode": node, "visitedChoiceIds": [], "completed": completed} }