Expand CCS patient history
This commit is contained in:
@@ -101,8 +101,17 @@ public sealed class CcsCaseTests
|
||||
Assert.Empty(CcsCaseValidator.ValidateImport(request!, new HashSet<string>()));
|
||||
Assert.Equal(CcsExperienceModes.GraphicalInterview, request.Cases.Single().ExperienceMode);
|
||||
var topics = request.Cases.Single().InitialInterview.Topics;
|
||||
Assert.Equal(["chief_concern", "dizziness_character", "fatigue_course"], topics.Select(value => value.Id));
|
||||
Assert.Equal(2, topics.Count(value => value.Required));
|
||||
Assert.Equal([
|
||||
"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]
|
||||
|
||||
@@ -108,6 +108,7 @@ public class CcsGraphicalPresentation
|
||||
{
|
||||
public string SceneKey { get; set; } = "";
|
||||
public string PatientAssetKey { get; set; } = "";
|
||||
public int InterviewRevision { get; set; }
|
||||
}
|
||||
|
||||
public static class CcsExperienceModes
|
||||
@@ -379,6 +380,7 @@ public class CcsAttemptResponse
|
||||
public string Appearance { get; set; } = "";
|
||||
public string History { get; set; } = "";
|
||||
public List<CcsNarrativeSection> HistorySections { get; set; } = [];
|
||||
public string MedicalHistory { get; set; } = "";
|
||||
public Dictionary<string, string> Vitals { get; set; } = [];
|
||||
public List<CcsChartEntry> Chart { get; set; } = [];
|
||||
public List<string> ActiveOrderCodes { get; set; } = [];
|
||||
|
||||
@@ -467,6 +467,7 @@ public sealed class CcsStore
|
||||
? "" : definition.InitialChart.History,
|
||||
HistorySections = definition.ExperienceMode == CcsExperienceModes.GraphicalInterview && !completed
|
||||
? [] : definition.InitialChart.HistorySections,
|
||||
MedicalHistory = MedicalHistory(definition),
|
||||
Vitals = attempt.CurrentVitals, Chart = attempt.Chart, ActiveOrderCodes = attempt.ActiveOrderCodes,
|
||||
ActiveOrders = activeOrders, VitalHistory = vitalHistory,
|
||||
PendingResults = attempt.PendingResults.Select(value => new CcsPendingResult
|
||||
@@ -893,6 +894,10 @@ public sealed class CcsStore
|
||||
});
|
||||
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)
|
||||
{
|
||||
var values = new[] { order.CanonicalName }.Concat(order.Aliases).Select(value => value.ToLowerInvariant());
|
||||
|
||||
@@ -13,17 +13,20 @@
|
||||
"experienceMode": "graphical_interview",
|
||||
"graphicalPresentation": {
|
||||
"sceneKey": "doctor_office",
|
||||
"patientAssetKey": "patient_44_white_male"
|
||||
"patientAssetKey": "patient_44_white_male",
|
||||
"interviewRevision": 2
|
||||
},
|
||||
"initialChart": {
|
||||
"patientSummary": "44-year-old man attending an office visit",
|
||||
"reasonForEncounter": "The reason for the visit has not yet been elicited.",
|
||||
"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": [
|
||||
{ "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": "alarm_features", "title": "Alarm features", "text": "No melena, hematemesis, persistent vomiting, dysphagia, unintentional weight loss, or syncope." }
|
||||
{ "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": "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": {
|
||||
"temperature": "36.8 C (98.2 F)",
|
||||
@@ -56,17 +59,53 @@
|
||||
{
|
||||
"id": "fatigue_course",
|
||||
"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,
|
||||
"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": {
|
||||
"likelyDiagnosis": "Lightheadedness and fatigue requiring further evaluation",
|
||||
"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.",
|
||||
"likelyDiagnosis": "Peptic ulcer disease with possible intermittent upper gastrointestinal bleeding",
|
||||
"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": [
|
||||
"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": [
|
||||
{ "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": "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": [
|
||||
{
|
||||
|
||||
@@ -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="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/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/clipboard-list.svg" id="11_history"]
|
||||
|
||||
[sub_resource type="ShaderMaterial" id="ShaderMaterial_patient_fit"]
|
||||
shader = ExtResource("5_patient_fit")
|
||||
@@ -287,6 +288,20 @@ expand_icon = true
|
||||
text = "Vitals"
|
||||
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"]
|
||||
visible = false
|
||||
z_index = 2
|
||||
@@ -353,6 +368,70 @@ text = "No nurse vital signs are available."
|
||||
horizontal_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"]
|
||||
layout_mode = 1
|
||||
anchors_preset = 1
|
||||
|
||||
@@ -38,6 +38,11 @@ const DIALOGUE_BUBBLE_VERTICAL_CHROME := 95.0
|
||||
@onready var vitals_header: HBoxContainer = $ComputerOverlay/VitalsWindow/Margin/Content/Header
|
||||
@onready var vitals_rows: GridContainer = $ComputerOverlay/VitalsWindow/Margin/Content/Rows
|
||||
@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 dialogue_tween: Tween
|
||||
@@ -54,8 +59,9 @@ var dialogue_reveal_complete := false
|
||||
var dialogue_layout_generation := 0
|
||||
var dialogue_input_enabled := true
|
||||
var computer_available := true
|
||||
var vitals_dragging := false
|
||||
var vitals_drag_offset := Vector2.ZERO
|
||||
var computer_drag_window: Control
|
||||
var computer_drag_offset := Vector2.ZERO
|
||||
var computer_window_z := 2
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
@@ -65,10 +71,17 @@ func _ready() -> void:
|
||||
computer_close_button.pressed.connect(close_computer)
|
||||
vitals_launcher.pressed.connect(open_vitals)
|
||||
vitals_close_button.pressed.connect(close_vitals)
|
||||
vitals_header.gui_input.connect(_on_vitals_header_gui_input)
|
||||
resized.connect(_resize_vitals_window)
|
||||
medical_history_launcher.pressed.connect(open_medical_history)
|
||||
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()
|
||||
_resize_vitals_window()
|
||||
_style_computer_window(medical_history_window)
|
||||
_resize_computer_windows()
|
||||
_update_clock()
|
||||
_schedule_patient_blink()
|
||||
hide_patient_dialogue()
|
||||
@@ -93,7 +106,7 @@ func _update_clock() -> void:
|
||||
|
||||
func configure_presentation(
|
||||
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":
|
||||
push_warning("Unsupported CCS patient asset key; using the office default.")
|
||||
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)
|
||||
_update_clock()
|
||||
_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:
|
||||
@@ -284,6 +300,7 @@ func close_computer() -> bool:
|
||||
if not computer_overlay.visible:
|
||||
return false
|
||||
close_vitals()
|
||||
close_medical_history()
|
||||
computer_overlay.visible = false
|
||||
computer_launcher.visible = computer_available
|
||||
if dialogue_tween and dialogue_tween.is_valid():
|
||||
@@ -296,25 +313,28 @@ func is_computer_open() -> bool:
|
||||
|
||||
|
||||
func close_transient_overlay() -> bool:
|
||||
if close_vitals():
|
||||
return true
|
||||
var top_window := _topmost_computer_window()
|
||||
if top_window == vitals_window:
|
||||
return close_vitals()
|
||||
if top_window == medical_history_window:
|
||||
return close_medical_history()
|
||||
return close_computer()
|
||||
|
||||
|
||||
func open_vitals() -> void:
|
||||
if not computer_overlay.visible or vitals_window.visible:
|
||||
if not computer_overlay.visible:
|
||||
return
|
||||
vitals_window.visible = true
|
||||
vitals_launcher.visible = false
|
||||
_bring_computer_window_to_front(vitals_window)
|
||||
vitals_close_button.grab_focus()
|
||||
|
||||
|
||||
func close_vitals() -> bool:
|
||||
if not vitals_window.visible:
|
||||
return false
|
||||
vitals_dragging = false
|
||||
if computer_drag_window == vitals_window:
|
||||
computer_drag_window = null
|
||||
vitals_window.visible = false
|
||||
vitals_launcher.visible = true
|
||||
return true
|
||||
|
||||
|
||||
@@ -322,6 +342,27 @@ func is_vitals_open() -> bool:
|
||||
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:
|
||||
for child in vitals_rows.get_children():
|
||||
vitals_rows.remove_child(child)
|
||||
@@ -399,6 +440,10 @@ func _friendly_vital_name(key: String) -> String:
|
||||
|
||||
|
||||
func _style_vitals_window() -> void:
|
||||
_style_computer_window(vitals_window)
|
||||
|
||||
|
||||
func _style_computer_window(window: PanelContainer) -> void:
|
||||
var panel_style := StyleBoxFlat.new()
|
||||
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)
|
||||
@@ -406,46 +451,83 @@ func _style_vitals_window() -> void:
|
||||
panel_style.set_corner_radius_all(14)
|
||||
panel_style.shadow_color = Color(0.0, 0.0, 0.0, 0.35)
|
||||
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:
|
||||
if vitals_window == null:
|
||||
func _resize_computer_windows() -> void:
|
||||
if vitals_window == null or medical_history_window == null:
|
||||
return
|
||||
var window_size := Vector2(
|
||||
var vitals_size := Vector2(
|
||||
minf(720.0, maxf(320.0, size.x - 48.0)),
|
||||
minf(540.0, maxf(360.0, size.y - 48.0)))
|
||||
vitals_window.offset_left = -window_size.x * 0.5
|
||||
vitals_window.offset_top = -window_size.y * 0.5
|
||||
vitals_window.offset_right = window_size.x * 0.5
|
||||
vitals_window.offset_bottom = window_size.y * 0.5
|
||||
_set_centered_window_rect(vitals_window, vitals_size, Vector2(-55.0, -25.0))
|
||||
var history_size := Vector2(
|
||||
minf(660.0, maxf(320.0, size.x - 48.0)),
|
||||
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.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
|
||||
vitals_dragging = true
|
||||
vitals_drag_offset = event.global_position - vitals_window.global_position
|
||||
computer_drag_window = window
|
||||
computer_drag_offset = event.global_position - window.global_position
|
||||
_bring_computer_window_to_front(window)
|
||||
else:
|
||||
vitals_dragging = false
|
||||
vitals_header.accept_event()
|
||||
elif event is InputEventMouseMotion and vitals_dragging:
|
||||
var target_global: Vector2 = event.global_position - vitals_drag_offset
|
||||
_set_vitals_window_position(target_global - global_position)
|
||||
vitals_header.accept_event()
|
||||
computer_drag_window = null
|
||||
window.accept_event()
|
||||
elif event is InputEventMouseMotion and computer_drag_window == window:
|
||||
var target_global: Vector2 = event.global_position - computer_drag_offset
|
||||
_set_computer_window_position(window, target_global - global_position)
|
||||
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:
|
||||
_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(
|
||||
maxf(0.0, size.x - vitals_window.size.x),
|
||||
maxf(0.0, size.y - vitals_window.size.y))
|
||||
vitals_window.position = Vector2(
|
||||
maxf(0.0, size.x - window.size.x),
|
||||
maxf(0.0, size.y - window.size.y))
|
||||
window.position = Vector2(
|
||||
clampf(target.x, 0.0, maximum.x),
|
||||
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:
|
||||
if not computer_launcher.visible:
|
||||
return false
|
||||
|
||||
@@ -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 DESIGN_SIZE := Vector2(1600.0, 900.0)
|
||||
const FOCUSED_INTERVIEW_CASE_ID := "ccs-gastro-duodenal-ulcer-office-001"
|
||||
const RETIRED_INTERVIEW_TOPIC_IDS := [
|
||||
"abdominal_symptoms", "bleeding_alarm_features", "medications",
|
||||
"medical_history", "social_history", "additional_review"
|
||||
]
|
||||
const FOCUSED_INTERVIEW_REVISION := 2
|
||||
|
||||
var main
|
||||
var stage_root: Control
|
||||
@@ -235,7 +232,8 @@ func _show_doctor_office(node: Dictionary, attempt: Dictionary = {}) -> void:
|
||||
attempt.get("graphicalPresentation", {}),
|
||||
str(attempt.get("simulatedStartUtc", "")),
|
||||
int(attempt.get("simulatedMinute", 0)),
|
||||
attempt.get("vitals", {}))
|
||||
attempt.get("vitals", {}),
|
||||
str(attempt.get("medicalHistory", "")))
|
||||
active_scene.configure_interview(node)
|
||||
active_scene.set_choices_pending(not pending_action.is_empty())
|
||||
|
||||
@@ -270,7 +268,8 @@ func _render_debrief(attempt: Dictionary) -> void:
|
||||
attempt.get("graphicalPresentation", {}),
|
||||
str(attempt.get("simulatedStartUtc", "")),
|
||||
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", {}))
|
||||
current_view = "interview_debrief"
|
||||
|
||||
@@ -289,16 +288,11 @@ func _repeat_interview() -> void:
|
||||
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:
|
||||
return false
|
||||
if str(node.get("id", "")) in RETIRED_INTERVIEW_TOPIC_IDS:
|
||||
return true
|
||||
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")
|
||||
var presentation: Dictionary = attempt.get("graphicalPresentation", {})
|
||||
return int(presentation.get("interviewRevision", 0)) < FOCUSED_INTERVIEW_REVISION
|
||||
|
||||
|
||||
func _restart_with_current_interview(attempt: Dictionary) -> void:
|
||||
@@ -306,7 +300,7 @@ func _restart_with_current_interview(attempt: Dictionary) -> void:
|
||||
return
|
||||
restart_case_external_id = str(attempt.get("caseExternalId", ""))
|
||||
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()
|
||||
|
||||
|
||||
|
||||
@@ -65,19 +65,15 @@ func _run() -> void:
|
||||
_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")
|
||||
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, {
|
||||
"id": "fatigue_course", "patientText": "My stomach bothers me.", "choices": []
|
||||
}), "the retired stomach response should trigger a current-revision restart")
|
||||
_assert(screen._is_retired_interview_revision(retired_attempt, {
|
||||
"id": "chief_concern", "patientText": "Dizzy.",
|
||||
"choices": [{"id": "abdominal_symptoms", "text": "Ask about stomach pain"}]
|
||||
}), "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")
|
||||
"id": "fatigue_course", "patientText": "I feel tired.", "choices": []
|
||||
}), "an older authored interview revision should trigger a restart")
|
||||
retired_attempt.graphicalPresentation.interviewRevision = 2
|
||||
_assert(not screen._is_retired_interview_revision(retired_attempt, {}),
|
||||
"the current authored interview revision should remain active")
|
||||
|
||||
service.attempt = _sample_attempt(false)
|
||||
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").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.medical_history_launcher.visible, "the computer desktop should expose a Medical History app")
|
||||
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.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_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))
|
||||
_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))
|
||||
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.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(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.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(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")
|
||||
@@ -245,7 +261,8 @@ func _sample_attempt(completed: bool) -> Dictionary:
|
||||
return {
|
||||
"id": "attempt-1", "state": "active", "revision": 1,
|
||||
"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": {
|
||||
"temperature": "36.8 C (98.2 F)", "bloodPressure": "122/76 mm Hg",
|
||||
"pulse": "76/min", "respirations": "14/min", "oxygenSaturation": "99% on room air"
|
||||
|
||||
Reference in New Issue
Block a user