Expand CCS patient history

This commit is contained in:
hz
2026-08-09 23:59:28 -05:00
parent d349998d12
commit 33b55753cb
8 changed files with 306 additions and 75 deletions
+11 -2
View File
@@ -101,8 +101,17 @@ public sealed class CcsCaseTests
Assert.Empty(CcsCaseValidator.ValidateImport(request!, new HashSet<string>())); Assert.Empty(CcsCaseValidator.ValidateImport(request!, new HashSet<string>()));
Assert.Equal(CcsExperienceModes.GraphicalInterview, request.Cases.Single().ExperienceMode); Assert.Equal(CcsExperienceModes.GraphicalInterview, request.Cases.Single().ExperienceMode);
var topics = request.Cases.Single().InitialInterview.Topics; var topics = request.Cases.Single().InitialInterview.Topics;
Assert.Equal(["chief_concern", "dizziness_character", "fatigue_course"], topics.Select(value => value.Id)); Assert.Equal([
Assert.Equal(2, topics.Count(value => value.Required)); "chief_concern", "dizziness_character", "fatigue_course", "abdominal_character",
"relieving_factors", "associated_gi", "exposure_history", "past_medical_history"
], topics.Select(value => value.Id));
Assert.Equal(6, topics.Count(value => value.Required));
Assert.Equal(["fatigue_course"], topics.Single(value => value.Id == "abdominal_character").PrerequisiteTopicIds);
Assert.All(topics.Where(value => value.Id is "relieving_factors" or "associated_gi" or "exposure_history"),
value => Assert.Equal(["abdominal_character"], value.PrerequisiteTopicIds));
Assert.False(topics.Single(value => value.Id == "past_medical_history").Required);
Assert.Equal(2, request.Cases.Single().GraphicalPresentation.InterviewRevision);
Assert.Equal("Hyperlipidemia", CcsStore.MedicalHistory(request.Cases.Single()));
} }
[Fact] [Fact]
+2
View File
@@ -108,6 +108,7 @@ public class CcsGraphicalPresentation
{ {
public string SceneKey { get; set; } = ""; public string SceneKey { get; set; } = "";
public string PatientAssetKey { get; set; } = ""; public string PatientAssetKey { get; set; } = "";
public int InterviewRevision { get; set; }
} }
public static class CcsExperienceModes public static class CcsExperienceModes
@@ -379,6 +380,7 @@ public class CcsAttemptResponse
public string Appearance { get; set; } = ""; public string Appearance { get; set; } = "";
public string History { get; set; } = ""; public string History { get; set; } = "";
public List<CcsNarrativeSection> HistorySections { get; set; } = []; public List<CcsNarrativeSection> HistorySections { get; set; } = [];
public string MedicalHistory { get; set; } = "";
public Dictionary<string, string> Vitals { get; set; } = []; public Dictionary<string, string> Vitals { get; set; } = [];
public List<CcsChartEntry> Chart { get; set; } = []; public List<CcsChartEntry> Chart { get; set; } = [];
public List<string> ActiveOrderCodes { get; set; } = []; public List<string> ActiveOrderCodes { get; set; } = [];
+5
View File
@@ -467,6 +467,7 @@ public sealed class CcsStore
? "" : definition.InitialChart.History, ? "" : definition.InitialChart.History,
HistorySections = definition.ExperienceMode == CcsExperienceModes.GraphicalInterview && !completed HistorySections = definition.ExperienceMode == CcsExperienceModes.GraphicalInterview && !completed
? [] : definition.InitialChart.HistorySections, ? [] : definition.InitialChart.HistorySections,
MedicalHistory = MedicalHistory(definition),
Vitals = attempt.CurrentVitals, Chart = attempt.Chart, ActiveOrderCodes = attempt.ActiveOrderCodes, Vitals = attempt.CurrentVitals, Chart = attempt.Chart, ActiveOrderCodes = attempt.ActiveOrderCodes,
ActiveOrders = activeOrders, VitalHistory = vitalHistory, ActiveOrders = activeOrders, VitalHistory = vitalHistory,
PendingResults = attempt.PendingResults.Select(value => new CcsPendingResult PendingResults = attempt.PendingResults.Select(value => new CcsPendingResult
@@ -893,6 +894,10 @@ public sealed class CcsStore
}); });
return new CcsInitialInterview { StartNodeId = "opening", Nodes = nodes }; return new CcsInitialInterview { StartNodeId = "opening", Nodes = nodes };
} }
internal static string MedicalHistory(CcsCaseRevision definition) =>
definition.InitialChart.HistorySections.FirstOrDefault(value =>
value.Code.Equals("past_medical_history", StringComparison.OrdinalIgnoreCase))?.Text ?? "";
internal static int Rank(CcsOrderDefinition order, string query) internal static int Rank(CcsOrderDefinition order, string query)
{ {
var values = new[] { order.CanonicalName }.Concat(order.Aliases).Select(value => value.ToLowerInvariant()); var values = new[] { order.CanonicalName }.Concat(order.Aliases).Select(value => value.ToLowerInvariant());
+52 -9
View File
@@ -13,17 +13,20 @@
"experienceMode": "graphical_interview", "experienceMode": "graphical_interview",
"graphicalPresentation": { "graphicalPresentation": {
"sceneKey": "doctor_office", "sceneKey": "doctor_office",
"patientAssetKey": "patient_44_white_male" "patientAssetKey": "patient_44_white_male",
"interviewRevision": 2
}, },
"initialChart": { "initialChart": {
"patientSummary": "44-year-old man attending an office visit", "patientSummary": "44-year-old man attending an office visit",
"reasonForEncounter": "The reason for the visit has not yet been elicited.", "reasonForEncounter": "The reason for the visit has not yet been elicited.",
"appearance": "He is seated comfortably, attentive, and in no acute distress.", "appearance": "He is seated comfortably, attentive, and in no acute distress.",
"history": "Several weeks of intermittent lightheadedness and fatigue accompany sleep-disrupting, gnawing epigastric discomfort that is worse on an empty stomach and temporarily relieved by food or antacids. He has no symptoms of gastrointestinal bleeding or other alarm features.", "history": "Several weeks of intermittent lightheadedness and fatigue accompany three weeks of sleep-disrupting, burning epigastric discomfort rated 4/10. Food and antacids provide temporary relief. He reports belching, fullness, and occasional black stool without frank blood. He has hyperlipidemia and no recent sick contacts, travel, or antibiotic use.",
"historySections": [ "historySections": [
{ "code": "chief_concern", "title": "Chief concern", "text": "Intermittent lightheadedness and fatigue for several weeks." }, { "code": "chief_concern", "title": "Chief concern", "text": "Intermittent lightheadedness and fatigue for several weeks." },
{ "code": "abdominal_symptoms", "title": "Abdominal symptoms", "text": "Gnawing epigastric discomfort occurs when his stomach is empty and at night, and improves temporarily after food or antacids." }, { "code": "abdominal_symptoms", "title": "Abdominal symptoms", "text": "Burning, non-radiating epigastric discomfort has been present for three weeks, is rated 4/10, is unaffected by position or movement, and improves temporarily after food or antacids." },
{ "code": "alarm_features", "title": "Alarm features", "text": "No melena, hematemesis, persistent vomiting, dysphagia, unintentional weight loss, or syncope." } { "code": "associated_symptoms", "title": "Associated symptoms", "text": "Belching, fullness, sleep disruption, and occasional black stool without frank blood." },
{ "code": "past_medical_history", "title": "Past medical history", "text": "Hyperlipidemia" },
{ "code": "exposure_history", "title": "Exposure history", "text": "No recent sick contacts, travel, or antibiotic use." }
], ],
"vitals": { "vitals": {
"temperature": "36.8 C (98.2 F)", "temperature": "36.8 C (98.2 F)",
@@ -56,17 +59,53 @@
{ {
"id": "fatigue_course", "id": "fatigue_course",
"clinicianText": "How has the fatigue affected you, and how have you been sleeping and eating?", "clinicianText": "How has the fatigue affected you, and how have you been sleeping and eating?",
"patientText": "My energy is down by the afternoon, even on days when I sleep normally. I have not noticed fever, shortness of breath, chest pain, or a major change in my appetite.", "patientText": "My energy is down by the afternoon. My appetite is about the same, but lately a burning discomfort high in my abdomen sometimes wakes me at night.",
"required": true, "required": true,
"prerequisiteTopicIds": ["chief_concern"] "prerequisiteTopicIds": ["chief_concern"]
},
{
"id": "abdominal_character",
"clinicianText": "Tell me more about the abdominal discomfort—where is it, what does it feel like, and how long has it been happening?",
"patientText": "It started about three weeks ago. It is a burning pain in the upper middle part of my abdomen. It does not spread anywhere, I would rate it about a four out of ten, and changing position or moving does not make it better or worse.",
"required": true,
"prerequisiteTopicIds": ["fatigue_course"]
},
{
"id": "relieving_factors",
"clinicianText": "Does anything make the discomfort better or worse?",
"patientText": "Eating usually settles it for a while, and antacids help too. Position and movement do not seem to change it.",
"required": true,
"prerequisiteTopicIds": ["abdominal_character"]
},
{
"id": "associated_gi",
"clinicianText": "Have you noticed belching, fullness, vomiting, or any blood or change in the color of your stool?",
"patientText": "I have been belching more and sometimes feel unusually full. I have not vomited or seen red blood, but my stool has looked black on occasion.",
"required": true,
"prerequisiteTopicIds": ["abdominal_character"]
},
{
"id": "exposure_history",
"clinicianText": "Have you had any sick contacts, recent travel, or antibiotic use?",
"patientText": "No. I have not been around anyone sick, traveled recently, or taken antibiotics.",
"required": true,
"prerequisiteTopicIds": ["abdominal_character"]
},
{
"id": "past_medical_history",
"clinicianText": "Do you have any past medical conditions?",
"patientText": "I have hyperlipidemia.",
"required": false,
"prerequisiteTopicIds": ["chief_concern"]
} }
], ],
"debrief": { "debrief": {
"likelyDiagnosis": "Lightheadedness and fatigue requiring further evaluation", "likelyDiagnosis": "Peptic ulcer disease with possible intermittent upper gastrointestinal bleeding",
"summary": "The focused opening interview identifies intermittent orthostatic lightheadedness without vertigo or syncope and several weeks of nonspecific fatigue. Additional clinical assessment would be needed before assigning a diagnosis.", "summary": "The history identifies orthostatic lightheadedness and fatigue accompanying burning epigastric pain relieved by food and antacids, with occasional black stool concerning for intermittent upper gastrointestinal bleeding.",
"teachingPoints": [ "teachingPoints": [
"Clarify whether dizziness is vertigo, presyncope, or disequilibrium and screen for neurologic and cardiac warning symptoms.", "Clarify whether dizziness is vertigo, presyncope, or disequilibrium and screen for neurologic and cardiac warning symptoms.",
"Characterize the timing and functional impact of fatigue and ask about sleep, intake, and associated systemic symptoms." "Characterize epigastric pain by timing, quality, severity, radiation, triggers, and relieving factors.",
"Ask specifically about melena or hematemesis because occult or intermittent bleeding can present with fatigue and lightheadedness."
] ]
} }
}, },
@@ -77,7 +116,11 @@
"scoringCriteria": [ "scoringCriteria": [
{ "id": "elicit_chief_concern", "title": "Elicit the chief concern", "actionType": "dialogue_choice", "targetCode": "chief_concern", "points": 10, "rationale": "An open-ended opening allows the patient to identify lightheadedness and fatigue in his own words." }, { "id": "elicit_chief_concern", "title": "Elicit the chief concern", "actionType": "dialogue_choice", "targetCode": "chief_concern", "points": 10, "rationale": "An open-ended opening allows the patient to identify lightheadedness and fatigue in his own words." },
{ "id": "characterize_dizziness", "title": "Characterize the dizziness", "actionType": "dialogue_choice", "targetCode": "dizziness_character", "points": 15, "rationale": "Distinguishing presyncope from vertigo and screening for neurologic and cardiac warning symptoms focuses the differential." }, { "id": "characterize_dizziness", "title": "Characterize the dizziness", "actionType": "dialogue_choice", "targetCode": "dizziness_character", "points": 15, "rationale": "Distinguishing presyncope from vertigo and screening for neurologic and cardiac warning symptoms focuses the differential." },
{ "id": "explore_fatigue", "title": "Explore fatigue, sleep, and intake", "actionType": "dialogue_choice", "targetCode": "fatigue_course", "points": 10, "rationale": "The course and associated symptoms help characterize the nonspecific fatigue." } { "id": "explore_fatigue", "title": "Explore fatigue, sleep, and intake", "actionType": "dialogue_choice", "targetCode": "fatigue_course", "points": 10, "rationale": "The course and associated symptoms help characterize the nonspecific fatigue." },
{ "id": "characterize_abdominal_pain", "title": "Characterize the abdominal discomfort", "actionType": "dialogue_choice", "targetCode": "abdominal_character", "points": 15, "rationale": "Location, quality, duration, severity, and radiation help characterize the source of pain." },
{ "id": "identify_relief", "title": "Identify relieving and aggravating factors", "actionType": "dialogue_choice", "targetCode": "relieving_factors", "points": 10, "rationale": "Temporary relief with food or antacids supports an acid-related pattern." },
{ "id": "screen_gi_bleeding", "title": "Screen for gastrointestinal bleeding", "actionType": "dialogue_choice", "targetCode": "associated_gi", "points": 15, "rationale": "Occasional black stool may represent melena and requires recognition." },
{ "id": "review_exposures", "title": "Review relevant exposures", "actionType": "dialogue_choice", "targetCode": "exposure_history", "points": 5, "rationale": "Recent illness, travel, and antibiotic exposure help refine the gastrointestinal differential." }
], ],
"references": [ "references": [
{ {
+80 -1
View File
@@ -1,4 +1,4 @@
[gd_scene load_steps=12 format=3] [gd_scene load_steps=13 format=3]
[ext_resource type="Texture2D" path="res://assets/ccs_cases_2/images/doctor/office-backdrop.png" id="1_backdrop"] [ext_resource type="Texture2D" path="res://assets/ccs_cases_2/images/doctor/office-backdrop.png" id="1_backdrop"]
[ext_resource type="Script" path="res://scripts/ui/ccs_cases_2/doctor_office.gd" id="2_script"] [ext_resource type="Script" path="res://scripts/ui/ccs_cases_2/doctor_office.gd" id="2_script"]
@@ -10,6 +10,7 @@
[ext_resource type="Texture2D" path="res://assets/icons/lucide/monitor.svg" id="8_monitor"] [ext_resource type="Texture2D" path="res://assets/icons/lucide/monitor.svg" id="8_monitor"]
[ext_resource type="Texture2D" path="res://assets/icons/lucide/x.svg" id="9_close"] [ext_resource type="Texture2D" path="res://assets/icons/lucide/x.svg" id="9_close"]
[ext_resource type="Texture2D" path="res://assets/icons/lucide/heart-pulse.svg" id="10_vitals"] [ext_resource type="Texture2D" path="res://assets/icons/lucide/heart-pulse.svg" id="10_vitals"]
[ext_resource type="Texture2D" path="res://assets/icons/lucide/clipboard-list.svg" id="11_history"]
[sub_resource type="ShaderMaterial" id="ShaderMaterial_patient_fit"] [sub_resource type="ShaderMaterial" id="ShaderMaterial_patient_fit"]
shader = ExtResource("5_patient_fit") shader = ExtResource("5_patient_fit")
@@ -287,6 +288,20 @@ expand_icon = true
text = "Vitals" text = "Vitals"
vertical_icon_alignment = 0 vertical_icon_alignment = 0
[node name="MedicalHistoryLauncher" type="Button" parent="ComputerOverlay"]
layout_mode = 0
offset_left = 166.0
offset_top = 32.0
offset_right = 342.0
offset_bottom = 142.0
mouse_default_cursor_shape = 2
tooltip_text = "View medical history"
icon = ExtResource("11_history")
icon_max_width = 48
expand_icon = true
text = "Medical History"
vertical_icon_alignment = 0
[node name="VitalsWindow" type="PanelContainer" parent="ComputerOverlay"] [node name="VitalsWindow" type="PanelContainer" parent="ComputerOverlay"]
visible = false visible = false
z_index = 2 z_index = 2
@@ -353,6 +368,70 @@ text = "No nurse vital signs are available."
horizontal_alignment = 1 horizontal_alignment = 1
vertical_alignment = 1 vertical_alignment = 1
[node name="MedicalHistoryWindow" type="PanelContainer" parent="ComputerOverlay"]
visible = false
z_index = 2
layout_mode = 1
anchors_preset = 8
anchor_left = 0.5
anchor_top = 0.5
anchor_right = 0.5
anchor_bottom = 0.5
offset_left = -330.0
offset_top = -210.0
offset_right = 330.0
offset_bottom = 210.0
grow_horizontal = 2
grow_vertical = 2
[node name="Margin" type="MarginContainer" parent="ComputerOverlay/MedicalHistoryWindow"]
layout_mode = 2
theme_override_constants/margin_left = 32
theme_override_constants/margin_top = 24
theme_override_constants/margin_right = 32
theme_override_constants/margin_bottom = 28
[node name="Content" type="VBoxContainer" parent="ComputerOverlay/MedicalHistoryWindow/Margin"]
layout_mode = 2
theme_override_constants/separation = 20
[node name="Header" type="HBoxContainer" parent="ComputerOverlay/MedicalHistoryWindow/Margin/Content"]
layout_mode = 2
mouse_default_cursor_shape = 13
[node name="Title" type="Label" parent="ComputerOverlay/MedicalHistoryWindow/Margin/Content/Header"]
layout_mode = 2
size_flags_horizontal = 3
theme_override_font_sizes/font_size = 30
text = "Medical History"
vertical_alignment = 1
[node name="Close" type="Button" parent="ComputerOverlay/MedicalHistoryWindow/Margin/Content/Header"]
custom_minimum_size = Vector2(48, 48)
layout_mode = 2
mouse_default_cursor_shape = 2
tooltip_text = "Close medical history"
icon = ExtResource("9_close")
icon_max_width = 24
expand_icon = true
[node name="Divider" type="HSeparator" parent="ComputerOverlay/MedicalHistoryWindow/Margin/Content"]
layout_mode = 2
[node name="SectionTitle" type="Label" parent="ComputerOverlay/MedicalHistoryWindow/Margin/Content"]
layout_mode = 2
theme_override_colors/font_color = Color(0.18, 0.25, 0.31, 1)
theme_override_font_sizes/font_size = 20
text = "Past Medical History"
[node name="Text" type="Label" parent="ComputerOverlay/MedicalHistoryWindow/Margin/Content"]
layout_mode = 2
size_flags_vertical = 3
theme_override_colors/font_color = Color(0.04, 0.09, 0.13, 1)
theme_override_font_sizes/font_size = 22
text = "No past medical history is available."
autowrap_mode = 3
[node name="Close" type="Button" parent="ComputerOverlay"] [node name="Close" type="Button" parent="ComputerOverlay"]
layout_mode = 1 layout_mode = 1
anchors_preset = 1 anchors_preset = 1
+115 -33
View File
@@ -38,6 +38,11 @@ const DIALOGUE_BUBBLE_VERTICAL_CHROME := 95.0
@onready var vitals_header: HBoxContainer = $ComputerOverlay/VitalsWindow/Margin/Content/Header @onready var vitals_header: HBoxContainer = $ComputerOverlay/VitalsWindow/Margin/Content/Header
@onready var vitals_rows: GridContainer = $ComputerOverlay/VitalsWindow/Margin/Content/Rows @onready var vitals_rows: GridContainer = $ComputerOverlay/VitalsWindow/Margin/Content/Rows
@onready var vitals_empty: Label = $ComputerOverlay/VitalsWindow/Margin/Content/Empty @onready var vitals_empty: Label = $ComputerOverlay/VitalsWindow/Margin/Content/Empty
@onready var medical_history_launcher: Button = $ComputerOverlay/MedicalHistoryLauncher
@onready var medical_history_window: PanelContainer = $ComputerOverlay/MedicalHistoryWindow
@onready var medical_history_header: HBoxContainer = $ComputerOverlay/MedicalHistoryWindow/Margin/Content/Header
@onready var medical_history_close_button: Button = $ComputerOverlay/MedicalHistoryWindow/Margin/Content/Header/Close
@onready var medical_history_text: Label = $ComputerOverlay/MedicalHistoryWindow/Margin/Content/Text
var blink_tween: Tween var blink_tween: Tween
var dialogue_tween: Tween var dialogue_tween: Tween
@@ -54,8 +59,9 @@ var dialogue_reveal_complete := false
var dialogue_layout_generation := 0 var dialogue_layout_generation := 0
var dialogue_input_enabled := true var dialogue_input_enabled := true
var computer_available := true var computer_available := true
var vitals_dragging := false var computer_drag_window: Control
var vitals_drag_offset := Vector2.ZERO var computer_drag_offset := Vector2.ZERO
var computer_window_z := 2
func _ready() -> void: func _ready() -> void:
@@ -65,10 +71,17 @@ func _ready() -> void:
computer_close_button.pressed.connect(close_computer) computer_close_button.pressed.connect(close_computer)
vitals_launcher.pressed.connect(open_vitals) vitals_launcher.pressed.connect(open_vitals)
vitals_close_button.pressed.connect(close_vitals) vitals_close_button.pressed.connect(close_vitals)
vitals_header.gui_input.connect(_on_vitals_header_gui_input) medical_history_launcher.pressed.connect(open_medical_history)
resized.connect(_resize_vitals_window) medical_history_close_button.pressed.connect(close_medical_history)
vitals_header.gui_input.connect(_on_computer_window_header_input.bind(vitals_window, vitals_close_button))
medical_history_header.gui_input.connect(_on_computer_window_header_input.bind(
medical_history_window, medical_history_close_button))
vitals_window.gui_input.connect(_on_computer_window_input.bind(vitals_window))
medical_history_window.gui_input.connect(_on_computer_window_input.bind(medical_history_window))
resized.connect(_resize_computer_windows)
_style_vitals_window() _style_vitals_window()
_resize_vitals_window() _style_computer_window(medical_history_window)
_resize_computer_windows()
_update_clock() _update_clock()
_schedule_patient_blink() _schedule_patient_blink()
hide_patient_dialogue() hide_patient_dialogue()
@@ -93,7 +106,7 @@ func _update_clock() -> void:
func configure_presentation( func configure_presentation(
presentation: Dictionary, simulated_start: String, simulated_minute: int, presentation: Dictionary, simulated_start: String, simulated_minute: int,
vitals: Dictionary = {}) -> void: vitals: Dictionary = {}, medical_history: String = "") -> void:
if str(presentation.get("patientAssetKey", "")) != "patient_44_white_male": if str(presentation.get("patientAssetKey", "")) != "patient_44_white_male":
push_warning("Unsupported CCS patient asset key; using the office default.") push_warning("Unsupported CCS patient asset key; using the office default.")
var date_time := Time.get_datetime_dict_from_datetime_string(simulated_start, false) var date_time := Time.get_datetime_dict_from_datetime_string(simulated_start, false)
@@ -102,6 +115,9 @@ func configure_presentation(
clock_seconds += float(simulated_minute * 60) clock_seconds += float(simulated_minute * 60)
_update_clock() _update_clock()
_render_vitals(vitals) _render_vitals(vitals)
medical_history_text.text = medical_history.strip_edges() \
if not medical_history.strip_edges().is_empty() \
else "No past medical history is available."
func configure_experience_mode(value: String) -> void: func configure_experience_mode(value: String) -> void:
@@ -284,6 +300,7 @@ func close_computer() -> bool:
if not computer_overlay.visible: if not computer_overlay.visible:
return false return false
close_vitals() close_vitals()
close_medical_history()
computer_overlay.visible = false computer_overlay.visible = false
computer_launcher.visible = computer_available computer_launcher.visible = computer_available
if dialogue_tween and dialogue_tween.is_valid(): if dialogue_tween and dialogue_tween.is_valid():
@@ -296,25 +313,28 @@ func is_computer_open() -> bool:
func close_transient_overlay() -> bool: func close_transient_overlay() -> bool:
if close_vitals(): var top_window := _topmost_computer_window()
return true if top_window == vitals_window:
return close_vitals()
if top_window == medical_history_window:
return close_medical_history()
return close_computer() return close_computer()
func open_vitals() -> void: func open_vitals() -> void:
if not computer_overlay.visible or vitals_window.visible: if not computer_overlay.visible:
return return
vitals_window.visible = true vitals_window.visible = true
vitals_launcher.visible = false _bring_computer_window_to_front(vitals_window)
vitals_close_button.grab_focus() vitals_close_button.grab_focus()
func close_vitals() -> bool: func close_vitals() -> bool:
if not vitals_window.visible: if not vitals_window.visible:
return false return false
vitals_dragging = false if computer_drag_window == vitals_window:
computer_drag_window = null
vitals_window.visible = false vitals_window.visible = false
vitals_launcher.visible = true
return true return true
@@ -322,6 +342,27 @@ func is_vitals_open() -> bool:
return vitals_window.visible return vitals_window.visible
func open_medical_history() -> void:
if not computer_overlay.visible:
return
medical_history_window.visible = true
_bring_computer_window_to_front(medical_history_window)
medical_history_close_button.grab_focus()
func close_medical_history() -> bool:
if not medical_history_window.visible:
return false
if computer_drag_window == medical_history_window:
computer_drag_window = null
medical_history_window.visible = false
return true
func is_medical_history_open() -> bool:
return medical_history_window.visible
func _render_vitals(vitals: Dictionary) -> void: func _render_vitals(vitals: Dictionary) -> void:
for child in vitals_rows.get_children(): for child in vitals_rows.get_children():
vitals_rows.remove_child(child) vitals_rows.remove_child(child)
@@ -399,6 +440,10 @@ func _friendly_vital_name(key: String) -> String:
func _style_vitals_window() -> void: func _style_vitals_window() -> void:
_style_computer_window(vitals_window)
func _style_computer_window(window: PanelContainer) -> void:
var panel_style := StyleBoxFlat.new() var panel_style := StyleBoxFlat.new()
panel_style.bg_color = Color(0.97, 0.98, 0.99, 0.98) panel_style.bg_color = Color(0.97, 0.98, 0.99, 0.98)
panel_style.border_color = Color(0.12, 0.24, 0.34, 0.9) panel_style.border_color = Color(0.12, 0.24, 0.34, 0.9)
@@ -406,46 +451,83 @@ func _style_vitals_window() -> void:
panel_style.set_corner_radius_all(14) panel_style.set_corner_radius_all(14)
panel_style.shadow_color = Color(0.0, 0.0, 0.0, 0.35) panel_style.shadow_color = Color(0.0, 0.0, 0.0, 0.35)
panel_style.shadow_size = 18 panel_style.shadow_size = 18
vitals_window.add_theme_stylebox_override("panel", panel_style) window.add_theme_stylebox_override("panel", panel_style)
func _resize_vitals_window() -> void: func _resize_computer_windows() -> void:
if vitals_window == null: if vitals_window == null or medical_history_window == null:
return return
var window_size := Vector2( var vitals_size := Vector2(
minf(720.0, maxf(320.0, size.x - 48.0)), minf(720.0, maxf(320.0, size.x - 48.0)),
minf(540.0, maxf(360.0, size.y - 48.0))) minf(540.0, maxf(360.0, size.y - 48.0)))
vitals_window.offset_left = -window_size.x * 0.5 _set_centered_window_rect(vitals_window, vitals_size, Vector2(-55.0, -25.0))
vitals_window.offset_top = -window_size.y * 0.5 var history_size := Vector2(
vitals_window.offset_right = window_size.x * 0.5 minf(660.0, maxf(320.0, size.x - 48.0)),
vitals_window.offset_bottom = window_size.y * 0.5 minf(420.0, maxf(300.0, size.y - 48.0)))
_set_centered_window_rect(medical_history_window, history_size, Vector2(55.0, 25.0))
func _on_vitals_header_gui_input(event: InputEvent) -> void: func _set_centered_window_rect(window: Control, window_size: Vector2, offset: Vector2) -> void:
window.offset_left = -window_size.x * 0.5 + offset.x
window.offset_top = -window_size.y * 0.5 + offset.y
window.offset_right = window_size.x * 0.5 + offset.x
window.offset_bottom = window_size.y * 0.5 + offset.y
_set_computer_window_position(window, window.position)
func _on_computer_window_header_input(
event: InputEvent, window: Control, close_button: Button) -> void:
if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT: if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT:
if event.pressed: if event.pressed:
if vitals_close_button.get_global_rect().has_point(event.global_position): if close_button.get_global_rect().has_point(event.global_position):
return return
vitals_dragging = true computer_drag_window = window
vitals_drag_offset = event.global_position - vitals_window.global_position computer_drag_offset = event.global_position - window.global_position
_bring_computer_window_to_front(window)
else: else:
vitals_dragging = false computer_drag_window = null
vitals_header.accept_event() window.accept_event()
elif event is InputEventMouseMotion and vitals_dragging: elif event is InputEventMouseMotion and computer_drag_window == window:
var target_global: Vector2 = event.global_position - vitals_drag_offset var target_global: Vector2 = event.global_position - computer_drag_offset
_set_vitals_window_position(target_global - global_position) _set_computer_window_position(window, target_global - global_position)
vitals_header.accept_event() window.accept_event()
func _on_computer_window_input(event: InputEvent, window: Control) -> void:
if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT and event.pressed:
_bring_computer_window_to_front(window)
func _set_vitals_window_position(target: Vector2) -> void: func _set_vitals_window_position(target: Vector2) -> void:
_set_computer_window_position(vitals_window, target)
func _set_medical_history_window_position(target: Vector2) -> void:
_set_computer_window_position(medical_history_window, target)
func _set_computer_window_position(window: Control, target: Vector2) -> void:
var maximum := Vector2( var maximum := Vector2(
maxf(0.0, size.x - vitals_window.size.x), maxf(0.0, size.x - window.size.x),
maxf(0.0, size.y - vitals_window.size.y)) maxf(0.0, size.y - window.size.y))
vitals_window.position = Vector2( window.position = Vector2(
clampf(target.x, 0.0, maximum.x), clampf(target.x, 0.0, maximum.x),
clampf(target.y, 0.0, maximum.y)) clampf(target.y, 0.0, maximum.y))
func _bring_computer_window_to_front(window: Control) -> void:
computer_window_z += 1
window.z_index = computer_window_z
func _topmost_computer_window() -> Control:
var top_window: Control
for window in [vitals_window, medical_history_window]:
if window.visible and (top_window == null or window.z_index > top_window.z_index):
top_window = window
return top_window
func _event_targets_computer_launcher(event: InputEvent) -> bool: func _event_targets_computer_launcher(event: InputEvent) -> bool:
if not computer_launcher.visible: if not computer_launcher.visible:
return false return false
+9 -15
View File
@@ -5,10 +5,7 @@ const Style := preload("res://scripts/ui/ui_style.gd")
const DOCTOR_SCENE := preload("res://scenes/ccs_cases_2/doctor_office.tscn") const DOCTOR_SCENE := preload("res://scenes/ccs_cases_2/doctor_office.tscn")
const DESIGN_SIZE := Vector2(1600.0, 900.0) const DESIGN_SIZE := Vector2(1600.0, 900.0)
const FOCUSED_INTERVIEW_CASE_ID := "ccs-gastro-duodenal-ulcer-office-001" const FOCUSED_INTERVIEW_CASE_ID := "ccs-gastro-duodenal-ulcer-office-001"
const RETIRED_INTERVIEW_TOPIC_IDS := [ const FOCUSED_INTERVIEW_REVISION := 2
"abdominal_symptoms", "bleeding_alarm_features", "medications",
"medical_history", "social_history", "additional_review"
]
var main var main
var stage_root: Control var stage_root: Control
@@ -235,7 +232,8 @@ func _show_doctor_office(node: Dictionary, attempt: Dictionary = {}) -> void:
attempt.get("graphicalPresentation", {}), attempt.get("graphicalPresentation", {}),
str(attempt.get("simulatedStartUtc", "")), str(attempt.get("simulatedStartUtc", "")),
int(attempt.get("simulatedMinute", 0)), int(attempt.get("simulatedMinute", 0)),
attempt.get("vitals", {})) attempt.get("vitals", {}),
str(attempt.get("medicalHistory", "")))
active_scene.configure_interview(node) active_scene.configure_interview(node)
active_scene.set_choices_pending(not pending_action.is_empty()) active_scene.set_choices_pending(not pending_action.is_empty())
@@ -270,7 +268,8 @@ func _render_debrief(attempt: Dictionary) -> void:
attempt.get("graphicalPresentation", {}), attempt.get("graphicalPresentation", {}),
str(attempt.get("simulatedStartUtc", "")), str(attempt.get("simulatedStartUtc", "")),
int(attempt.get("simulatedMinute", 0)), int(attempt.get("simulatedMinute", 0)),
attempt.get("vitals", {})) attempt.get("vitals", {}),
str(attempt.get("medicalHistory", "")))
active_scene.show_debrief(attempt.get("interviewDebrief", {}), attempt.get("score", {})) active_scene.show_debrief(attempt.get("interviewDebrief", {}), attempt.get("score", {}))
current_view = "interview_debrief" current_view = "interview_debrief"
@@ -289,16 +288,11 @@ func _repeat_interview() -> void:
CCSService.create_attempt(case_external_id, "timed") CCSService.create_attempt(case_external_id, "timed")
func _is_retired_interview_revision(attempt: Dictionary, node: Dictionary) -> bool: func _is_retired_interview_revision(attempt: Dictionary, _node: Dictionary) -> bool:
if str(attempt.get("caseExternalId", "")) != FOCUSED_INTERVIEW_CASE_ID: if str(attempt.get("caseExternalId", "")) != FOCUSED_INTERVIEW_CASE_ID:
return false return false
if str(node.get("id", "")) in RETIRED_INTERVIEW_TOPIC_IDS: var presentation: Dictionary = attempt.get("graphicalPresentation", {})
return true return int(presentation.get("interviewRevision", 0)) < FOCUSED_INTERVIEW_REVISION
for raw_choice in node.get("choices", []):
if str(raw_choice.get("id", "")) in RETIRED_INTERVIEW_TOPIC_IDS:
return true
var patient_text := str(node.get("patientText", "")).to_lower()
return patient_text.contains("stomach bothers me") or patient_text.contains("gnawing ache")
func _restart_with_current_interview(attempt: Dictionary) -> void: func _restart_with_current_interview(attempt: Dictionary) -> void:
@@ -306,7 +300,7 @@ func _restart_with_current_interview(attempt: Dictionary) -> void:
return return
restart_case_external_id = str(attempt.get("caseExternalId", "")) restart_case_external_id = str(attempt.get("caseExternalId", ""))
pending_action = "refreshing_interview" pending_action = "refreshing_interview"
_show_selection("Updating this interview to its latest dizziness and fatigue dialogue...") _show_selection("Updating this interview to its latest patient history dialogue...")
CCSService.abandon() CCSService.abandon()
+32 -15
View File
@@ -65,19 +65,15 @@ func _run() -> void:
_assert(screen.current_view == "case_selection", "CCS Cases 2 should begin with compatible case selection") _assert(screen.current_view == "case_selection", "CCS Cases 2 should begin with compatible case selection")
_assert(screen.case_list.get_child_count() == 1, "legacy cases should be excluded from graphical selection") _assert(screen.case_list.get_child_count() == 1, "legacy cases should be excluded from graphical selection")
var retired_attempt := { var retired_attempt := {
"caseExternalId": "ccs-gastro-duodenal-ulcer-office-001" "caseExternalId": "ccs-gastro-duodenal-ulcer-office-001",
"graphicalPresentation": {"interviewRevision": 1}
} }
_assert(screen._is_retired_interview_revision(retired_attempt, { _assert(screen._is_retired_interview_revision(retired_attempt, {
"id": "fatigue_course", "patientText": "My stomach bothers me.", "choices": [] "id": "fatigue_course", "patientText": "I feel tired.", "choices": []
}), "the retired stomach response should trigger a current-revision restart") }), "an older authored interview revision should trigger a restart")
_assert(screen._is_retired_interview_revision(retired_attempt, { retired_attempt.graphicalPresentation.interviewRevision = 2
"id": "chief_concern", "patientText": "Dizzy.", _assert(not screen._is_retired_interview_revision(retired_attempt, {}),
"choices": [{"id": "abdominal_symptoms", "text": "Ask about stomach pain"}] "the current authored interview revision should remain active")
}), "retired stomach-topic choices should trigger a current-revision restart")
_assert(not screen._is_retired_interview_revision(retired_attempt, {
"id": "chief_concern", "patientText": "Dizzy and tired.",
"choices": [{"id": "dizziness_character", "text": "Ask about dizziness"}]
}), "the focused dizziness and fatigue revision should remain active")
service.attempt = _sample_attempt(false) service.attempt = _sample_attempt(false)
screen._render_state() screen._render_state()
@@ -93,21 +89,41 @@ func _run() -> void:
_assert(screen.active_scene.computer_overlay.get_node("Screen").texture.resource_path.ends_with("computer-base.png"), "the overlay should render the supplied computer base") _assert(screen.active_scene.computer_overlay.get_node("Screen").texture.resource_path.ends_with("computer-base.png"), "the overlay should render the supplied computer base")
_assert(screen.active_scene.computer_overlay.get_node("Screen").stretch_mode == TextureRect.STRETCH_SCALE, "the computer should fill the viewport without side bars") _assert(screen.active_scene.computer_overlay.get_node("Screen").stretch_mode == TextureRect.STRETCH_SCALE, "the computer should fill the viewport without side bars")
_assert(screen.active_scene.vitals_launcher.visible, "the computer desktop should expose a Vitals app") _assert(screen.active_scene.vitals_launcher.visible, "the computer desktop should expose a Vitals app")
_assert(screen.active_scene.medical_history_launcher.visible, "the computer desktop should expose a Medical History app")
screen.active_scene.vitals_launcher.pressed.emit() screen.active_scene.vitals_launcher.pressed.emit()
_assert(screen.active_scene.is_vitals_open(), "the Vitals app should open a nurse vital-sign window") _assert(screen.active_scene.is_vitals_open(), "the Vitals app should open a nurse vital-sign window")
_assert(screen.active_scene.vitals_header.get_node("Title").text == "Vitals", "the vital window should use the concise Vitals heading") _assert(screen.active_scene.vitals_header.get_node("Title").text == "Vitals", "the vital window should use the concise Vitals heading")
_assert(_vital_values(screen.active_scene) == ["36.8 C (98.2 F)", "76/min", "14/min", "122 mm Hg", "76 mm Hg", "99% on room air"], "the Vitals app should render the active attempt data in clinical order") _assert(_vital_values(screen.active_scene) == ["36.8 C (98.2 F)", "76/min", "14/min", "122 mm Hg", "76 mm Hg", "99% on room air"], "the Vitals app should render the active attempt data in clinical order")
_assert(_vital_values_fit(screen.active_scene), "vital values should retain a readable column without character-by-character wrapping") _assert(_vital_values_fit(screen.active_scene), "vital values should retain a readable column without character-by-character wrapping")
screen.active_scene.medical_history_launcher.pressed.emit()
_assert(screen.active_scene.is_vitals_open() and screen.active_scene.is_medical_history_open(), "both chart windows should be able to remain open")
_assert(screen.active_scene.medical_history_text.text == "Hyperlipidemia", "Medical History should render the case-authored past medical history")
_assert(screen.active_scene.medical_history_window.z_index > screen.active_scene.vitals_window.z_index, "the most recently opened chart window should come to the front")
var original_history_position: Vector2 = screen.active_scene.medical_history_window.position
screen.active_scene._set_medical_history_window_position(Vector2(90.0, 70.0))
var moved_history_position: Vector2 = screen.active_scene.medical_history_window.position
_assert(not moved_history_position.is_equal_approx(original_history_position) \
and moved_history_position.x >= 0.0 and moved_history_position.y >= 0.0,
"Medical History should use the shared draggable-window behavior")
_assert(screen.close_transient_overlay(), "the first Escape should close the topmost Medical History app")
_assert(not screen.active_scene.is_medical_history_open() and screen.active_scene.is_vitals_open(), "closing Medical History should leave Vitals open")
screen.active_scene._set_vitals_window_position(Vector2(75.0, 55.0)) screen.active_scene._set_vitals_window_position(Vector2(75.0, 55.0))
_assert(screen.active_scene.vitals_window.position.is_equal_approx(Vector2(75.0, 55.0)), "the Vitals window should support repositioning by its header") _assert(screen.active_scene.vitals_window.position.is_equal_approx(Vector2(75.0, 55.0)), "the Vitals window should support repositioning by its header")
screen.active_scene._set_vitals_window_position(Vector2(-500.0, 5000.0)) screen.active_scene._set_vitals_window_position(Vector2(-500.0, 5000.0))
var max_vitals_position: Vector2 = screen.active_scene.size - screen.active_scene.vitals_window.size var max_vitals_position: Vector2 = screen.active_scene.size - screen.active_scene.vitals_window.size
_assert(screen.active_scene.vitals_window.position.is_equal_approx(Vector2(0.0, max_vitals_position.y)), "dragging should keep the Vitals window inside the computer viewport") _assert(screen.active_scene.vitals_window.position.is_equal_approx(Vector2(0.0, max_vitals_position.y)), "dragging should keep the Vitals window inside the computer viewport")
_assert(screen.close_transient_overlay(), "the first Escape should close the Vitals app") _assert(screen.close_transient_overlay(), "the second Escape should close the Vitals app")
_assert(not screen.active_scene.is_vitals_open() and screen.active_scene.is_computer_open(), "closing Vitals should return to the computer desktop") _assert(not screen.active_scene.is_vitals_open() and screen.active_scene.is_computer_open(), "closing Vitals should return to the computer desktop")
_assert(screen.close_transient_overlay(), "the second Escape should close the computer") _assert(screen.close_transient_overlay(), "the third Escape should close the computer")
_assert(not screen.active_scene.is_computer_open(), "closing the transient overlay should restore the doctor office") _assert(not screen.active_scene.is_computer_open(), "closing the transient overlay should restore the doctor office")
_assert(not screen.close_transient_overlay(), "the third Escape should be available to open the pause menu") _assert(not screen.close_transient_overlay(), "the fourth Escape should be available to open the pause menu")
screen.active_scene.open_computer()
screen.active_scene.open_vitals()
screen.active_scene.open_medical_history()
screen.active_scene.computer_close_button.pressed.emit()
_assert(not screen.active_scene.is_computer_open() and not screen.active_scene.is_vitals_open() \
and not screen.active_scene.is_medical_history_open(),
"the computer close button should close every child chart window")
_assert(is_equal_approx(screen.DESIGN_SIZE.x / screen.DESIGN_SIZE.y, 16.0 / 9.0), "the graphical CCS stage should use a true 16:9 design canvas") _assert(is_equal_approx(screen.DESIGN_SIZE.x / screen.DESIGN_SIZE.y, 16.0 / 9.0), "the graphical CCS stage should use a true 16:9 design canvas")
_assert(screen.stage_root.size.is_equal_approx(screen.size), "the graphical stage should fill the available viewport") _assert(screen.stage_root.size.is_equal_approx(screen.size), "the graphical stage should fill the available viewport")
_assert(screen.stage_root.position.is_equal_approx(Vector2.ZERO), "the graphical stage should not introduce letterbox margins") _assert(screen.stage_root.position.is_equal_approx(Vector2.ZERO), "the graphical stage should not introduce letterbox margins")
@@ -245,7 +261,8 @@ func _sample_attempt(completed: bool) -> Dictionary:
return { return {
"id": "attempt-1", "state": "active", "revision": 1, "id": "attempt-1", "state": "active", "revision": 1,
"simulatedStartUtc": "2026-08-05T09:00:00Z", "simulatedMinute": 0, "simulatedStartUtc": "2026-08-05T09:00:00Z", "simulatedMinute": 0,
"graphicalPresentation": {"sceneKey": "doctor_office", "patientAssetKey": "patient_44_white_male"}, "graphicalPresentation": {"sceneKey": "doctor_office", "patientAssetKey": "patient_44_white_male", "interviewRevision": 2},
"medicalHistory": "Hyperlipidemia",
"vitals": { "vitals": {
"temperature": "36.8 C (98.2 F)", "bloodPressure": "122/76 mm Hg", "temperature": "36.8 C (98.2 F)", "bloodPressure": "122/76 mm Hg",
"pulse": "76/min", "respirations": "14/min", "oxygenSaturation": "99% on room air" "pulse": "76/min", "respirations": "14/min", "oxygenSaturation": "99% on room air"