Unify CCS case management flow
This commit is contained in:
@@ -1,12 +1,27 @@
|
||||
using QuestionsApi.Models;
|
||||
using QuestionsApi.Services;
|
||||
using System.Text.Json;
|
||||
using MongoDB.Bson;
|
||||
using Xunit;
|
||||
|
||||
namespace QuestionsApi.Tests;
|
||||
|
||||
public sealed class CcsCaseTests
|
||||
{
|
||||
[Fact]
|
||||
public void CcsHistoryCursorRoundTripsTimestampAndId()
|
||||
{
|
||||
var createdUtc = new DateTime(2026, 7, 19, 12, 34, 56, DateTimeKind.Utc);
|
||||
var id = ObjectId.GenerateNewId().ToString();
|
||||
|
||||
var encoded = CcsStore.EncodeHistoryCursor(createdUtc, id);
|
||||
|
||||
Assert.True(CcsStore.TryDecodeHistoryCursor(encoded, out var decoded));
|
||||
Assert.Equal(createdUtc, decoded!.CreatedUtc);
|
||||
Assert.Equal(id, decoded.Id);
|
||||
Assert.False(CcsStore.TryDecodeHistoryCursor("not-a-cursor", out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AppFeedbackValidationAcceptsSafeDiagnostics()
|
||||
{
|
||||
|
||||
@@ -27,6 +27,13 @@ public sealed class CcsController(CcsStore store, CcsRatingStore ratings) : Cont
|
||||
catch (InvalidOperationException exception) { return Conflict(exception.Message); }
|
||||
}
|
||||
|
||||
[HttpGet("active")]
|
||||
public async Task<IActionResult> Active()
|
||||
{
|
||||
var attempt = await store.FindActiveAsync(UserId());
|
||||
return attempt is null ? NoContent() : Ok(await store.ToResponseAsync(attempt));
|
||||
}
|
||||
|
||||
[HttpGet("attempts/{id}")]
|
||||
public async Task<IActionResult> Attempt(string id)
|
||||
{
|
||||
@@ -54,7 +61,15 @@ public sealed class CcsController(CcsStore store, CcsRatingStore ratings) : Cont
|
||||
}
|
||||
|
||||
[HttpGet("history")]
|
||||
public async Task<IActionResult> History([FromQuery] int limit = 50) => Ok(await store.HistoryAsync(UserId(), limit));
|
||||
public async Task<IActionResult> History(
|
||||
[FromQuery] int limit = 20,
|
||||
[FromQuery] string status = "all",
|
||||
[FromQuery] string sort = "newest",
|
||||
[FromQuery] string? cursor = null)
|
||||
{
|
||||
try { return Ok(await store.HistoryAsync(UserId(), limit, status, sort, cursor)); }
|
||||
catch (ArgumentException exception) { return BadRequest(exception.Message); }
|
||||
}
|
||||
|
||||
[HttpGet("prediction")]
|
||||
public async Task<IActionResult> Prediction() => Ok(await store.PredictionAsync(UserId()));
|
||||
|
||||
@@ -107,6 +107,7 @@ public class CcsAttempt
|
||||
{
|
||||
[BsonId, BsonRepresentation(BsonType.ObjectId)] public string? Id { get; set; }
|
||||
public string UserId { get; set; } = "";
|
||||
[BsonIgnoreIfNull] public string? ActiveSlot { get; set; }
|
||||
public string CaseExternalId { get; set; } = "";
|
||||
[BsonRepresentation(BsonType.ObjectId)] public string CaseRevisionId { get; set; } = "";
|
||||
public int CaseVersion { get; set; }
|
||||
@@ -228,6 +229,8 @@ public class CcsAttemptResponse
|
||||
public string Location { get; set; } = "";
|
||||
public DateTime? DeadlineUtc { get; set; }
|
||||
public DateTime? FinalOrdersDeadlineUtc { get; set; }
|
||||
public DateTime CreatedUtc { get; set; }
|
||||
public int RealTimeMinutes { get; set; }
|
||||
public int ElapsedSeconds { get; set; }
|
||||
public string PatientSummary { get; set; } = "";
|
||||
public string ReasonForEncounter { get; set; } = "";
|
||||
@@ -256,6 +259,12 @@ public class CcsHistoryItem
|
||||
public DateTime CreatedUtc { get; set; }
|
||||
}
|
||||
|
||||
public class CcsHistoryPage
|
||||
{
|
||||
public List<CcsHistoryItem> Items { get; set; } = [];
|
||||
public string? NextCursor { get; set; }
|
||||
}
|
||||
|
||||
public class CcsUserRating
|
||||
{
|
||||
[BsonId, BsonRepresentation(BsonType.ObjectId)] public string? Id { get; set; }
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Driver;
|
||||
using QuestionsApi.Models;
|
||||
using System.Text;
|
||||
|
||||
namespace QuestionsApi.Services;
|
||||
|
||||
@@ -29,6 +31,9 @@ public sealed class CcsStore
|
||||
Builders<CcsAttempt>.IndexKeys.Ascending(value => value.UserId).Ascending(value => value.CaseExternalId).Ascending(value => value.Mode).Ascending(value => value.CreatedUtc)));
|
||||
_attempts.Indexes.CreateOne(new CreateIndexModel<CcsAttempt>(
|
||||
Builders<CcsAttempt>.IndexKeys.Ascending(value => value.UserId).Descending(value => value.CreatedUtc)));
|
||||
_attempts.Indexes.CreateOne(new CreateIndexModel<CcsAttempt>(
|
||||
Builders<CcsAttempt>.IndexKeys.Ascending(value => value.ActiveSlot),
|
||||
new CreateIndexOptions { Unique = true, Sparse = true, Name = "unique_active_ccs_attempt" }));
|
||||
}
|
||||
|
||||
public async Task<CcsImportResponse> ImportAsync(CcsImportRequest request)
|
||||
@@ -86,6 +91,8 @@ public sealed class CcsStore
|
||||
{
|
||||
var validation = CcsCaseValidator.ValidateCreate(request);
|
||||
if (validation is not null) throw new ArgumentException(validation);
|
||||
if (await FindActiveAsync(userId) is not null)
|
||||
throw new InvalidOperationException("An active CCS case already exists.");
|
||||
CcsCaseRevision? definition;
|
||||
if (string.IsNullOrWhiteSpace(request.CaseExternalId))
|
||||
{
|
||||
@@ -104,6 +111,7 @@ public sealed class CcsStore
|
||||
var attempt = new CcsAttempt
|
||||
{
|
||||
UserId = userId,
|
||||
ActiveSlot = userId,
|
||||
CaseExternalId = definition.ExternalId,
|
||||
CaseRevisionId = definition.Id ?? "",
|
||||
CaseVersion = definition.Version,
|
||||
@@ -119,10 +127,45 @@ public sealed class CcsStore
|
||||
new CcsChartEntry { Category = "progress", Text = definition.InitialChart.Appearance }
|
||||
]
|
||||
};
|
||||
await _attempts.InsertOneAsync(attempt);
|
||||
try { await _attempts.InsertOneAsync(attempt); }
|
||||
catch (MongoWriteException exception) when (exception.WriteError?.Category == ServerErrorCategory.DuplicateKey)
|
||||
{
|
||||
throw new InvalidOperationException("An active CCS case already exists.", exception);
|
||||
}
|
||||
return attempt;
|
||||
}
|
||||
|
||||
public async Task<CcsAttempt?> FindActiveAsync(string userId)
|
||||
{
|
||||
var unfinished = await _attempts.Find(value => value.UserId == userId &&
|
||||
(value.State == CcsAttemptStates.Active || value.State == CcsAttemptStates.FinalOrders))
|
||||
.SortByDescending(value => value.UpdatedUtc).ThenByDescending(value => value.Id).ToListAsync();
|
||||
if (unfinished.Count == 0) return null;
|
||||
|
||||
var retained = unfinished[0];
|
||||
foreach (var older in unfinished.Skip(1))
|
||||
{
|
||||
older.State = CcsAttemptStates.Abandoned;
|
||||
older.ActiveSlot = null;
|
||||
older.UpdatedUtc = DateTime.UtcNow;
|
||||
await SaveAsync(older);
|
||||
}
|
||||
|
||||
retained = await GetOwnedAsync(retained.Id ?? "", userId) ?? retained;
|
||||
if (retained.State is CcsAttemptStates.Completed or CcsAttemptStates.Abandoned) return null;
|
||||
if (retained.ActiveSlot != userId)
|
||||
{
|
||||
retained.ActiveSlot = userId;
|
||||
retained.UpdatedUtc = DateTime.UtcNow;
|
||||
try { await SaveAsync(retained); }
|
||||
catch (MongoWriteException exception) when (exception.WriteError?.Category == ServerErrorCategory.DuplicateKey)
|
||||
{
|
||||
return await _attempts.Find(value => value.ActiveSlot == userId).FirstOrDefaultAsync();
|
||||
}
|
||||
}
|
||||
return retained;
|
||||
}
|
||||
|
||||
public async Task<CcsAttempt?> GetOwnedAsync(string id, string userId)
|
||||
{
|
||||
var attempt = await _attempts.Find(value => value.Id == id && value.UserId == userId).FirstOrDefaultAsync();
|
||||
@@ -177,19 +220,71 @@ public sealed class CcsStore
|
||||
return attempt;
|
||||
}
|
||||
|
||||
public async Task<List<CcsHistoryItem>> HistoryAsync(string userId, int limit)
|
||||
public async Task<CcsHistoryPage> HistoryAsync(string userId, int limit, string status = "all", string sort = "newest", string? cursor = null)
|
||||
{
|
||||
var attempts = await _attempts.Find(value => value.UserId == userId).SortByDescending(value => value.CreatedUtc).Limit(Math.Clamp(limit, 1, 100)).ToListAsync();
|
||||
limit = Math.Clamp(limit, 1, 50);
|
||||
status = status.Trim().ToLowerInvariant();
|
||||
sort = sort.Trim().ToLowerInvariant();
|
||||
if (status is not ("all" or "completed" or "abandoned")) throw new ArgumentException("The CCS history status is invalid.");
|
||||
if (sort is not ("newest" or "oldest")) throw new ArgumentException("The CCS history sort order is invalid.");
|
||||
|
||||
var filters = Builders<CcsAttempt>.Filter;
|
||||
var filter = filters.Eq(value => value.UserId, userId);
|
||||
filter &= status switch
|
||||
{
|
||||
"completed" => filters.Eq(value => value.State, CcsAttemptStates.Completed),
|
||||
"abandoned" => filters.Eq(value => value.State, CcsAttemptStates.Abandoned),
|
||||
_ => filters.In(value => value.State, [CcsAttemptStates.Completed, CcsAttemptStates.Abandoned])
|
||||
};
|
||||
if (!string.IsNullOrWhiteSpace(cursor))
|
||||
{
|
||||
if (!TryDecodeHistoryCursor(cursor, out var decoded)) throw new ArgumentException("The CCS history cursor is invalid.");
|
||||
var dateComparison = sort == "newest" ? filters.Lt(value => value.CreatedUtc, decoded!.CreatedUtc) : filters.Gt(value => value.CreatedUtc, decoded!.CreatedUtc);
|
||||
var idComparison = sort == "newest" ? filters.Lt(value => value.Id, decoded!.Id) : filters.Gt(value => value.Id, decoded!.Id);
|
||||
filter &= dateComparison | (filters.Eq(value => value.CreatedUtc, decoded!.CreatedUtc) & idComparison);
|
||||
}
|
||||
var ordering = sort == "newest"
|
||||
? Builders<CcsAttempt>.Sort.Descending(value => value.CreatedUtc).Descending(value => value.Id)
|
||||
: Builders<CcsAttempt>.Sort.Ascending(value => value.CreatedUtc).Ascending(value => value.Id);
|
||||
var attempts = await _attempts.Find(filter).Sort(ordering).Limit(limit + 1).ToListAsync();
|
||||
var hasMore = attempts.Count > limit;
|
||||
if (hasMore) attempts.RemoveAt(attempts.Count - 1);
|
||||
var revisionIds = attempts.Select(value => value.CaseRevisionId).Distinct().ToList();
|
||||
var definitions = (await _cases.Find(value => revisionIds.Contains(value.Id!)).ToListAsync()).ToDictionary(value => value.Id ?? "");
|
||||
return attempts.Select(value => new CcsHistoryItem
|
||||
var items = attempts.Select(value => new CcsHistoryItem
|
||||
{
|
||||
Id = value.Id ?? "", CaseExternalId = value.CaseExternalId,
|
||||
CaseTitle = definitions.GetValueOrDefault(value.CaseRevisionId)?.Title ?? value.CaseExternalId,
|
||||
CaseTitle = value.State == CcsAttemptStates.Completed
|
||||
? definitions.GetValueOrDefault(value.CaseRevisionId)?.Title ?? value.CaseExternalId
|
||||
: "Timed CCS Case",
|
||||
Mode = value.Mode, State = value.State, Score = value.Score?.Percent, Rating = value.Rating, CreatedUtc = value.CreatedUtc
|
||||
}).ToList();
|
||||
var last = attempts.LastOrDefault();
|
||||
return new CcsHistoryPage
|
||||
{
|
||||
Items = items,
|
||||
NextCursor = hasMore && last is not null ? EncodeHistoryCursor(last.CreatedUtc, last.Id ?? "") : null
|
||||
};
|
||||
}
|
||||
|
||||
public static string EncodeHistoryCursor(DateTime createdUtc, string id) =>
|
||||
Convert.ToBase64String(Encoding.UTF8.GetBytes($"{createdUtc.ToUniversalTime().Ticks}|{id}"));
|
||||
|
||||
public static bool TryDecodeHistoryCursor(string value, out HistoryCursor? cursor)
|
||||
{
|
||||
cursor = null;
|
||||
try
|
||||
{
|
||||
var parts = Encoding.UTF8.GetString(Convert.FromBase64String(value)).Split('|', 2);
|
||||
if (parts.Length != 2 || !long.TryParse(parts[0], out var ticks) || !ObjectId.TryParse(parts[1], out _)) return false;
|
||||
cursor = new HistoryCursor(new DateTime(ticks, DateTimeKind.Utc), parts[1]);
|
||||
return true;
|
||||
}
|
||||
catch (Exception exception) when (exception is FormatException or ArgumentOutOfRangeException) { return false; }
|
||||
}
|
||||
|
||||
public sealed record HistoryCursor(DateTime CreatedUtc, string Id);
|
||||
|
||||
public async Task<CcsPredictionResponse> PredictionAsync(string userId)
|
||||
{
|
||||
var timed = await _attempts.Find(value => value.UserId == userId && value.Mode == CcsAttemptModes.Timed && value.State == CcsAttemptStates.Completed && value.Score != null)
|
||||
@@ -226,6 +321,7 @@ public sealed class CcsStore
|
||||
Mode = attempt.Mode, State = attempt.State, Revision = attempt.Revision,
|
||||
SimulatedMinute = attempt.SimulatedMinute, Location = attempt.Location,
|
||||
DeadlineUtc = attempt.DeadlineUtc, FinalOrdersDeadlineUtc = attempt.FinalOrdersDeadlineUtc,
|
||||
CreatedUtc = attempt.CreatedUtc, RealTimeMinutes = definition.RealTimeMinutes,
|
||||
ElapsedSeconds = Math.Max(0, (int)Math.Round(((attempt.CompletedUtc ?? DateTime.UtcNow) - attempt.CreatedUtc).TotalSeconds)),
|
||||
PatientSummary = definition.InitialChart.PatientSummary, ReasonForEncounter = definition.InitialChart.ReasonForEncounter,
|
||||
Appearance = definition.InitialChart.Appearance, History = definition.InitialChart.History,
|
||||
@@ -241,6 +337,7 @@ public sealed class CcsStore
|
||||
public async Task AbandonAsync(CcsAttempt attempt)
|
||||
{
|
||||
attempt.State = CcsAttemptStates.Abandoned;
|
||||
attempt.ActiveSlot = null;
|
||||
attempt.UpdatedUtc = DateTime.UtcNow;
|
||||
await SaveAsync(attempt);
|
||||
}
|
||||
@@ -346,6 +443,7 @@ public sealed class CcsStore
|
||||
if (attempt.State == CcsAttemptStates.Completed) return;
|
||||
var definition = await DefinitionAsync(attempt);
|
||||
attempt.State = CcsAttemptStates.Completed;
|
||||
attempt.ActiveSlot = null;
|
||||
attempt.CompletedUtc = DateTime.UtcNow;
|
||||
attempt.Score = CalculateScore(attempt, definition);
|
||||
attempt.Rating = await _ratings.ApplyAsync(attempt);
|
||||
|
||||
@@ -24,6 +24,7 @@ var cases: Array = []
|
||||
var attempt := {}
|
||||
var orders: Array = []
|
||||
var history: Array = []
|
||||
var history_next_cursor := ""
|
||||
var prediction := {}
|
||||
var rating := {"elo": 1000, "ratedCaseCount": 0}
|
||||
var is_loading := false
|
||||
@@ -50,6 +51,10 @@ func create_attempt(case_external_id: String, mode: String) -> void:
|
||||
_send("attempt", HTTPClient.METHOD_POST, "/attempts", {"caseExternalId": case_external_id, "mode": mode})
|
||||
|
||||
|
||||
func load_active() -> void:
|
||||
_send("active", HTTPClient.METHOD_GET, "/active")
|
||||
|
||||
|
||||
func load_attempt(attempt_id: String) -> void:
|
||||
_send("attempt", HTTPClient.METHOD_GET, "/attempts/%s" % attempt_id.uri_encode())
|
||||
|
||||
@@ -74,8 +79,11 @@ func search_orders(query: String) -> void:
|
||||
_send("orders", HTTPClient.METHOD_GET, "/orders/search?q=%s&limit=20" % query.strip_edges().uri_encode())
|
||||
|
||||
|
||||
func load_history() -> void:
|
||||
_send("history", HTTPClient.METHOD_GET, "/history?limit=50")
|
||||
func load_history(cursor: String = "", status: String = "all", sort_order: String = "newest") -> void:
|
||||
var suffix := "/history?limit=20&status=%s&sort=%s" % [status.uri_encode(), sort_order.uri_encode()]
|
||||
if not cursor.is_empty():
|
||||
suffix += "&cursor=%s" % cursor.uri_encode()
|
||||
_send("history_append" if not cursor.is_empty() else "history", HTTPClient.METHOD_GET, suffix)
|
||||
|
||||
|
||||
func load_prediction() -> void:
|
||||
@@ -184,16 +192,21 @@ func _on_request_completed(result: int, response_code: int, _headers: PackedStri
|
||||
else: attempt_changed.emit()
|
||||
return
|
||||
var parsed: Variant = JSON.parse_string(body.get_string_from_utf8())
|
||||
if action in ["cases", "orders", "history"] and typeof(parsed) != TYPE_ARRAY:
|
||||
if action in ["cases", "orders"] and typeof(parsed) != TYPE_ARRAY:
|
||||
request_failed.emit("The CCS API returned an invalid list.")
|
||||
return
|
||||
if action not in ["cases", "orders", "history"] and typeof(parsed) != TYPE_DICTIONARY:
|
||||
if action not in ["cases", "orders"] and typeof(parsed) != TYPE_DICTIONARY:
|
||||
request_failed.emit("The CCS API returned an invalid response.")
|
||||
return
|
||||
match action:
|
||||
"cases": cases = parsed; cases_changed.emit()
|
||||
"orders": orders = parsed; orders_changed.emit()
|
||||
"history": history = parsed; history_changed.emit()
|
||||
"history", "history_append":
|
||||
var items: Array = parsed.get("items", [])
|
||||
history = history + items if action == "history_append" else items
|
||||
var cursor_value: Variant = parsed.get("nextCursor", null)
|
||||
history_next_cursor = "" if cursor_value == null else str(cursor_value)
|
||||
history_changed.emit()
|
||||
"prediction": prediction = parsed; prediction_changed.emit()
|
||||
"rating": rating = parsed; rating_changed.emit()
|
||||
"report": report_submitted.emit(str(parsed.get("id", "")))
|
||||
@@ -218,3 +231,9 @@ func _on_auth_changed() -> void:
|
||||
if not AuthState.is_logged_in:
|
||||
highlights.clear()
|
||||
text_scale = 1.0
|
||||
attempt = {}
|
||||
orders = []
|
||||
history = []
|
||||
history_next_cursor = ""
|
||||
prediction = {}
|
||||
rating = {"elo": 1000, "ratedCaseCount": 0}
|
||||
|
||||
@@ -19,7 +19,7 @@ func _ready() -> void:
|
||||
CCSService.rating_changed.connect(_render_rating)
|
||||
|
||||
func on_show(_payload: Variant = null) -> void:
|
||||
CCSService.load_history(); CCSService.load_prediction(); CCSService.load_rating()
|
||||
main.show_screen("ccs_hub")
|
||||
|
||||
func _render() -> void:
|
||||
for child in list.get_children(): child.queue_free()
|
||||
|
||||
+683
-33
@@ -1,48 +1,698 @@
|
||||
extends VBoxContainer
|
||||
|
||||
const Style := preload("res://scripts/ui/ui_style.gd")
|
||||
const Brand := preload("res://scripts/ui/medmind_style.gd")
|
||||
const CASE_ICON := preload("res://assets/icons/lucide/stethoscope.svg")
|
||||
const REVIEW_ICON := preload("res://assets/icons/lucide/chart-no-axes-column-increasing.svg")
|
||||
|
||||
var main: Control
|
||||
var status: Label
|
||||
var start_button: Button
|
||||
var page: VBoxContainer
|
||||
var active_section: PanelContainer
|
||||
var active_content: VBoxContainer
|
||||
var history_section: PanelContainer
|
||||
var history_header: BoxContainer
|
||||
var table_header: HBoxContainer
|
||||
var history_list: VBoxContainer
|
||||
var status_label: Label
|
||||
var load_more_button: Button
|
||||
var filter_option: OptionButton
|
||||
var sort_option: OptionButton
|
||||
var abandon_dialog: ConfirmationDialog
|
||||
var pending_action := ""
|
||||
var pending_attempt_id := ""
|
||||
var history_status := "all"
|
||||
var history_sort := "newest"
|
||||
var compact_layout := false
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
add_theme_constant_override("separation", 14)
|
||||
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)
|
||||
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(_on_request_failed)
|
||||
size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||
add_theme_constant_override("separation", 0)
|
||||
_build_ui()
|
||||
_connect_services()
|
||||
resized.connect(_update_responsive_layout)
|
||||
UIThemeService.theme_changed.connect(func(_dark_mode: bool): refresh_theme_controls())
|
||||
call_deferred("_update_responsive_layout")
|
||||
|
||||
|
||||
func _build_ui() -> void:
|
||||
var scroll := ScrollContainer.new()
|
||||
scroll.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||
scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
|
||||
add_child(scroll)
|
||||
|
||||
page = VBoxContainer.new()
|
||||
page.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
page.add_theme_constant_override("separation", 18)
|
||||
scroll.add_child(page)
|
||||
|
||||
active_section = PanelContainer.new()
|
||||
active_section.name = "ActiveCcsSection"
|
||||
page.add_child(active_section)
|
||||
active_content = VBoxContainer.new()
|
||||
active_content.add_theme_constant_override("separation", 10)
|
||||
active_section.add_child(active_content)
|
||||
|
||||
history_section = PanelContainer.new()
|
||||
history_section.name = "CcsHistorySection"
|
||||
history_section.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||
page.add_child(history_section)
|
||||
var history_content := VBoxContainer.new()
|
||||
history_content.add_theme_constant_override("separation", 12)
|
||||
history_section.add_child(history_content)
|
||||
|
||||
history_header = BoxContainer.new()
|
||||
history_header.add_theme_constant_override("separation", 10)
|
||||
history_content.add_child(history_header)
|
||||
var heading := Label.new()
|
||||
heading.text = "Case History"
|
||||
heading.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
Brand.apply_font(heading)
|
||||
heading.add_theme_font_size_override("font_size", 21)
|
||||
history_header.add_child(heading)
|
||||
|
||||
filter_option = OptionButton.new()
|
||||
filter_option.name = "HistoryFilter"
|
||||
filter_option.custom_minimum_size = Vector2(175, 42)
|
||||
filter_option.add_item("All Cases")
|
||||
filter_option.set_item_metadata(0, "all")
|
||||
filter_option.add_item("Completed")
|
||||
filter_option.set_item_metadata(1, "completed")
|
||||
filter_option.add_item("Abandoned")
|
||||
filter_option.set_item_metadata(2, "abandoned")
|
||||
filter_option.item_selected.connect(_on_filter_selected)
|
||||
history_header.add_child(filter_option)
|
||||
|
||||
sort_option = OptionButton.new()
|
||||
sort_option.name = "HistorySort"
|
||||
sort_option.custom_minimum_size = Vector2(165, 42)
|
||||
sort_option.add_item("Newest First")
|
||||
sort_option.set_item_metadata(0, "newest")
|
||||
sort_option.add_item("Oldest First")
|
||||
sort_option.set_item_metadata(1, "oldest")
|
||||
sort_option.item_selected.connect(_on_sort_selected)
|
||||
history_header.add_child(sort_option)
|
||||
|
||||
status_label = Label.new()
|
||||
status_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
Brand.apply_font(status_label)
|
||||
status_label.add_theme_font_size_override("font_size", 15)
|
||||
history_content.add_child(status_label)
|
||||
|
||||
table_header = _build_table_header()
|
||||
history_content.add_child(table_header)
|
||||
history_list = VBoxContainer.new()
|
||||
history_list.add_theme_constant_override("separation", 0)
|
||||
history_content.add_child(history_list)
|
||||
|
||||
load_more_button = Button.new()
|
||||
load_more_button.text = "Show more"
|
||||
load_more_button.visible = false
|
||||
load_more_button.custom_minimum_size = Vector2(150, 42)
|
||||
load_more_button.size_flags_horizontal = Control.SIZE_SHRINK_CENTER
|
||||
load_more_button.pressed.connect(_load_more)
|
||||
history_content.add_child(load_more_button)
|
||||
|
||||
abandon_dialog = ConfirmationDialog.new()
|
||||
abandon_dialog.title = "Abandon case?"
|
||||
abandon_dialog.dialog_text = "This case cannot be resumed and will not receive a score. It will remain visible in your case history."
|
||||
abandon_dialog.ok_button_text = "Abandon Case"
|
||||
abandon_dialog.cancel_button_text = "Keep Case"
|
||||
abandon_dialog.confirmed.connect(_confirm_abandon)
|
||||
add_child(abandon_dialog)
|
||||
refresh_theme_controls()
|
||||
|
||||
|
||||
func _connect_services() -> void:
|
||||
if not CCSService.cases_changed.is_connected(_render_active_section):
|
||||
CCSService.cases_changed.connect(_render_active_section)
|
||||
if not CCSService.attempt_changed.is_connected(_on_attempt_changed):
|
||||
CCSService.attempt_changed.connect(_on_attempt_changed)
|
||||
if not CCSService.attempt_abandoned.is_connected(_on_attempt_abandoned):
|
||||
CCSService.attempt_abandoned.connect(_on_attempt_abandoned)
|
||||
if not CCSService.history_changed.is_connected(_render_history):
|
||||
CCSService.history_changed.connect(_render_history)
|
||||
if not CCSService.prediction_changed.is_connected(_render_active_section):
|
||||
CCSService.prediction_changed.connect(_render_active_section)
|
||||
if not CCSService.rating_changed.is_connected(_render_active_section):
|
||||
CCSService.rating_changed.connect(_render_active_section)
|
||||
if not CCSService.request_failed.is_connected(_on_request_failed):
|
||||
CCSService.request_failed.connect(_on_request_failed)
|
||||
|
||||
|
||||
func on_show(_payload: Variant = null) -> void:
|
||||
status.text = "Loading cases..."
|
||||
pending_action = ""
|
||||
pending_attempt_id = ""
|
||||
status_label.text = "Loading case history..."
|
||||
load_more_button.disabled = true
|
||||
CCSService.load_cases()
|
||||
CCSService.load_active()
|
||||
CCSService.load_prediction()
|
||||
CCSService.load_rating()
|
||||
_load_history_page()
|
||||
|
||||
func _render() -> void:
|
||||
status.text = "No published cases are available yet." if CCSService.cases.is_empty() else "%d cases available" % CCSService.cases.size()
|
||||
start_button.disabled = CCSService.cases.is_empty()
|
||||
|
||||
func _start() -> void:
|
||||
status.text = "Starting case..."
|
||||
start_button.disabled = true
|
||||
CCSService.create_attempt("", "timed")
|
||||
func refresh_theme_controls() -> void:
|
||||
if page == null:
|
||||
return
|
||||
var dark := UIThemeService.is_dark_mode()
|
||||
active_section.add_theme_stylebox_override("panel", _section_box(dark))
|
||||
history_section.add_theme_stylebox_override("panel", _section_box(dark))
|
||||
status_label.add_theme_color_override("font_color", Brand.muted_color(dark))
|
||||
_style_option(filter_option, dark)
|
||||
_style_option(sort_option, dark)
|
||||
_style_outline_button(load_more_button, Brand.PURPLE, dark)
|
||||
_render_active_section()
|
||||
_render_history()
|
||||
|
||||
|
||||
func _render_active_section() -> void:
|
||||
if active_content == null:
|
||||
return
|
||||
_clear(active_content)
|
||||
var dark := UIThemeService.is_dark_mode()
|
||||
var has_active := has_active_case(CCSService.attempt)
|
||||
var section_title := Label.new()
|
||||
section_title.text = "Active Case" if has_active else "Timed CCS Case"
|
||||
Brand.apply_font(section_title)
|
||||
section_title.add_theme_font_size_override("font_size", 18)
|
||||
section_title.add_theme_color_override("font_color", Color("9b73ff") if dark else Brand.PURPLE)
|
||||
active_content.add_child(section_title)
|
||||
|
||||
var card := PanelContainer.new()
|
||||
card.add_theme_stylebox_override("panel", _active_card_box(dark))
|
||||
active_content.add_child(card)
|
||||
var card_layout := BoxContainer.new()
|
||||
card_layout.name = "ActiveCardLayout"
|
||||
card_layout.vertical = compact_layout
|
||||
card_layout.add_theme_constant_override("separation", 18)
|
||||
card.add_child(card_layout)
|
||||
|
||||
var icon_panel := PanelContainer.new()
|
||||
icon_panel.custom_minimum_size = Vector2(94, 94)
|
||||
icon_panel.size_flags_vertical = Control.SIZE_SHRINK_CENTER
|
||||
icon_panel.add_theme_stylebox_override("panel", Brand.icon_circle_box(dark, Brand.TEAL, 8))
|
||||
card_layout.add_child(icon_panel)
|
||||
var icon := TextureRect.new()
|
||||
icon.texture = CASE_ICON
|
||||
icon.custom_minimum_size = Vector2(44, 44)
|
||||
icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||||
icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
||||
icon.modulate = Brand.TEAL
|
||||
icon_panel.add_child(icon)
|
||||
|
||||
var details := VBoxContainer.new()
|
||||
details.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
details.add_theme_constant_override("separation", 5)
|
||||
card_layout.add_child(details)
|
||||
var heading := Label.new()
|
||||
heading.text = "Timed CCS Case"
|
||||
Brand.apply_font(heading)
|
||||
heading.add_theme_font_size_override("font_size", 19)
|
||||
heading.add_theme_color_override("font_color", Brand.text_color(dark))
|
||||
details.add_child(heading)
|
||||
if has_active:
|
||||
_build_active_details(details, dark)
|
||||
else:
|
||||
_build_start_details(details, dark)
|
||||
|
||||
var actions := VBoxContainer.new()
|
||||
actions.name = "ActiveActions"
|
||||
if not compact_layout:
|
||||
actions.custom_minimum_size = Vector2(265, 0)
|
||||
actions.size_flags_vertical = Control.SIZE_SHRINK_CENTER
|
||||
actions.add_theme_constant_override("separation", 10)
|
||||
card_layout.add_child(actions)
|
||||
var primary := Button.new()
|
||||
primary.name = "PrimaryCcsAction"
|
||||
primary.text = "Resume Case" if has_active else "Start Case"
|
||||
primary.custom_minimum_size = Vector2(0, 48)
|
||||
primary.disabled = not pending_action.is_empty() or (not has_active and CCSService.cases.is_empty())
|
||||
_style_gradient_button(primary)
|
||||
primary.pressed.connect(_launch_or_resume)
|
||||
actions.add_child(primary)
|
||||
if has_active:
|
||||
var abandon := Button.new()
|
||||
abandon.name = "AbandonCase"
|
||||
abandon.text = "Abandon Case"
|
||||
abandon.custom_minimum_size = Vector2(0, 46)
|
||||
_style_outline_button(abandon, Color("ff5f76"), dark)
|
||||
abandon.pressed.connect(func(): abandon_dialog.popup_centered(Vector2i(520, 220)))
|
||||
actions.add_child(abandon)
|
||||
|
||||
_build_performance_summary(dark)
|
||||
var notice := PanelContainer.new()
|
||||
notice.add_theme_stylebox_override("panel", _notice_box(dark))
|
||||
active_content.add_child(notice)
|
||||
var notice_label := Label.new()
|
||||
notice_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
notice_label.text = "Abandoning ends this case without a score; the attempt remains in case history." if has_active else _availability_message()
|
||||
_style_muted_label(notice_label, dark, 14)
|
||||
notice.add_child(notice_label)
|
||||
|
||||
|
||||
func _build_active_details(details: VBoxContainer, dark: bool) -> void:
|
||||
var attempt: Dictionary = CCSService.attempt
|
||||
var started := Label.new()
|
||||
started.text = "Started: %s" % _format_local_datetime(str(attempt.get("createdUtc", "")))
|
||||
_style_muted_label(started, dark, 14)
|
||||
details.add_child(started)
|
||||
var state := Label.new()
|
||||
var state_text := "Final orders" if str(attempt.get("state", "")) == "final_orders" else "In progress"
|
||||
state.text = "%s | %s | %s" % [state_text, humanize(str(attempt.get("location", ""))), remaining_time_text(attempt)]
|
||||
_style_muted_label(state, dark, 14)
|
||||
details.add_child(state)
|
||||
|
||||
|
||||
func _build_start_details(details: VBoxContainer, dark: bool) -> void:
|
||||
var copy := Label.new()
|
||||
copy.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
copy.text = "Manage a randomly selected patient through focused examination, diagnostic orders, treatment, disposition, and simulated time."
|
||||
_style_muted_label(copy, dark, 15)
|
||||
details.add_child(copy)
|
||||
|
||||
|
||||
func _build_performance_summary(dark: bool) -> void:
|
||||
var panel := PanelContainer.new()
|
||||
panel.add_theme_stylebox_override("panel", _notice_box(dark))
|
||||
active_content.add_child(panel)
|
||||
var row := BoxContainer.new()
|
||||
row.vertical = compact_layout
|
||||
row.add_theme_constant_override("separation", 18)
|
||||
panel.add_child(row)
|
||||
var rating_value := "%d" % int(CCSService.rating.get("elo", 1000))
|
||||
var rating_detail := "%d rated cases" % int(CCSService.rating.get("ratedCaseCount", 0))
|
||||
_add_metric(row, "CCS Rating", rating_value, rating_detail, dark)
|
||||
var ccs_score: Variant = CCSService.prediction.get("ccsScore", null)
|
||||
var score_value := "--"
|
||||
if ccs_score != null:
|
||||
score_value = "%d%%" % int(round(float(ccs_score)))
|
||||
_add_metric(row, "CCS Performance", score_value, "%d contributing cases" % int(CCSService.prediction.get("contributingCases", 0)), dark)
|
||||
var combined: Variant = CCSService.prediction.get("combinedPredictedScore", null)
|
||||
var prediction_value := "--"
|
||||
var prediction_detail := "Complete a Day 1 simulation"
|
||||
if combined != null:
|
||||
prediction_value = "%d" % int(combined)
|
||||
prediction_detail = "Range %d-%d" % [int(CCSService.prediction.get("rangeLow", 0)), int(CCSService.prediction.get("rangeHigh", 0))]
|
||||
_add_metric(row, "Combined Prediction", prediction_value, prediction_detail, dark)
|
||||
|
||||
|
||||
func _add_metric(parent: BoxContainer, caption: String, value: String, detail: String, dark: bool) -> void:
|
||||
var column := VBoxContainer.new()
|
||||
column.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
parent.add_child(column)
|
||||
var caption_label := Label.new()
|
||||
caption_label.text = caption
|
||||
_style_muted_label(caption_label, dark, 12)
|
||||
column.add_child(caption_label)
|
||||
var value_label := Label.new()
|
||||
value_label.text = value
|
||||
Brand.apply_font(value_label)
|
||||
value_label.add_theme_font_size_override("font_size", 22)
|
||||
value_label.add_theme_color_override("font_color", Brand.text_color(dark))
|
||||
column.add_child(value_label)
|
||||
var detail_label := Label.new()
|
||||
detail_label.text = detail
|
||||
_style_muted_label(detail_label, dark, 12)
|
||||
column.add_child(detail_label)
|
||||
|
||||
|
||||
func _render_history() -> void:
|
||||
if history_list == null or not visible:
|
||||
return
|
||||
_clear(history_list)
|
||||
var items: Array = CCSService.history
|
||||
status_label.text = "No cases match this filter." if items.is_empty() else ""
|
||||
for raw_item in items:
|
||||
var item: Dictionary = raw_item
|
||||
history_list.add_child(_build_history_row(item))
|
||||
load_more_button.visible = not CCSService.history_next_cursor.is_empty()
|
||||
load_more_button.disabled = false
|
||||
table_header.visible = not compact_layout and not items.is_empty()
|
||||
|
||||
|
||||
func _build_table_header() -> HBoxContainer:
|
||||
var dark := UIThemeService.is_dark_mode()
|
||||
var row := HBoxContainer.new()
|
||||
row.add_theme_constant_override("separation", 10)
|
||||
for definition in [["Case", 2.0], ["Date", 1.15], ["Status", 1.0], ["Score", 0.8], ["CCS Rating", 1.0], ["Actions", 1.0]]:
|
||||
var label := Label.new()
|
||||
label.text = str(definition[0])
|
||||
label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
label.size_flags_stretch_ratio = float(definition[1])
|
||||
Brand.apply_font(label)
|
||||
label.add_theme_font_size_override("font_size", 13)
|
||||
label.add_theme_color_override("font_color", Brand.muted_color(dark))
|
||||
row.add_child(label)
|
||||
return row
|
||||
|
||||
|
||||
func _build_history_row(item: Dictionary) -> Control:
|
||||
var dark := UIThemeService.is_dark_mode()
|
||||
var panel := PanelContainer.new()
|
||||
panel.add_theme_stylebox_override("panel", _row_box(dark))
|
||||
var state := str(item.get("state", ""))
|
||||
if compact_layout:
|
||||
var card := VBoxContainer.new()
|
||||
card.add_theme_constant_override("separation", 8)
|
||||
panel.add_child(card)
|
||||
var top := HBoxContainer.new()
|
||||
card.add_child(top)
|
||||
top.add_child(_case_cell(item, dark))
|
||||
top.add_child(_status_badge(state, dark))
|
||||
var metrics := Label.new()
|
||||
metrics.text = "%s | %s | %s" % [_format_local_datetime(str(item.get("createdUtc", ""))).replace("\n", " "), _score_text(item), _rating_text(item)]
|
||||
_style_muted_label(metrics, dark, 14)
|
||||
card.add_child(metrics)
|
||||
if state == "completed":
|
||||
card.add_child(_review_button(item, dark))
|
||||
return panel
|
||||
|
||||
var row := HBoxContainer.new()
|
||||
row.custom_minimum_size = Vector2(0, 66)
|
||||
row.add_theme_constant_override("separation", 10)
|
||||
panel.add_child(row)
|
||||
_add_table_cell(row, _case_cell(item, dark), 2.0)
|
||||
_add_table_cell(row, _plain_cell(_format_local_datetime(str(item.get("createdUtc", ""))), dark), 1.15)
|
||||
_add_table_cell(row, _status_badge(state, dark), 1.0)
|
||||
_add_table_cell(row, _plain_cell(_score_text(item), dark), 0.8)
|
||||
_add_table_cell(row, _plain_cell(_rating_text(item), dark), 1.0)
|
||||
var action: Control = _plain_cell("-", dark)
|
||||
if state == "completed":
|
||||
action = _review_button(item, dark)
|
||||
_add_table_cell(row, action, 1.0)
|
||||
return panel
|
||||
|
||||
|
||||
func _case_cell(item: Dictionary, dark: bool) -> Control:
|
||||
var column := VBoxContainer.new()
|
||||
column.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
var title := Label.new()
|
||||
title.text = str(item.get("caseTitle", "CCS Case"))
|
||||
title.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
Brand.apply_font(title)
|
||||
title.add_theme_font_size_override("font_size", 15)
|
||||
title.add_theme_color_override("font_color", Brand.text_color(dark))
|
||||
column.add_child(title)
|
||||
var mode := Label.new()
|
||||
mode.text = "Timed case"
|
||||
_style_muted_label(mode, dark, 12)
|
||||
column.add_child(mode)
|
||||
return column
|
||||
|
||||
|
||||
func _plain_cell(value: String, dark: bool) -> Label:
|
||||
var label := Label.new()
|
||||
label.text = value
|
||||
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
Brand.apply_font(label)
|
||||
label.add_theme_font_size_override("font_size", 14)
|
||||
label.add_theme_color_override("font_color", Brand.text_color(dark))
|
||||
return label
|
||||
|
||||
|
||||
func _status_badge(state: String, dark: bool) -> Label:
|
||||
var label := Label.new()
|
||||
var accent := Brand.SUCCESS if state == "completed" else Brand.SLATE
|
||||
label.text = "Completed" if state == "completed" else "Abandoned"
|
||||
label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
label.size_flags_vertical = Control.SIZE_SHRINK_CENTER
|
||||
Brand.apply_font(label)
|
||||
label.add_theme_font_size_override("font_size", 12)
|
||||
label.add_theme_color_override("font_color", accent if dark else accent.darkened(0.2))
|
||||
label.add_theme_stylebox_override("normal", Brand.badge_box(dark, accent))
|
||||
return label
|
||||
|
||||
|
||||
func _review_button(item: Dictionary, dark: bool) -> Button:
|
||||
var button := Button.new()
|
||||
button.text = "Review"
|
||||
button.icon = REVIEW_ICON
|
||||
button.expand_icon = true
|
||||
button.add_theme_constant_override("icon_max_width", 17)
|
||||
button.custom_minimum_size = Vector2(105, 38)
|
||||
button.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN
|
||||
_style_outline_button(button, Brand.PURPLE, dark)
|
||||
button.pressed.connect(_open_review.bind(str(item.get("id", ""))))
|
||||
return button
|
||||
|
||||
|
||||
func _add_table_cell(row: HBoxContainer, control: Control, ratio: float) -> void:
|
||||
control.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
control.size_flags_vertical = Control.SIZE_SHRINK_CENTER
|
||||
control.size_flags_stretch_ratio = ratio
|
||||
row.add_child(control)
|
||||
|
||||
|
||||
func _launch_or_resume() -> void:
|
||||
if not pending_action.is_empty():
|
||||
return
|
||||
pending_action = "resume" if has_active_case(CCSService.attempt) else "start"
|
||||
_render_active_section()
|
||||
if pending_action == "resume":
|
||||
main.show_screen("ccs_player")
|
||||
pending_action = ""
|
||||
else:
|
||||
CCSService.create_attempt("", "timed")
|
||||
|
||||
|
||||
func _open_review(attempt_id: String) -> void:
|
||||
if not pending_action.is_empty():
|
||||
return
|
||||
pending_action = "review"
|
||||
pending_attempt_id = attempt_id
|
||||
status_label.text = "Opening case review..."
|
||||
CCSService.load_attempt(attempt_id)
|
||||
|
||||
|
||||
func _confirm_abandon() -> void:
|
||||
if not has_active_case(CCSService.attempt):
|
||||
return
|
||||
pending_action = "abandon"
|
||||
CCSService.abandon()
|
||||
_render_active_section()
|
||||
|
||||
|
||||
func _on_attempt_changed() -> void:
|
||||
if not visible:
|
||||
return
|
||||
if pending_action in ["start", "resume_after_conflict"] and has_active_case(CCSService.attempt):
|
||||
pending_action = ""
|
||||
main.show_screen("ccs_player")
|
||||
return
|
||||
if pending_action == "review" and str(CCSService.attempt.get("id", "")) == pending_attempt_id:
|
||||
pending_action = ""
|
||||
pending_attempt_id = ""
|
||||
main.show_screen("ccs_review", CCSService.attempt)
|
||||
return
|
||||
pending_action = ""
|
||||
_render_active_section()
|
||||
|
||||
|
||||
func _on_attempt_abandoned() -> void:
|
||||
if not visible:
|
||||
return
|
||||
pending_action = ""
|
||||
CCSService.load_active()
|
||||
CCSService.load_prediction()
|
||||
CCSService.load_rating()
|
||||
_load_history_page()
|
||||
|
||||
|
||||
func _on_filter_selected(index: int) -> void:
|
||||
history_status = str(filter_option.get_item_metadata(index))
|
||||
_load_history_page()
|
||||
|
||||
|
||||
func _on_sort_selected(index: int) -> void:
|
||||
history_sort = str(sort_option.get_item_metadata(index))
|
||||
_load_history_page()
|
||||
|
||||
|
||||
func _load_history_page() -> void:
|
||||
status_label.text = "Loading case history..."
|
||||
load_more_button.disabled = true
|
||||
CCSService.load_history("", history_status, history_sort)
|
||||
|
||||
|
||||
func _load_more() -> void:
|
||||
if CCSService.history_next_cursor.is_empty():
|
||||
return
|
||||
load_more_button.disabled = true
|
||||
CCSService.load_history(CCSService.history_next_cursor, history_status, history_sort)
|
||||
|
||||
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()
|
||||
if not visible:
|
||||
return
|
||||
if pending_action == "start" and message.contains("409"):
|
||||
pending_action = "resume_after_conflict"
|
||||
status_label.text = "An active case was found. Resuming it..."
|
||||
CCSService.load_active()
|
||||
return
|
||||
pending_action = ""
|
||||
pending_attempt_id = ""
|
||||
status_label.text = message
|
||||
load_more_button.disabled = false
|
||||
_render_active_section()
|
||||
|
||||
|
||||
func _update_responsive_layout() -> void:
|
||||
var use_compact := size.x < 940
|
||||
if compact_layout == use_compact:
|
||||
return
|
||||
compact_layout = use_compact
|
||||
history_header.vertical = compact_layout
|
||||
filter_option.size_flags_horizontal = Control.SIZE_EXPAND_FILL if compact_layout else Control.SIZE_SHRINK_END
|
||||
sort_option.size_flags_horizontal = Control.SIZE_EXPAND_FILL if compact_layout else Control.SIZE_SHRINK_END
|
||||
_render_active_section()
|
||||
_render_history()
|
||||
|
||||
|
||||
func _availability_message() -> String:
|
||||
if CCSService.cases.is_empty():
|
||||
return "No published CCS cases are currently available."
|
||||
return "%d published cases are available. A random case will begin immediately." % CCSService.cases.size()
|
||||
|
||||
|
||||
func _score_text(item: Dictionary) -> String:
|
||||
var score: Variant = item.get("score", null)
|
||||
return "--" if score == null else "%d%%" % int(round(float(score)))
|
||||
|
||||
|
||||
func _rating_text(item: Dictionary) -> String:
|
||||
var rating_value: Variant = item.get("rating", null)
|
||||
if typeof(rating_value) != TYPE_DICTIONARY:
|
||||
return "--"
|
||||
var rating: Dictionary = rating_value
|
||||
if not bool(rating.get("rated", false)):
|
||||
return "--"
|
||||
return "%d (%+d)" % [int(rating.get("userElo", 1000)), int(rating.get("userEloDelta", 0))]
|
||||
|
||||
|
||||
func _format_local_datetime(value: String) -> String:
|
||||
if value.is_empty():
|
||||
return "--"
|
||||
var unix := Time.get_unix_time_from_datetime_string(value)
|
||||
var timezone: Dictionary = Time.get_time_zone_from_system()
|
||||
var local := Time.get_datetime_dict_from_unix_time(int(unix) + int(timezone.get("bias", 0)) * 60)
|
||||
return format_datetime_parts(local)
|
||||
|
||||
|
||||
static func format_datetime_parts(parts: Dictionary) -> String:
|
||||
const MONTHS := ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
|
||||
var month_index: int = clamp(int(parts.get("month", 1)) - 1, 0, 11)
|
||||
var hour_24 := int(parts.get("hour", 0))
|
||||
var suffix := "AM" if hour_24 < 12 else "PM"
|
||||
var hour_12 := hour_24 % 12
|
||||
if hour_12 == 0:
|
||||
hour_12 = 12
|
||||
return "%s %d, %d\n%d:%02d %s" % [MONTHS[month_index], int(parts.get("day", 1)), int(parts.get("year", 1970)), hour_12, int(parts.get("minute", 0)), suffix]
|
||||
|
||||
|
||||
static func has_active_case(attempt: Dictionary) -> bool:
|
||||
return not attempt.is_empty() and str(attempt.get("state", "")) in ["active", "final_orders"]
|
||||
|
||||
|
||||
static func remaining_time_text(attempt: Dictionary, now_unix: float = -1.0) -> String:
|
||||
var deadline := str(attempt.get("finalOrdersDeadlineUtc", "")) if str(attempt.get("state", "")) == "final_orders" else str(attempt.get("deadlineUtc", ""))
|
||||
if deadline.is_empty() or deadline == "<null>":
|
||||
return "Timed"
|
||||
var current_unix := Time.get_unix_time_from_system() if now_unix < 0.0 else now_unix
|
||||
var seconds: int = max(0, int(Time.get_unix_time_from_datetime_string(deadline) - current_unix))
|
||||
var minutes := int(ceil(float(seconds) / 60.0))
|
||||
return "%d min remaining" % minutes
|
||||
|
||||
|
||||
static func humanize(value: String) -> String:
|
||||
return value.replace("_", " ").capitalize()
|
||||
|
||||
|
||||
func _style_muted_label(label: Label, dark: bool, font_size: int) -> void:
|
||||
Brand.apply_font(label)
|
||||
label.add_theme_font_size_override("font_size", font_size)
|
||||
label.add_theme_color_override("font_color", Brand.muted_color(dark))
|
||||
|
||||
|
||||
func _style_option(option: OptionButton, dark: bool) -> void:
|
||||
Brand.apply_font(option)
|
||||
option.add_theme_font_size_override("font_size", 14)
|
||||
option.add_theme_color_override("font_color", Brand.text_color(dark))
|
||||
for state in ["normal", "hover", "pressed"]:
|
||||
option.add_theme_stylebox_override(state, Brand.secondary_button_box(dark, state != "normal"))
|
||||
|
||||
|
||||
func _style_gradient_button(button: Button) -> void:
|
||||
Brand.apply_font(button)
|
||||
button.add_theme_font_size_override("font_size", 15)
|
||||
for state in ["normal", "hover", "pressed"]:
|
||||
button.add_theme_stylebox_override(state, Brand.gradient_button_box())
|
||||
button.add_theme_color_override("font_color", Color.WHITE)
|
||||
button.add_theme_color_override("font_hover_color", Color.WHITE)
|
||||
|
||||
|
||||
func _style_outline_button(button: Button, accent: Color, dark: bool) -> void:
|
||||
Brand.apply_font(button)
|
||||
button.add_theme_font_size_override("font_size", 14)
|
||||
var normal := Style.button_box(Color(0, 0, 0, 0), accent)
|
||||
var hover := Style.button_box(Color(accent, 0.12), accent)
|
||||
button.add_theme_stylebox_override("normal", normal)
|
||||
button.add_theme_stylebox_override("hover", hover)
|
||||
button.add_theme_stylebox_override("pressed", hover)
|
||||
button.add_theme_color_override("font_color", accent if dark else accent.darkened(0.08))
|
||||
button.add_theme_color_override("font_hover_color", accent)
|
||||
button.add_theme_color_override("icon_normal_color", accent)
|
||||
|
||||
|
||||
func _section_box(dark: bool) -> StyleBoxFlat:
|
||||
var box := Brand.content_card_box(dark, false)
|
||||
box.content_margin_left = 16
|
||||
box.content_margin_right = 16
|
||||
box.content_margin_top = 14
|
||||
box.content_margin_bottom = 14
|
||||
return box
|
||||
|
||||
|
||||
func _active_card_box(dark: bool) -> StyleBoxFlat:
|
||||
var box := Brand.content_card_box(dark, true, Brand.TEAL)
|
||||
box.bg_color = Color("0c1c31") if dark else Color("f4fbfb")
|
||||
box.border_color = Color("174c58") if dark else Color("b9e7e2")
|
||||
box.content_margin_left = 16
|
||||
box.content_margin_right = 16
|
||||
box.content_margin_top = 16
|
||||
box.content_margin_bottom = 16
|
||||
return box
|
||||
|
||||
|
||||
func _notice_box(dark: bool) -> StyleBoxFlat:
|
||||
var box := StyleBoxFlat.new()
|
||||
box.bg_color = Color("101a32") if dark else Color("f5f7ff")
|
||||
box.border_color = Color("29365d") if dark else Color("dbe3f4")
|
||||
box.set_border_width_all(1)
|
||||
box.set_corner_radius_all(7)
|
||||
box.content_margin_left = 12
|
||||
box.content_margin_right = 12
|
||||
box.content_margin_top = 9
|
||||
box.content_margin_bottom = 9
|
||||
return box
|
||||
|
||||
|
||||
func _row_box(dark: bool) -> StyleBoxFlat:
|
||||
var box := StyleBoxFlat.new()
|
||||
box.bg_color = Color("0c162b") if dark else Color("ffffff")
|
||||
box.border_color = Color("273450") if dark else Color("e3e8f1")
|
||||
box.border_width_top = 1
|
||||
box.content_margin_left = 12
|
||||
box.content_margin_right = 12
|
||||
box.content_margin_top = 10
|
||||
box.content_margin_bottom = 10
|
||||
return box
|
||||
|
||||
|
||||
func _clear(container: Node) -> void:
|
||||
for child in container.get_children():
|
||||
container.remove_child(child)
|
||||
child.queue_free()
|
||||
|
||||
@@ -532,7 +532,7 @@ func _on_abandoned() -> void:
|
||||
if not leaving:
|
||||
return
|
||||
leaving = false
|
||||
main.show_screen("selection", "step3")
|
||||
main.show_screen("ccs_hub")
|
||||
|
||||
|
||||
func _request_failed(message: String) -> void:
|
||||
|
||||
@@ -36,7 +36,7 @@ func _ready() -> void:
|
||||
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 = "Another Case"; Style.style_primary_button(cases); cases.pressed.connect(_another_case); row.add_child(cases)
|
||||
var back := Button.new(); back.text = "Back to Step 3 Modes"; back.pressed.connect(func(): main.show_screen("selection", "step3")); row.add_child(back)
|
||||
var back := Button.new(); back.text = "Back to CCS Cases"; back.pressed.connect(func(): main.show_screen("ccs_hub")); row.add_child(back)
|
||||
status = Label.new(); Style.set_label(status, 14, Style.RED); row.add_child(status)
|
||||
CCSService.request_failed.connect(_request_failed)
|
||||
if not theme_service.theme_changed.is_connected(_on_theme_changed): theme_service.theme_changed.connect(_on_theme_changed)
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
extends SceneTree
|
||||
|
||||
var failures := 0
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
call_deferred("_run")
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
var hub = load("res://scripts/ui/ccs_hub.gd")
|
||||
_assert(hub.has_active_case({"state": "active"}), "active CCS attempts should be resumable")
|
||||
_assert(hub.has_active_case({"state": "final_orders"}), "final-orders CCS attempts should be resumable")
|
||||
_assert(not hub.has_active_case({"state": "completed"}), "completed CCS attempts should not appear active")
|
||||
_assert(not hub.has_active_case({"state": "abandoned"}), "abandoned CCS attempts should not appear active")
|
||||
_assert(hub.remaining_time_text({"state": "active", "deadlineUtc": "2026-07-19T12:15:00Z"}, 1784462400.0) == "15 min remaining", "active case countdown should use the case deadline")
|
||||
_assert(hub.humanize("emergency_department") == "Emergency Department", "CCS locations should be human readable")
|
||||
_assert(hub.format_datetime_parts({"year": 2026, "month": 7, "day": 19, "hour": 13, "minute": 5}) == "Jul 19, 2026\n1:05 PM", "history dates should use the compact local format")
|
||||
quit(1 if failures > 0 else 0)
|
||||
|
||||
|
||||
func _assert(condition: bool, message: String) -> void:
|
||||
if condition:
|
||||
return
|
||||
failures += 1
|
||||
push_error(message)
|
||||
@@ -0,0 +1 @@
|
||||
uid://fi27rqa8vcoj
|
||||
@@ -0,0 +1,57 @@
|
||||
extends SceneTree
|
||||
|
||||
var failures := 0
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
call_deferred("_run")
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
var service = root.get_node("CCSService")
|
||||
var theme_service = root.get_node("UIThemeService")
|
||||
var prior_dark_mode: bool = theme_service.is_dark_mode()
|
||||
service.cases = [{"externalId": "case-1"}]
|
||||
service.attempt = {"id": "active", "state": "active", "createdUtc": "2026-07-19T12:00:00Z", "deadlineUtc": "2026-07-19T12:20:00Z", "location": "emergency_department"}
|
||||
service.rating = {"elo": 1040, "ratedCaseCount": 3}
|
||||
service.prediction = {"ccsScore": 72.0, "contributingCases": 3}
|
||||
service.history = [{"id": "completed", "caseTitle": "Completed case", "state": "completed", "createdUtc": "2026-07-18T12:00:00Z", "score": 80.0}]
|
||||
service.history_next_cursor = "next-page"
|
||||
var host := Control.new()
|
||||
host.size = Vector2(1400, 800)
|
||||
root.add_child(host)
|
||||
var screen = load("res://scripts/ui/ccs_hub.gd").new()
|
||||
screen.size = host.size
|
||||
host.add_child(screen)
|
||||
await process_frame
|
||||
screen._update_responsive_layout()
|
||||
screen._render_active_section()
|
||||
screen._render_history()
|
||||
await process_frame
|
||||
_assert(screen.history_list.get_child_count() == 1, "CCS history should render terminal attempts")
|
||||
_assert(screen.load_more_button.visible, "CCS history should expose cursor pagination")
|
||||
var active_layout: BoxContainer = screen.find_child("ActiveCardLayout", true, false)
|
||||
_assert(active_layout != null and not active_layout.vertical, "wide CCS screens should use the horizontal active card")
|
||||
var primary: Button = screen.find_child("PrimaryCcsAction", true, false)
|
||||
_assert(primary != null and primary.text == "Resume Case", "an unfinished case should expose Resume Case")
|
||||
theme_service.set_dark_mode(false)
|
||||
await process_frame
|
||||
var light_box := screen.active_section.get_theme_stylebox("panel") as StyleBoxFlat
|
||||
_assert(light_box.bg_color.is_equal_approx(screen._section_box(false).bg_color), "CCS should apply its light surface after a theme change")
|
||||
screen.size = Vector2(760, 800)
|
||||
screen._update_responsive_layout()
|
||||
await process_frame
|
||||
active_layout = screen.find_child("ActiveCardLayout", true, false)
|
||||
_assert(active_layout != null and active_layout.vertical, "narrow CCS screens should stack the active card")
|
||||
_assert(not screen.table_header.visible, "narrow CCS screens should render history cards without a table header")
|
||||
host.queue_free()
|
||||
await process_frame
|
||||
theme_service.set_dark_mode(prior_dark_mode)
|
||||
quit(1 if failures > 0 else 0)
|
||||
|
||||
|
||||
func _assert(condition: bool, message: String) -> void:
|
||||
if condition:
|
||||
return
|
||||
failures += 1
|
||||
push_error(message)
|
||||
@@ -0,0 +1 @@
|
||||
uid://c64rhs53ln007
|
||||
Reference in New Issue
Block a user