Add CCS study tools and case reporting
This commit is contained in:
@@ -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 ?? "";
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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.");
|
||||
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
uid://bvabwfcabwpkh
|
||||
+74
-24
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user