Add CCS study tools and case reporting

This commit is contained in:
hz
2026-07-15 10:43:03 -05:00
parent 7256f93002
commit 9f379cda5c
13 changed files with 542 additions and 56 deletions
+8
View File
@@ -7,6 +7,14 @@ namespace QuestionsApi.Tests;
public sealed class CcsCaseTests
{
[Fact]
public void CcsReportCategoriesAreSpecificAndStable()
{
Assert.Contains("case_progression_scoring", CcsCaseReportCategory.All);
Assert.DoesNotContain("answer_explanation", CcsCaseReportCategory.All);
Assert.Equal(6, CcsCaseReportCategory.All.Count);
}
[Fact]
public void DkaImportArtifactMatchesTheCaseSchema()
{
@@ -0,0 +1,52 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using QuestionsApi.Models;
using QuestionsApi.Services;
using System.Security.Claims;
namespace QuestionsApi.Controllers;
[ApiController, Route("api/ccs/reports")]
public sealed class CcsCaseReportsController(CcsStore cases, CcsCaseReportStore reports) : ControllerBase
{
private const int MinDetails = 10;
private const int MaxDetails = 2000;
[HttpPost, Authorize(Roles = "USER,SUPER")]
public async Task<IActionResult> Create(CreateCcsCaseReportRequest request)
{
var category = request.Category.Trim().ToLowerInvariant();
var details = request.Details.Trim();
if (!CcsCaseReportCategory.All.Contains(category)) return BadRequest("category is invalid");
if (details.Length is < MinDetails or > MaxDetails) return BadRequest($"details must be between {MinDetails} and {MaxDetails} characters");
var attempt = await cases.GetOwnedAsync(request.AttemptId.Trim(), UserId());
if (attempt is null) return NotFound();
var report = await reports.CreateAsync(attempt, await cases.DefinitionAsync(attempt), category, details);
return StatusCode(StatusCodes.Status201Created, new { id = report.Id, report.AttemptId, report.Category, report.Status, report.CreatedUtc });
}
[HttpGet, Authorize(Roles = "SUPER")]
public async Task<IActionResult> List([FromQuery] string status = "open", [FromQuery] string category = "", [FromQuery] string caseExternalId = "", [FromQuery] string userId = "", [FromQuery] int skip = 0, [FromQuery] int limit = 50)
{
status = status.Trim().ToLowerInvariant(); category = category.Trim().ToLowerInvariant();
if (status != "all" && status != QuestionReportStatus.Open && !QuestionReportStatus.Terminal.Contains(status)) return BadRequest("status is invalid");
if (category.Length > 0 && !CcsCaseReportCategory.All.Contains(category)) return BadRequest("category is invalid");
if (skip < 0 || limit is < 1 or > 100) return BadRequest("pagination is invalid");
return Ok(await reports.SearchAsync(status, category, caseExternalId.Trim(), userId.Trim(), skip, limit));
}
[HttpGet("{id}"), Authorize(Roles = "SUPER")]
public async Task<IActionResult> Get(string id) => await reports.GetAsync(id.Trim()) is { } report ? Ok(report) : NotFound();
[HttpPatch("{id}/status"), Authorize(Roles = "SUPER")]
public async Task<IActionResult> Close(string id, UpdateQuestionReportStatusRequest request)
{
var status = request.Status.Trim().ToLowerInvariant();
if (!QuestionReportStatus.Terminal.Contains(status)) return BadRequest("status must be resolved or dismissed");
if (request.ReviewerNote.Length > MaxDetails) return BadRequest("reviewerNote must be 2000 characters or fewer");
var updated = await reports.CloseAsync(id.Trim(), status, UserId(), request.ReviewerNote.Trim());
return updated is null ? Conflict("report is missing or already closed") : Ok(updated);
}
private string UserId() => User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue(ClaimTypes.Name) ?? User.Identity?.Name ?? "";
}
+63
View File
@@ -0,0 +1,63 @@
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
namespace QuestionsApi.Models;
public static class CcsCaseReportCategory
{
public const string Clarity = "clarity";
public const string MedicalAccuracy = "medical_accuracy";
public const string CaseProgressionScoring = "case_progression_scoring";
public const string FormattingMedia = "formatting_media";
public const string TechnicalIssue = "technical_issue";
public const string Other = "other";
public static readonly HashSet<string> All = [Clarity, MedicalAccuracy, CaseProgressionScoring, FormattingMedia, TechnicalIssue, Other];
}
public sealed class CcsCaseReport
{
[BsonId, BsonRepresentation(BsonType.ObjectId)] public string? Id { get; set; }
public string CaseExternalId { get; set; } = "";
[BsonRepresentation(BsonType.ObjectId)] public string CaseRevisionId { get; set; } = "";
public int CaseVersion { get; set; }
[BsonRepresentation(BsonType.ObjectId)] public string AttemptId { get; set; } = "";
public string UserId { get; set; } = "";
public string Category { get; set; } = "";
public string Details { get; set; } = "";
public string Status { get; set; } = QuestionReportStatus.Open;
public CcsCaseRevision CaseSnapshot { get; set; } = new();
public CcsAttempt AttemptSnapshot { get; set; } = new();
public string ReviewedByUserId { get; set; } = "";
public string ReviewerNote { get; set; } = "";
public DateTime? ReviewedUtc { get; set; }
public DateTime CreatedUtc { get; set; } = DateTime.UtcNow;
public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow;
}
public sealed class CreateCcsCaseReportRequest
{
public string AttemptId { get; set; } = "";
public string Category { get; set; } = "";
public string Details { get; set; } = "";
}
public sealed class CcsCaseReportListResponse
{
public List<CcsCaseReportSummary> Items { get; set; } = [];
public long Total { get; set; }
public int Skip { get; set; }
public int Limit { get; set; }
}
public sealed class CcsCaseReportSummary
{
public string Id { get; set; } = "";
public string CaseExternalId { get; set; } = "";
public int CaseVersion { get; set; }
public string AttemptId { get; set; } = "";
public string UserId { get; set; } = "";
public string Category { get; set; } = "";
public string Details { get; set; } = "";
public string Status { get; set; } = "";
public DateTime CreatedUtc { get; set; }
}
+1
View File
@@ -18,6 +18,7 @@ builder.Services.AddSingleton<QuestionMediaStore>();
builder.Services.AddSingleton<QuestionReportStore>();
builder.Services.AddSingleton<ExamSimulationStore>();
builder.Services.AddSingleton<CcsStore>();
builder.Services.AddSingleton<CcsCaseReportStore>();
builder.Services.AddSingleton<ServiceMetrics>();
builder.Services.AddHostedService<AnswerStatisticsMaintenanceService>();
@@ -0,0 +1,67 @@
using MongoDB.Bson;
using MongoDB.Driver;
using QuestionsApi.Models;
namespace QuestionsApi.Services;
public sealed class CcsCaseReportStore
{
private readonly IMongoCollection<CcsCaseReport> _reports;
public CcsCaseReportStore(MongoDatabaseProvider mongo)
{
_reports = mongo.Database.GetCollection<CcsCaseReport>("CcsCaseReports");
_reports.Indexes.CreateMany([
new CreateIndexModel<CcsCaseReport>(Builders<CcsCaseReport>.IndexKeys.Ascending(value => value.Status).Descending(value => value.CreatedUtc)),
new CreateIndexModel<CcsCaseReport>(Builders<CcsCaseReport>.IndexKeys.Ascending(value => value.CaseExternalId)),
new CreateIndexModel<CcsCaseReport>(Builders<CcsCaseReport>.IndexKeys.Ascending(value => value.AttemptId)),
new CreateIndexModel<CcsCaseReport>(Builders<CcsCaseReport>.IndexKeys.Ascending(value => value.UserId))
]);
}
public async Task<CcsCaseReport> CreateAsync(CcsAttempt attempt, CcsCaseRevision definition, string category, string details)
{
var now = DateTime.UtcNow;
var report = new CcsCaseReport
{
CaseExternalId = attempt.CaseExternalId, CaseRevisionId = attempt.CaseRevisionId,
CaseVersion = attempt.CaseVersion, AttemptId = attempt.Id ?? "", UserId = attempt.UserId,
Category = category, Details = details, CaseSnapshot = definition, AttemptSnapshot = attempt,
CreatedUtc = now, UpdatedUtc = now
};
await _reports.InsertOneAsync(report);
return report;
}
public async Task<CcsCaseReport?> GetAsync(string id) => ObjectId.TryParse(id, out _)
? await _reports.Find(value => value.Id == id).FirstOrDefaultAsync() : null;
public async Task<CcsCaseReportListResponse> SearchAsync(string status, string category, string caseId, string userId, int skip, int limit)
{
var filter = Builders<CcsCaseReport>.Filter.Empty;
if (status != "all") filter &= Builders<CcsCaseReport>.Filter.Eq(value => value.Status, status);
if (category.Length > 0) filter &= Builders<CcsCaseReport>.Filter.Eq(value => value.Category, category);
if (caseId.Length > 0) filter &= Builders<CcsCaseReport>.Filter.Eq(value => value.CaseExternalId, caseId);
if (userId.Length > 0) filter &= Builders<CcsCaseReport>.Filter.Eq(value => value.UserId, userId);
var reports = await _reports.Find(filter).SortByDescending(value => value.CreatedUtc).Skip(skip).Limit(limit).ToListAsync();
return new CcsCaseReportListResponse {
Items = reports.Select(value => new CcsCaseReportSummary {
Id = value.Id ?? "", CaseExternalId = value.CaseExternalId, CaseVersion = value.CaseVersion,
AttemptId = value.AttemptId, UserId = value.UserId, Category = value.Category,
Details = value.Details, Status = value.Status, CreatedUtc = value.CreatedUtc
}).ToList(),
Total = await _reports.CountDocumentsAsync(filter), Skip = skip, Limit = limit
};
}
public async Task<CcsCaseReport?> CloseAsync(string id, string status, string reviewer, string note)
{
if (!ObjectId.TryParse(id, out _)) return null;
var now = DateTime.UtcNow;
return await _reports.FindOneAndUpdateAsync(
value => value.Id == id && value.Status == QuestionReportStatus.Open,
Builders<CcsCaseReport>.Update.Set(value => value.Status, status).Set(value => value.ReviewerNote, note)
.Set(value => value.ReviewedByUserId, reviewer).Set(value => value.ReviewedUtc, now).Set(value => value.UpdatedUtc, now),
new FindOneAndUpdateOptions<CcsCaseReport> { ReturnDocument = ReturnDocument.After });
}
}
+1 -1
View File
@@ -378,7 +378,7 @@ public sealed class CcsStore
};
}
private async Task<CcsCaseRevision> DefinitionAsync(CcsAttempt attempt) =>
public async Task<CcsCaseRevision> DefinitionAsync(CcsAttempt attempt) =>
await _cases.Find(value => value.Id == attempt.CaseRevisionId).FirstOrDefaultAsync()
?? throw new InvalidOperationException("The case revision is unavailable.");
+61 -1
View File
@@ -8,8 +8,14 @@ signal attempt_changed
signal orders_changed
signal history_changed
signal prediction_changed
signal report_submitted(report_id: String)
signal report_failed(message: String)
signal request_failed(message: String)
const TEXT_SCALE_MIN := 0.85
const TEXT_SCALE_MAX := 1.35
const TEXT_SCALE_STEP := 0.1
var api_url := DEFAULT_API_URL
var request: HTTPRequest
var cases: Array = []
@@ -20,6 +26,8 @@ var prediction := {}
var is_loading := false
var pending_action := ""
var queue: Array = []
var text_scale := 1.0
var highlights := {}
func _ready() -> void:
@@ -27,6 +35,7 @@ func _ready() -> void:
request = HTTPRequest.new()
request.request_completed.connect(_on_request_completed)
add_child(request)
AuthState.auth_changed.connect(_on_auth_changed)
func load_cases() -> void:
@@ -69,6 +78,44 @@ func load_prediction() -> void:
_send("prediction", HTTPClient.METHOD_GET, "/prediction")
func submit_report(attempt_id: String, category: String, details: String) -> void:
_send("report", HTTPClient.METHOD_POST, "/reports", {"attemptId": attempt_id, "category": category, "details": details})
func adjust_text_scale(delta: float) -> void:
text_scale = clampf(text_scale + delta, TEXT_SCALE_MIN, TEXT_SCALE_MAX)
func toggle_highlight(attempt_id: String, region_id: String, start: int, end: int) -> void:
var key := "%s:%s" % [attempt_id, region_id]
var ranges: Array = highlights.get(key, [])
var removed := false
for index in range(ranges.size() - 1, -1, -1):
var item: Dictionary = ranges[index]
if start < int(item.get("end", 0)) and end > int(item.get("start", 0)):
ranges.remove_at(index); removed = true
if not removed: ranges.append({"start": start, "end": end})
highlights[key] = ranges
func clear_highlights(attempt_id: String) -> void:
for key in highlights.keys():
if str(key).begins_with("%s:" % attempt_id): highlights.erase(key)
func highlighted_text(attempt_id: String, region_id: String, plain_text: String) -> String:
var ranges: Array = highlights.get("%s:%s" % [attempt_id, region_id], []).duplicate()
ranges.sort_custom(func(a: Dictionary, b: Dictionary): return int(a.get("start", 0)) < int(b.get("start", 0)))
var result := ""; var cursor := 0
for item in ranges:
var start := clampi(int(item.get("start", 0)), cursor, plain_text.length())
var end := clampi(int(item.get("end", 0)), start, plain_text.length())
result += _escape_bbcode(plain_text.substr(cursor, start - cursor))
result += "[bgcolor=yellow][color=black]%s[/color][/bgcolor]" % _escape_bbcode(plain_text.substr(start, end - start))
cursor = end
return result + _escape_bbcode(plain_text.substr(cursor))
func abandon() -> void:
if attempt.is_empty():
return
@@ -107,7 +154,9 @@ func _on_request_completed(result: int, response_code: int, _headers: PackedStri
call_deferred("_process_queue")
if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300:
var detail := body.get_string_from_utf8().strip_edges().left(240)
request_failed.emit("CCS request failed (%d). %s" % [response_code, detail])
var message := "CCS request failed (%d). %s" % [response_code, detail]
if action == "report": report_failed.emit(message)
else: request_failed.emit(message)
return
if response_code == 204:
attempt = {}
@@ -125,6 +174,7 @@ func _on_request_completed(result: int, response_code: int, _headers: PackedStri
"orders": orders = parsed; orders_changed.emit()
"history": history = parsed; history_changed.emit()
"prediction": prediction = parsed; prediction_changed.emit()
"report": report_submitted.emit(str(parsed.get("id", "")))
_:
attempt = parsed
attempt_changed.emit()
@@ -136,3 +186,13 @@ func _process_queue() -> void:
var queued: Dictionary = queue.pop_front()
var suffix := str(queued.get("url", "")).trim_prefix(api_url.rstrip("/"))
_send(str(queued.get("action", "")), int(queued.get("method", HTTPClient.METHOD_GET)), suffix, queued.get("payload", {}))
func _escape_bbcode(value: String) -> String:
return value.replace("[", "[lb]").replace("]", "[rb]")
func _on_auth_changed() -> void:
if not AuthState.is_logged_in:
highlights.clear()
text_scale = 1.0
+21 -17
View File
@@ -2,43 +2,47 @@ extends VBoxContainer
const Style := preload("res://scripts/ui/ui_style.gd")
var main: Control
var list: VBoxContainer
var status: Label
var start_button: Button
func _ready() -> void:
set_anchors_preset(Control.PRESET_FULL_RECT)
add_theme_constant_override("separation", 14)
var back := Button.new(); back.text = "< All exams"; back.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN
Style.style_chip(back, Style.CYAN); back.pressed.connect(func(): main.show_screen("selection", "exam")); add_child(back)
var title := Label.new(); title.text = "CCS Case Practice"; Style.set_label(title, 34, Style.TEXT); add_child(title)
var back := Button.new(); back.text = "< Step 3 modes"; back.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN
Style.style_chip(back, Style.CYAN); back.pressed.connect(func(): main.show_screen("selection", "step3")); add_child(back)
var title := Label.new(); title.text = "CCS Cases"; Style.set_label(title, 34, Style.TEXT); add_child(title)
var note := Label.new(); note.text = "Manage a simulated patient through examination, orders, treatment, location, and time."; Style.set_label(note, 16, Style.MUTED); add_child(note)
status = Label.new(); Style.set_label(status, 15, Style.CYAN); add_child(status)
var scroll := ScrollContainer.new(); scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL; add_child(scroll)
list = VBoxContainer.new(); list.size_flags_horizontal = Control.SIZE_EXPAND_FILL; list.add_theme_constant_override("separation", 10); scroll.add_child(list)
start_button = Button.new()
start_button.text = "Start Timed Case"
start_button.custom_minimum_size = Vector2(280, 64)
start_button.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN
Style.style_primary_button(start_button)
start_button.pressed.connect(_start)
add_child(start_button)
var spacer := Control.new(); spacer.size_flags_vertical = Control.SIZE_EXPAND_FILL; add_child(spacer)
var history := Button.new(); history.text = "Case History"; history.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN
Style.style_chip(history, Style.CYAN); history.pressed.connect(func(): main.show_screen("ccs_history")); add_child(history)
CCSService.cases_changed.connect(_render)
CCSService.attempt_changed.connect(_attempt_ready)
CCSService.request_failed.connect(func(message: String): status.text = message)
CCSService.request_failed.connect(_on_request_failed)
func on_show(_payload: Variant = null) -> void:
status.text = "Loading cases..."
CCSService.load_cases()
func _render() -> void:
for child in list.get_children(): child.queue_free()
status.text = "No published cases are available yet." if CCSService.cases.is_empty() else "%d cases available" % CCSService.cases.size()
for case in CCSService.cases:
var panel := PanelContainer.new(); panel.add_theme_stylebox_override("panel", Style.panel_box(Style.SURFACE_DARK, Color("405475"))); list.add_child(panel)
var row := HBoxContainer.new(); row.add_theme_constant_override("separation", 12); panel.add_child(row)
var details := Label.new(); details.text = "%s\n%s | %d-minute case" % [case.get("title", "Untitled case"), case.get("system", ""), case.get("realTimeMinutes", 20)]
details.size_flags_horizontal = Control.SIZE_EXPAND_FILL; Style.set_label(details, 18, Style.TEXT); row.add_child(details)
var practice := Button.new(); practice.text = "Practice"; Style.style_chip(practice, Style.CYAN); practice.pressed.connect(_start.bind(str(case.get("externalId", "")), "practice")); row.add_child(practice)
var timed := Button.new(); timed.text = "Timed"; Style.style_primary_button(timed); timed.pressed.connect(_start.bind(str(case.get("externalId", "")), "timed")); row.add_child(timed)
start_button.disabled = CCSService.cases.is_empty()
func _start(case_id: String, mode: String) -> void:
func _start() -> void:
status.text = "Starting case..."
CCSService.create_attempt(case_id, mode)
start_button.disabled = true
CCSService.create_attempt("", "timed")
func _attempt_ready() -> void:
if visible and not CCSService.attempt.is_empty(): main.show_screen("ccs_player")
func _on_request_failed(message: String) -> void:
status.text = message
start_button.disabled = CCSService.cases.is_empty()
+68 -9
View File
@@ -14,17 +14,24 @@ var status: Label
var timer: Timer
var end_button: Button
var time_row: HBoxContainer
var report_dialog: Control
var report_button: Button
var workspace: HSplitContainer
func _ready() -> void:
set_anchors_preset(Control.PRESET_FULL_RECT); add_theme_constant_override("separation", 8)
var top := HBoxContainer.new(); add_child(top)
var top := HBoxContainer.new(); top.add_theme_constant_override("separation", 8); add_child(top)
heading = Label.new(); heading.size_flags_horizontal = Control.SIZE_EXPAND_FILL; Style.set_label(heading, 25, Style.TEXT); top.add_child(heading)
clock = Label.new(); Style.set_label(clock, 18, Style.AMBER); top.add_child(clock)
var columns := HSplitContainer.new(); columns.size_flags_vertical = Control.SIZE_EXPAND_FILL; columns.split_offset = 850; add_child(columns)
var chart_panel := VBoxContainer.new(); chart_panel.custom_minimum_size.x = 500; columns.add_child(chart_panel)
summary = RichTextLabel.new(); summary.bbcode_enabled = true; summary.custom_minimum_size.y = 160; chart_panel.add_child(summary)
chart = RichTextLabel.new(); chart.bbcode_enabled = true; chart.size_flags_vertical = Control.SIZE_EXPAND_FILL; chart_panel.add_child(chart)
var clerk_scroll := ScrollContainer.new(); clerk_scroll.custom_minimum_size.x = 430; columns.add_child(clerk_scroll)
var smaller := Button.new(); smaller.text = "A-"; smaller.tooltip_text = "Decrease text size"; smaller.pressed.connect(_zoom.bind(-CCSService.TEXT_SCALE_STEP)); top.add_child(smaller)
var larger := Button.new(); larger.text = "A+"; larger.tooltip_text = "Increase text size"; larger.pressed.connect(_zoom.bind(CCSService.TEXT_SCALE_STEP)); top.add_child(larger)
var clear_highlights := Button.new(); clear_highlights.text = "Clear"; clear_highlights.tooltip_text = "Clear highlights"; clear_highlights.pressed.connect(_clear_highlights); top.add_child(clear_highlights)
report_button = Button.new(); report_button.text = "Report"; report_button.tooltip_text = "Report CCS case"; report_button.pressed.connect(_report); top.add_child(report_button)
workspace = HSplitContainer.new(); workspace.size_flags_vertical = Control.SIZE_EXPAND_FILL; workspace.split_offset = 850; add_child(workspace)
var chart_panel := VBoxContainer.new(); chart_panel.custom_minimum_size.x = 500; workspace.add_child(chart_panel)
summary = RichTextLabel.new(); summary.bbcode_enabled = true; summary.selection_enabled = true; summary.custom_minimum_size.y = 160; summary.set_meta("ccs_region", "summary"); summary.gui_input.connect(_text_input.bind(summary)); chart_panel.add_child(summary)
chart = RichTextLabel.new(); chart.bbcode_enabled = true; chart.selection_enabled = true; chart.size_flags_vertical = Control.SIZE_EXPAND_FILL; chart.set_meta("ccs_region", "chart"); chart.gui_input.connect(_text_input.bind(chart)); chart_panel.add_child(chart)
var clerk_scroll := ScrollContainer.new(); clerk_scroll.custom_minimum_size.x = 430; workspace.add_child(clerk_scroll)
var clerk := VBoxContainer.new(); clerk.size_flags_horizontal = Control.SIZE_EXPAND_FILL; clerk.add_theme_constant_override("separation", 8); clerk_scroll.add_child(clerk)
var order_title := Label.new(); order_title.text = "Order Clerk"; Style.set_label(order_title, 21, Style.CYAN); clerk.add_child(order_title)
search = LineEdit.new(); search.placeholder_text = "Search tests, treatments, and procedures"; search.text_submitted.connect(func(_value): CCSService.search_orders(search.text)); clerk.add_child(search)
@@ -45,6 +52,8 @@ func _ready() -> void:
CCSService.attempt_changed.connect(_render)
CCSService.orders_changed.connect(_render_search)
CCSService.request_failed.connect(func(message: String): status.text = message)
CCSService.report_submitted.connect(_report_submitted)
report_dialog = preload("res://scripts/ui/ccs_report_dialog.gd").new(); add_child(report_dialog)
func on_show(payload: Variant = null) -> void:
status.text = ""
@@ -57,9 +66,8 @@ func _render() -> void:
var attempt: Dictionary = CCSService.attempt
if attempt.is_empty(): return
if str(attempt.get("state", "")) == "completed": main.show_screen("ccs_review", attempt); return
heading.text = "%s | %s" % [attempt.get("caseTitle", "CCS case"), str(attempt.get("location", "")).capitalize()]
summary.text = "[b]%s[/b]\n%s\n\n[b]Vitals[/b] %s" % [attempt.get("patientSummary", ""), attempt.get("reasonForEncounter", ""), _vitals_text(attempt.get("vitals", {}))]
chart.text = "[b]History[/b]\n%s\n\n[b]Appearance[/b]\n%s\n\n[b]Chart[/b]\n%s" % [attempt.get("history", ""), attempt.get("appearance", ""), _chart_text(attempt.get("chart", []))]
heading.text = "CCS Case | %s" % str(attempt.get("location", "")).capitalize()
_render_study_text()
_clear(orders)
for code in attempt.get("activeOrderCodes", []):
var row := HBoxContainer.new(); orders.add_child(row)
@@ -74,12 +82,14 @@ func _render() -> void:
end_button.text = "Submit final orders" if str(attempt.get("state", "")) == "final_orders" else "End case"
time_row.visible = str(attempt.get("state", "")) == "active"
if str(attempt.get("state", "")) == "final_orders": status.text = "Case-end instructions: finalize or cancel orders, then submit."
_apply_text_scale()
func _render_search() -> void:
_clear(search_results)
for order in CCSService.orders:
var button := Button.new(); button.text = "%s (%s)" % [order.get("canonicalName", ""), order.get("category", "")]; button.alignment = HORIZONTAL_ALIGNMENT_LEFT
button.pressed.connect(_place_order.bind(str(order.get("code", "")))); search_results.add_child(button)
_scale_controls(search_results)
func _place_order(code: String) -> void:
CCSService.send_command("order", code); search.text = ""; CCSService.orders = []; _render_search()
@@ -109,3 +119,52 @@ func _chart_text(entries: Array) -> String:
var lines: Array[String] = []
for entry in entries: lines.append("T+%d [%s] %s" % [entry.get("simulatedMinute", 0), entry.get("category", "note"), entry.get("text", "")])
return "\n\n".join(lines)
func _summary_plain() -> String:
var attempt := CCSService.attempt
return "%s\n%s\n\nVitals %s" % [attempt.get("patientSummary", ""), attempt.get("reasonForEncounter", ""), _vitals_text(attempt.get("vitals", {}))]
func _chart_plain() -> String:
var attempt := CCSService.attempt
return "History\n%s\n\nAppearance\n%s\n\nChart\n%s" % [attempt.get("history", ""), attempt.get("appearance", ""), _chart_text(attempt.get("chart", []))]
func _render_study_text() -> void:
var id := str(CCSService.attempt.get("id", ""))
summary.text = CCSService.highlighted_text(id, "summary", _summary_plain())
chart.text = CCSService.highlighted_text(id, "chart", _chart_plain())
func _text_input(event: InputEvent, label: RichTextLabel) -> void:
var mouse := event as InputEventMouseButton
if mouse != null and mouse.button_index == MOUSE_BUTTON_LEFT and not mouse.pressed:
call_deferred("_commit_selection", label)
func _commit_selection(label: RichTextLabel) -> void:
var selected := label.get_selected_text().strip_edges()
if selected.is_empty(): return
var start := label.get_selection_from(); var end := label.get_selection_to()
CCSService.toggle_highlight(str(CCSService.attempt.get("id", "")), str(label.get_meta("ccs_region", "")), mini(start, end), maxi(start, end))
label.deselect(); _render_study_text()
func _zoom(delta: float) -> void:
CCSService.adjust_text_scale(delta); _apply_text_scale()
func _apply_text_scale() -> void:
var size := int(round(18.0 * CCSService.text_scale))
for label in [summary, chart]:
label.add_theme_font_size_override("normal_font_size", size); label.add_theme_font_size_override("bold_font_size", size)
_scale_controls(workspace)
func _scale_controls(node: Node) -> void:
if node is Label or node is Button or node is LineEdit:
if not node.has_meta("ccs_base_font_size"): node.set_meta("ccs_base_font_size", node.get_theme_font_size("font_size"))
node.add_theme_font_size_override("font_size", int(round(float(node.get_meta("ccs_base_font_size")) * CCSService.text_scale)))
for child in node.get_children(): _scale_controls(child)
func _clear_highlights() -> void:
CCSService.clear_highlights(str(CCSService.attempt.get("id", ""))); _render_study_text()
func _report() -> void:
report_dialog.show_for(str(CCSService.attempt.get("id", "")))
func _report_submitted(_id: String) -> void:
Style.style_chip(report_button, Style.RED); report_button.tooltip_text = "Report submitted; report another issue"
+76
View File
@@ -0,0 +1,76 @@
extends Control
const Style := preload("res://scripts/ui/ui_style.gd")
const CATEGORIES := [
{"id":"clarity", "label":"Clarity"}, {"id":"medical_accuracy", "label":"Medical accuracy"},
{"id":"case_progression_scoring", "label":"Case progression / scoring"}, {"id":"formatting_media", "label":"Formatting / media"},
{"id":"technical_issue", "label":"Technical issue"}, {"id":"other", "label":"Other"}
]
var group: ButtonGroup
var details: TextEdit
var count: Label
var error: Label
var submit: Button
var form: VBoxContainer
var success: VBoxContainer
var attempt_id := ""
var panel: PanelContainer
func _ready() -> void:
visible = false; top_level = true; mouse_filter = Control.MOUSE_FILTER_STOP; set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
var dim := ColorRect.new(); dim.color = Color(0,0,0,0.7); dim.set_anchors_preset(Control.PRESET_FULL_RECT); add_child(dim)
var center := CenterContainer.new(); center.set_anchors_preset(Control.PRESET_FULL_RECT); add_child(center)
panel = PanelContainer.new(); panel.custom_minimum_size = Vector2(680, 560); panel.add_theme_stylebox_override("panel", Style.panel_box(Style.SURFACE_DARK, Style.RED)); center.add_child(panel)
var root := VBoxContainer.new(); root.add_theme_constant_override("separation", 12); panel.add_child(root)
var title := Label.new(); title.text = "Report CCS case"; Style.set_label(title, 25, Style.TEXT); root.add_child(title)
form = VBoxContainer.new(); form.add_theme_constant_override("separation", 10); root.add_child(form)
group = ButtonGroup.new(); group.allow_unpress = false
var grid := GridContainer.new(); grid.columns = 2; form.add_child(grid)
for category in CATEGORIES:
var option := CheckBox.new(); option.text = category.label; option.set_meta("category", category.id); option.button_group = group; option.toggled.connect(func(_value): _refresh()); grid.add_child(option)
var header := HBoxContainer.new(); form.add_child(header)
var prompt := Label.new(); prompt.text = "Details"; prompt.size_flags_horizontal = Control.SIZE_EXPAND_FILL; header.add_child(prompt)
count = Label.new(); header.add_child(count)
details = TextEdit.new(); details.placeholder_text = "Describe what should be reviewed."; details.custom_minimum_size = Vector2(0, 190); details.wrap_mode = TextEdit.LINE_WRAPPING_BOUNDARY; details.text_changed.connect(_refresh); form.add_child(details)
error = Label.new(); Style.set_label(error, 14, Style.RED); form.add_child(error)
var actions := HBoxContainer.new(); form.add_child(actions)
var cancel := Button.new(); cancel.text = "Cancel"; cancel.pressed.connect(hide_dialog); actions.add_child(cancel)
submit = Button.new(); submit.text = "Submit report"; submit.size_flags_horizontal = Control.SIZE_EXPAND_FILL; Style.style_primary_button(submit); submit.pressed.connect(_submit); actions.add_child(submit)
success = VBoxContainer.new(); success.visible = false; root.add_child(success)
var success_label := Label.new(); success_label.text = "Report submitted\nThank you for helping improve this case."; success_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER; Style.set_label(success_label, 22, Style.LIME); success.add_child(success_label)
var close := Button.new(); close.text = "Close"; close.pressed.connect(hide_dialog); success.add_child(close)
CCSService.report_submitted.connect(_submitted); CCSService.report_failed.connect(_failed)
resized.connect(_refresh_size)
_refresh()
_refresh_size()
func show_for(value: String) -> void:
attempt_id = value; details.text = ""; error.text = ""; form.visible = true; success.visible = false
for button in group.get_buttons(): button.button_pressed = false
visible = true; move_to_front(); _refresh()
func hide_dialog() -> void:
visible = false
func _unhandled_input(event: InputEvent) -> void:
if visible and event.is_action_pressed("ui_cancel"): hide_dialog(); get_viewport().set_input_as_handled()
func _refresh_size() -> void:
if panel != null: panel.custom_minimum_size = Vector2(minf(680.0, maxf(300.0, size.x - 32.0)), minf(560.0, maxf(420.0, size.y - 32.0)))
func _refresh() -> void:
var length := details.text.strip_edges().length() if details != null else 0
if count != null: count.text = "%d / 2000" % length
if submit != null: submit.disabled = group.get_pressed_button() == null or length < 10 or length > 2000
func _submit() -> void:
var selected := group.get_pressed_button();
if selected == null: return
submit.disabled = true; submit.text = "Submitting..."
CCSService.submit_report(attempt_id, str(selected.get_meta("category", "")), details.text.strip_edges())
func _submitted(_id: String) -> void:
submit.text = "Submit report"; form.visible = false; success.visible = true
func _failed(message: String) -> void:
submit.text = "Submit report"; error.text = message; _refresh()
+1
View File
@@ -0,0 +1 @@
uid://bvabwfcabwpkh
+74 -24
View File
@@ -3,36 +3,86 @@ extends VBoxContainer
const Style := preload("res://scripts/ui/ui_style.gd")
var main: Control
var title: Label
var body: RichTextLabel
var score_body: RichTextLabel
var timeline_body: RichTextLabel
var references: VBoxContainer
var report_dialog: Control
var report_button: Button
var current_attempt := {}
func _ready() -> void:
set_anchors_preset(Control.PRESET_FULL_RECT); add_theme_constant_override("separation", 12)
title = Label.new(); Style.set_label(title, 32, Style.TEXT); add_child(title)
body = RichTextLabel.new(); body.bbcode_enabled = true; body.fit_content = false; body.size_flags_vertical = Control.SIZE_EXPAND_FILL
body.meta_clicked.connect(func(meta): OS.shell_open(str(meta))); add_child(body)
CCSService.attempt_changed.connect(_refresh_loaded_attempt)
set_anchors_preset(Control.PRESET_FULL_RECT); add_theme_constant_override("separation", 10)
var toolbar := HBoxContainer.new(); toolbar.add_theme_constant_override("separation", 8); add_child(toolbar)
title = Label.new(); title.size_flags_horizontal = Control.SIZE_EXPAND_FILL; Style.set_label(title, 30, Style.TEXT); toolbar.add_child(title)
var smaller := Button.new(); smaller.text = "A-"; smaller.tooltip_text = "Decrease text size"; smaller.pressed.connect(_zoom.bind(-CCSService.TEXT_SCALE_STEP)); toolbar.add_child(smaller)
var larger := Button.new(); larger.text = "A+"; larger.tooltip_text = "Increase text size"; larger.pressed.connect(_zoom.bind(CCSService.TEXT_SCALE_STEP)); toolbar.add_child(larger)
var clear := Button.new(); clear.text = "Clear"; clear.tooltip_text = "Clear highlights"; clear.pressed.connect(_clear_highlights); toolbar.add_child(clear)
report_button = Button.new(); report_button.text = "Report"; report_button.tooltip_text = "Report CCS case"; report_button.pressed.connect(_report); toolbar.add_child(report_button)
var scroll := ScrollContainer.new(); scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL; add_child(scroll)
var content := VBoxContainer.new(); content.size_flags_horizontal = Control.SIZE_EXPAND_FILL; content.add_theme_constant_override("separation", 14); scroll.add_child(content)
score_body = _study_label("score"); score_body.custom_minimum_size.y = 200; content.add_child(score_body)
timeline_body = _study_label("timeline"); timeline_body.custom_minimum_size.y = 260; content.add_child(timeline_body)
references = VBoxContainer.new(); content.add_child(references)
CCSService.attempt_changed.connect(_refresh_loaded_attempt); CCSService.report_submitted.connect(_report_submitted)
report_dialog = preload("res://scripts/ui/ccs_report_dialog.gd").new(); add_child(report_dialog)
var row := HBoxContainer.new(); add_child(row)
var cases := Button.new(); cases.text = "More cases"; Style.style_primary_button(cases); cases.pressed.connect(func(): main.show_screen("ccs_hub")); row.add_child(cases)
var history := Button.new(); history.text = "Case history"; history.pressed.connect(func(): main.show_screen("ccs_history")); row.add_child(history)
func on_show(payload: Variant = null) -> void:
if typeof(payload) == TYPE_STRING:
CCSService.load_attempt(str(payload))
return
var attempt: Dictionary = payload if typeof(payload) == TYPE_DICTIONARY else CCSService.attempt
if attempt.is_empty(): return
var score: Dictionary = attempt.get("score", {})
title.text = "%s: %d%%" % [attempt.get("caseTitle", "Case review"), int(round(float(score.get("percent", 0))))]
var text := "[b]Scoring review[/b]\n"
for criterion in score.get("criteria", []):
var status := str(criterion.get("status", "missed"))
text += "\n[color=%s][b]%s %s%d[/b][/color]\n%s\n" % [Style.LIME.to_html() if status in ["credited", "avoided"] else Style.RED.to_html(), status.capitalize(), "+" if int(criterion.get("points", 0)) >= 0 else "", criterion.get("points", 0), criterion.get("rationale", "")]
text += "\n[b]Case timeline[/b]\n"
for entry in attempt.get("chart", []): text += "\nT+%d [%s] %s" % [entry.get("simulatedMinute", 0), entry.get("category", "note"), entry.get("text", "")]
if not attempt.get("references", []).is_empty():
text += "\n\n[b]References[/b]\n"
for reference in attempt.get("references", []): text += "\n[url=%s]%s[/url]" % [reference.get("url", ""), reference.get("title", reference.get("url", "Reference"))]
body.text = text
func _study_label(region: String) -> RichTextLabel:
var label := RichTextLabel.new(); label.bbcode_enabled = true; label.fit_content = true; label.selection_enabled = true; label.set_meta("ccs_region", region); label.gui_input.connect(_text_input.bind(label)); return label
func on_show(payload: Variant = null) -> void:
if typeof(payload) == TYPE_STRING: CCSService.load_attempt(str(payload)); return
current_attempt = payload if typeof(payload) == TYPE_DICTIONARY else CCSService.attempt
if current_attempt.is_empty(): return
var score: Dictionary = current_attempt.get("score", {})
title.text = "%s: %d%%" % [current_attempt.get("caseTitle", "Case review"), int(round(float(score.get("percent", 0))))]
_render_text(); _render_references(); _apply_text_scale()
func _score_plain() -> String:
var lines: Array[String] = ["Scoring review"]
for criterion in current_attempt.get("score", {}).get("criteria", []):
lines.append("%s %s%d\n%s" % [str(criterion.get("status", "missed")).capitalize(), "+" if int(criterion.get("points", 0)) >= 0 else "", criterion.get("points", 0), criterion.get("rationale", "")])
return "\n\n".join(lines)
func _timeline_plain() -> String:
var lines: Array[String] = ["Case timeline"]
for entry in current_attempt.get("chart", []): lines.append("T+%d [%s] %s" % [entry.get("simulatedMinute", 0), entry.get("category", "note"), entry.get("text", "")])
return "\n\n".join(lines)
func _render_text() -> void:
var id := str(current_attempt.get("id", ""))
score_body.text = CCSService.highlighted_text(id, "score", _score_plain())
timeline_body.text = CCSService.highlighted_text(id, "timeline", _timeline_plain())
func _render_references() -> void:
for child in references.get_children(): child.queue_free()
if current_attempt.get("references", []).is_empty(): return
var heading := Label.new(); heading.text = "References"; Style.set_label(heading, int(round(19.0 * CCSService.text_scale)), Style.TEXT); references.add_child(heading)
for reference in current_attempt.get("references", []):
var link := LinkButton.new(); link.text = str(reference.get("title", reference.get("url", "Reference"))); link.pressed.connect(OS.shell_open.bind(str(reference.get("url", "")))); link.add_theme_font_size_override("font_size", int(round(16.0 * CCSService.text_scale))); references.add_child(link)
func _text_input(event: InputEvent, label: RichTextLabel) -> void:
var mouse := event as InputEventMouseButton
if mouse != null and mouse.button_index == MOUSE_BUTTON_LEFT and not mouse.pressed: call_deferred("_commit_selection", label)
func _commit_selection(label: RichTextLabel) -> void:
if label.get_selected_text().strip_edges().is_empty(): return
var start := label.get_selection_from(); var end := label.get_selection_to()
CCSService.toggle_highlight(str(current_attempt.get("id", "")), str(label.get_meta("ccs_region", "")), mini(start,end), maxi(start,end)); label.deselect(); _render_text()
func _zoom(delta: float) -> void:
CCSService.adjust_text_scale(delta); _apply_text_scale(); _render_references()
func _apply_text_scale() -> void:
var size := int(round(18.0 * CCSService.text_scale))
for label in [score_body, timeline_body]: label.add_theme_font_size_override("normal_font_size", size); label.add_theme_font_size_override("bold_font_size", size)
func _clear_highlights() -> void:
CCSService.clear_highlights(str(current_attempt.get("id", ""))); _render_text()
func _report() -> void: report_dialog.show_for(str(current_attempt.get("id", "")))
func _report_submitted(_id: String) -> void: Style.style_chip(report_button, Style.RED); report_button.tooltip_text = "Report submitted; report another issue"
func _refresh_loaded_attempt() -> void:
if visible and str(CCSService.attempt.get("state", "")) == "completed": on_show(CCSService.attempt)
+49 -4
View File
@@ -14,7 +14,10 @@ var retry_button: Button
var simulation_button: Button
var simulation_status_label: Label
var simulation_preview_link: LinkButton
var ccs_button: Button
var ccs_status_label: Label
var pending_simulation_launch := false
var pending_ccs_launch := false
var current_stage := "exam"
var pending_question_bank_launch := false
@@ -35,6 +38,10 @@ func _ready() -> void:
ExamSimulationService.request_failed.connect(_on_simulation_failed)
if not AuthState.auth_changed.is_connected(_refresh_simulation_preview_visibility):
AuthState.auth_changed.connect(_refresh_simulation_preview_visibility)
if not CCSService.attempt_changed.is_connected(_on_ccs_attempt_changed):
CCSService.attempt_changed.connect(_on_ccs_attempt_changed)
if not CCSService.request_failed.is_connected(_on_ccs_failed):
CCSService.request_failed.connect(_on_ccs_failed)
_build_exam_view()
_build_mode_view()
@@ -53,6 +60,7 @@ func on_show(payload: Variant = null) -> void:
func on_hide() -> void:
pending_question_bank_launch = false
pending_ccs_launch = false
func _build_exam_view() -> void:
@@ -88,7 +96,6 @@ func _build_exam_view() -> void:
_add_exam_option("STEP 1", "Foundational sciences", false)
_add_exam_option("STEP 2", "Clinical knowledge", false)
_add_exam_option("STEP 3", "Advanced clinical management", true)
_add_exam_option("CCS Cases", "Interactive case simulations", true, "ccs")
func _build_mode_view() -> void:
@@ -159,6 +166,21 @@ func _build_mode_view() -> void:
simulation_button.pressed.connect(_select_simulation)
mode_grid.add_child(simulation_button)
ccs_button = Button.new()
ccs_button.text = "CCS Cases\nInteractive case simulations | Available"
ccs_button.alignment = HORIZONTAL_ALIGNMENT_LEFT
ccs_button.custom_minimum_size = Vector2(360, 150)
ccs_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
ccs_button.add_theme_font_size_override("font_size", 20)
Style.style_primary_button(ccs_button)
ccs_button.pressed.connect(_select_ccs)
mode_grid.add_child(ccs_button)
ccs_status_label = Label.new()
ccs_status_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
Style.set_label(ccs_status_label, 15, Style.MUTED)
mode_view.add_child(ccs_status_label)
simulation_status_label = Label.new()
simulation_status_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
Style.set_label(simulation_status_label, 15, Style.MUTED)
@@ -197,7 +219,7 @@ func _build_mode_view() -> void:
mode_view.add_child(spacer)
func _add_exam_option(option_name: String, description: String, available: bool, destination: String = "step3") -> void:
func _add_exam_option(option_name: String, description: String, available: bool) -> void:
var button := Button.new()
button.text = "%s\n%s | %s" % [option_name, description, "Available" if available else "Coming Soon"]
button.alignment = HORIZONTAL_ALIGNMENT_LEFT
@@ -208,8 +230,7 @@ func _add_exam_option(option_name: String, description: String, available: bool,
button.disabled = not available
if available:
Style.style_primary_button(button)
if destination == "ccs": button.pressed.connect(func(): main.show_screen("ccs_hub"))
else: button.pressed.connect(func(): _show_stage("step3"))
button.pressed.connect(func(): _show_stage("step3"))
exam_grid.add_child(button)
@@ -231,6 +252,30 @@ func _select_question_bank() -> void:
_refresh_bank_state()
func _select_ccs() -> void:
pending_ccs_launch = true
ccs_button.disabled = true
ccs_status_label.text = "Selecting a timed case..."
CCSService.create_attempt("", "timed")
func _on_ccs_attempt_changed() -> void:
if not pending_ccs_launch or CCSService.attempt.is_empty():
return
pending_ccs_launch = false
ccs_button.disabled = false
ccs_status_label.text = ""
main.show_screen("ccs_player")
func _on_ccs_failed(message: String) -> void:
if not pending_ccs_launch:
return
pending_ccs_launch = false
ccs_button.disabled = false
ccs_status_label.text = message
func _retry_question_bank() -> void:
pending_question_bank_launch = true
QuestionBankService.retry_now()