diff --git a/api/QuestionsApi.Tests/CcsCaseTests.cs b/api/QuestionsApi.Tests/CcsCaseTests.cs index 16e4836..c3da3c2 100644 --- a/api/QuestionsApi.Tests/CcsCaseTests.cs +++ b/api/QuestionsApi.Tests/CcsCaseTests.cs @@ -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() { diff --git a/api/QuestionsApi/Controllers/CcsCaseReportsController.cs b/api/QuestionsApi/Controllers/CcsCaseReportsController.cs new file mode 100644 index 0000000..9e95bec --- /dev/null +++ b/api/QuestionsApi/Controllers/CcsCaseReportsController.cs @@ -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 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 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 Get(string id) => await reports.GetAsync(id.Trim()) is { } report ? Ok(report) : NotFound(); + + [HttpPatch("{id}/status"), Authorize(Roles = "SUPER")] + public async Task 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 ?? ""; +} diff --git a/api/QuestionsApi/Models/CcsCaseReport.cs b/api/QuestionsApi/Models/CcsCaseReport.cs new file mode 100644 index 0000000..c75cbca --- /dev/null +++ b/api/QuestionsApi/Models/CcsCaseReport.cs @@ -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 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 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; } +} diff --git a/api/QuestionsApi/Program.cs b/api/QuestionsApi/Program.cs index cd8ec91..e467010 100644 --- a/api/QuestionsApi/Program.cs +++ b/api/QuestionsApi/Program.cs @@ -18,6 +18,7 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddHostedService(); diff --git a/api/QuestionsApi/Services/CcsCaseReportStore.cs b/api/QuestionsApi/Services/CcsCaseReportStore.cs new file mode 100644 index 0000000..e723731 --- /dev/null +++ b/api/QuestionsApi/Services/CcsCaseReportStore.cs @@ -0,0 +1,67 @@ +using MongoDB.Bson; +using MongoDB.Driver; +using QuestionsApi.Models; + +namespace QuestionsApi.Services; + +public sealed class CcsCaseReportStore +{ + private readonly IMongoCollection _reports; + + public CcsCaseReportStore(MongoDatabaseProvider mongo) + { + _reports = mongo.Database.GetCollection("CcsCaseReports"); + _reports.Indexes.CreateMany([ + new CreateIndexModel(Builders.IndexKeys.Ascending(value => value.Status).Descending(value => value.CreatedUtc)), + new CreateIndexModel(Builders.IndexKeys.Ascending(value => value.CaseExternalId)), + new CreateIndexModel(Builders.IndexKeys.Ascending(value => value.AttemptId)), + new CreateIndexModel(Builders.IndexKeys.Ascending(value => value.UserId)) + ]); + } + + public async Task 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 GetAsync(string id) => ObjectId.TryParse(id, out _) + ? await _reports.Find(value => value.Id == id).FirstOrDefaultAsync() : null; + + public async Task SearchAsync(string status, string category, string caseId, string userId, int skip, int limit) + { + var filter = Builders.Filter.Empty; + if (status != "all") filter &= Builders.Filter.Eq(value => value.Status, status); + if (category.Length > 0) filter &= Builders.Filter.Eq(value => value.Category, category); + if (caseId.Length > 0) filter &= Builders.Filter.Eq(value => value.CaseExternalId, caseId); + if (userId.Length > 0) filter &= Builders.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 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.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 { ReturnDocument = ReturnDocument.After }); + } +} diff --git a/api/QuestionsApi/Services/CcsStore.cs b/api/QuestionsApi/Services/CcsStore.cs index 7a6e856..3be2f53 100644 --- a/api/QuestionsApi/Services/CcsStore.cs +++ b/api/QuestionsApi/Services/CcsStore.cs @@ -378,7 +378,7 @@ public sealed class CcsStore }; } - private async Task DefinitionAsync(CcsAttempt attempt) => + public async Task DefinitionAsync(CcsAttempt attempt) => await _cases.Find(value => value.Id == attempt.CaseRevisionId).FirstOrDefaultAsync() ?? throw new InvalidOperationException("The case revision is unavailable."); diff --git a/scripts/autoload/ccs_service.gd b/scripts/autoload/ccs_service.gd index 9472eb5..8ca919e 100644 --- a/scripts/autoload/ccs_service.gd +++ b/scripts/autoload/ccs_service.gd @@ -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 diff --git a/scripts/ui/ccs_hub.gd b/scripts/ui/ccs_hub.gd index 6a55aef..55a1c5f 100644 --- a/scripts/ui/ccs_hub.gd +++ b/scripts/ui/ccs_hub.gd @@ -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() diff --git a/scripts/ui/ccs_player.gd b/scripts/ui/ccs_player.gd index 70a01b7..a98159d 100644 --- a/scripts/ui/ccs_player.gd +++ b/scripts/ui/ccs_player.gd @@ -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" diff --git a/scripts/ui/ccs_report_dialog.gd b/scripts/ui/ccs_report_dialog.gd new file mode 100644 index 0000000..3e0c93c --- /dev/null +++ b/scripts/ui/ccs_report_dialog.gd @@ -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() diff --git a/scripts/ui/ccs_report_dialog.gd.uid b/scripts/ui/ccs_report_dialog.gd.uid new file mode 100644 index 0000000..ddf7d71 --- /dev/null +++ b/scripts/ui/ccs_report_dialog.gd.uid @@ -0,0 +1 @@ +uid://bvabwfcabwpkh diff --git a/scripts/ui/ccs_review.gd b/scripts/ui/ccs_review.gd index 3be7ae5..28000ec 100644 --- a/scripts/ui/ccs_review.gd +++ b/scripts/ui/ccs_review.gd @@ -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) diff --git a/scripts/ui/study_selector.gd b/scripts/ui/study_selector.gd index e02e320..c4223df 100644 --- a/scripts/ui/study_selector.gd +++ b/scripts/ui/study_selector.gd @@ -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()