From a778a680e0ecb08ee6a548eec9b5c34960cffad8 Mon Sep 17 00:00:00 2001 From: hz Date: Thu, 16 Jul 2026 22:42:03 -0500 Subject: [PATCH] Redesign exam and CCS experiences --- api/QuestionsApi.Tests/ExamSimulationTests.cs | 18 +- .../Controllers/ExamSimulationsController.cs | 20 +- api/QuestionsApi/Models/ExamSimulation.cs | 4 + .../Services/ExamSimulationStore.cs | 87 +- assets/icons/lucide/activity.svg | 3 + assets/icons/lucide/activity.svg.import | 43 + assets/icons/lucide/eye-off.svg | 7 + assets/icons/lucide/eye-off.svg.import | 43 + assets/icons/lucide/eye.svg | 4 + assets/icons/lucide/eye.svg.import | 43 + assets/icons/lucide/heart-pulse.svg | 4 + assets/icons/lucide/heart-pulse.svg.import | 43 + assets/icons/lucide/lungs.svg | 7 + assets/icons/lucide/lungs.svg.import | 43 + assets/icons/lucide/moon.svg | 3 + assets/icons/lucide/moon.svg.import | 43 + assets/icons/lucide/search.svg | 4 + assets/icons/lucide/search.svg.import | 43 + assets/icons/lucide/sun.svg | 11 + assets/icons/lucide/sun.svg.import | 43 + assets/icons/lucide/thermometer.svg | 3 + assets/icons/lucide/thermometer.svg.import | 43 + scripts/autoload/exam_simulation_service.gd | 13 +- scripts/ui/ccs_player.gd | 785 ++++++++++++++--- scripts/ui/ccs_review.gd | 3 +- scripts/ui/login_screen.gd | 19 +- scripts/ui/main.gd | 2 +- scripts/ui/medmind_style.gd | 11 + scripts/ui/question_player.gd | 8 +- scripts/ui/simulation_history.gd | 797 ++++++++++++++---- tests/ccs_player_test.gd | 124 +++ tests/ccs_player_test.gd.uid | 1 + tests/simulation_history_test.gd | 6 + tests/simulation_history_ui_test.gd | 57 ++ tests/ui_theme_test.gd | 18 + 35 files changed, 2132 insertions(+), 274 deletions(-) create mode 100644 assets/icons/lucide/activity.svg create mode 100644 assets/icons/lucide/activity.svg.import create mode 100644 assets/icons/lucide/eye-off.svg create mode 100644 assets/icons/lucide/eye-off.svg.import create mode 100644 assets/icons/lucide/eye.svg create mode 100644 assets/icons/lucide/eye.svg.import create mode 100644 assets/icons/lucide/heart-pulse.svg create mode 100644 assets/icons/lucide/heart-pulse.svg.import create mode 100644 assets/icons/lucide/lungs.svg create mode 100644 assets/icons/lucide/lungs.svg.import create mode 100644 assets/icons/lucide/moon.svg create mode 100644 assets/icons/lucide/moon.svg.import create mode 100644 assets/icons/lucide/search.svg create mode 100644 assets/icons/lucide/search.svg.import create mode 100644 assets/icons/lucide/sun.svg create mode 100644 assets/icons/lucide/sun.svg.import create mode 100644 assets/icons/lucide/thermometer.svg create mode 100644 assets/icons/lucide/thermometer.svg.import create mode 100644 tests/ccs_player_test.gd create mode 100644 tests/ccs_player_test.gd.uid create mode 100644 tests/simulation_history_ui_test.gd diff --git a/api/QuestionsApi.Tests/ExamSimulationTests.cs b/api/QuestionsApi.Tests/ExamSimulationTests.cs index b31a2af..91494cd 100644 --- a/api/QuestionsApi.Tests/ExamSimulationTests.cs +++ b/api/QuestionsApi.Tests/ExamSimulationTests.cs @@ -85,16 +85,32 @@ public sealed class ExamSimulationTests State = ExamSimulationStates.Completed, IsSample = true, Items = BuildItems(20), - Score = new ExamSimulationScore { PredictedScore = 221, RangeLow = 204, RangeHigh = 238, Correct = 15, Total = 20 } + Score = new ExamSimulationScore { PredictedScore = 221, RangeLow = 204, RangeHigh = 238, PassProbability = 0.73, Correct = 15, Total = 20 } }); Assert.Null(active.PredictedScore); Assert.Equal(3, active.CurrentBlock); Assert.Equal(221, completed.PredictedScore); Assert.Equal(15, completed.Correct); + Assert.Equal(0.73, completed.PassProbability); Assert.True(completed.IsSample); } + [Fact] + public void HistoryCursorRoundTripsTimestampAndObjectId() + { + var timestamp = new DateTime(2026, 7, 16, 22, 15, 30, DateTimeKind.Utc); + const string id = "64b1d9f36f7a4e2b8c901234"; + + var encoded = ExamSimulationStore.EncodeHistoryCursor(timestamp, id); + + Assert.True(ExamSimulationStore.TryDecodeHistoryCursor(encoded, out var decoded)); + Assert.NotNull(decoded); + Assert.Equal(timestamp, decoded.CreatedUtc); + Assert.Equal(id, decoded.Id); + Assert.False(ExamSimulationStore.TryDecodeHistoryCursor("not-a-cursor", out _)); + } + private static List BuildItems(int count, bool wasSeen = false) => Enumerable.Range(0, count).Select(index => new ExamSimulationItem { diff --git a/api/QuestionsApi/Controllers/ExamSimulationsController.cs b/api/QuestionsApi/Controllers/ExamSimulationsController.cs index 37fb258..60d52be 100644 --- a/api/QuestionsApi/Controllers/ExamSimulationsController.cs +++ b/api/QuestionsApi/Controllers/ExamSimulationsController.cs @@ -51,27 +51,33 @@ public class ExamSimulationsController(ExamSimulationStore simulations) : Contro [HttpGet("completed")] public async Task Completed([FromQuery] int limit = 20, [FromQuery] string? before = null) { - DateTime? cursor = null; + DateTime? completedBefore = null; if (!string.IsNullOrWhiteSpace(before)) { if (!DateTime.TryParse(before, null, System.Globalization.DateTimeStyles.RoundtripKind, out var parsed)) return BadRequest("The completed-simulation cursor is invalid."); - cursor = parsed; + completedBefore = parsed; } - return Ok(await simulations.GetCompletedAsync(UserId(), limit, cursor)); + return Ok(await simulations.GetCompletedAsync(UserId(), limit, completedBefore)); } [HttpGet("history")] - public async Task History([FromQuery] int limit = 20, [FromQuery] string? before = null) + public async Task History( + [FromQuery] int limit = 20, + [FromQuery] string status = "all", + [FromQuery] string sort = "newest", + [FromQuery] string? cursor = null, + [FromQuery] string? before = null) { - DateTime? cursor = null; + DateTime? legacyBefore = null; if (!string.IsNullOrWhiteSpace(before)) { if (!DateTime.TryParse(before, null, System.Globalization.DateTimeStyles.RoundtripKind, out var parsed)) return BadRequest("The simulation-history cursor is invalid."); - cursor = parsed; + legacyBefore = parsed; } - return Ok(await simulations.GetHistoryAsync(UserId(), limit, cursor)); + try { return Ok(await simulations.GetHistoryAsync(UserId(), limit, status, sort, cursor, legacyBefore)); } + catch (ArgumentException exception) { return BadRequest(exception.Message); } } [HttpGet("{id}")] diff --git a/api/QuestionsApi/Models/ExamSimulation.cs b/api/QuestionsApi/Models/ExamSimulation.cs index 801d508..fab1927 100644 --- a/api/QuestionsApi/Models/ExamSimulation.cs +++ b/api/QuestionsApi/Models/ExamSimulation.cs @@ -104,6 +104,8 @@ public class ExamSimulationStateResponse public int TotalBlocks { get; set; } = 12; public int BreakSecondsRemaining { get; set; } public DateTime? BlockDeadlineUtc { get; set; } + public DateTime CreatedUtc { get; set; } + public DateTime UpdatedUtc { get; set; } public List Questions { get; set; } = []; public List Responses { get; set; } = []; public ExamSimulationScore? Score { get; set; } @@ -151,6 +153,7 @@ public class ExamSimulationHistorySummary public int? PredictedScore { get; set; } public int? RangeLow { get; set; } public int? RangeHigh { get; set; } + public double? PassProbability { get; set; } public int? Correct { get; set; } public int Total { get; set; } } @@ -159,6 +162,7 @@ public class ExamSimulationHistoryPage { public List Items { get; set; } = []; public DateTime? NextBefore { get; set; } + public string? NextCursor { get; set; } } public class Step3Outcome diff --git a/api/QuestionsApi/Services/ExamSimulationStore.cs b/api/QuestionsApi/Services/ExamSimulationStore.cs index 7cf1d03..357e1b7 100644 --- a/api/QuestionsApi/Services/ExamSimulationStore.cs +++ b/api/QuestionsApi/Services/ExamSimulationStore.cs @@ -1,5 +1,6 @@ using MongoDB.Bson; using MongoDB.Driver; +using System.Text; using QuestionsApi.Models; namespace QuestionsApi.Services; @@ -37,6 +38,8 @@ public class ExamSimulationStore Builders.IndexKeys.Ascending(item => item.UserId).Ascending(item => item.State).Descending(item => item.CompletedUtc))); _simulations.Indexes.CreateOne(new CreateIndexModel( Builders.IndexKeys.Ascending(item => item.UserId).Descending(item => item.CreatedUtc))); + _simulations.Indexes.CreateOne(new CreateIndexModel( + Builders.IndexKeys.Ascending(item => item.UserId).Ascending(item => item.State).Descending(item => item.CreatedUtc).Descending(item => item.Id))); _simulations.Indexes.CreateOne(new CreateIndexModel( Builders.IndexKeys.Ascending(item => item.UpdatedUtc))); _outcomes.Indexes.CreateOne(new CreateIndexModel( @@ -200,22 +203,91 @@ public class ExamSimulationStore }; } - public async Task GetHistoryAsync(string userId, int limit, DateTime? before) + public async Task GetHistoryAsync( + string userId, + int limit, + string status = "all", + string sort = "newest", + string? cursor = null, + DateTime? legacyBefore = null) { limit = Math.Clamp(limit, 1, 50); - var filter = Builders.Filter.Eq(item => item.UserId, userId); - if (before is not null) - filter &= Builders.Filter.Lt(item => item.CreatedUtc, before.Value); - var simulations = await _simulations.Find(filter).SortByDescending(item => item.CreatedUtc).Limit(limit + 1).ToListAsync(); + status = status.Trim().ToLowerInvariant(); + sort = sort.Trim().ToLowerInvariant(); + if (status is not ("all" or "completed" or "abandoned")) + throw new ArgumentException("The simulation-history status is invalid."); + if (sort is not ("newest" or "oldest")) + throw new ArgumentException("The simulation-history sort order is invalid."); + + var filters = Builders.Filter; + var filter = filters.Eq(item => item.UserId, userId); + filter &= status switch + { + "completed" => filters.Eq(item => item.State, ExamSimulationStates.Completed), + "abandoned" => filters.Eq(item => item.State, ExamSimulationStates.Abandoned), + _ => filters.In(item => item.State, [ExamSimulationStates.Completed, ExamSimulationStates.Abandoned]) + }; + + HistoryCursor? decodedCursor = null; + if (!string.IsNullOrWhiteSpace(cursor)) + { + if (!TryDecodeHistoryCursor(cursor, out decodedCursor)) + throw new ArgumentException("The simulation-history cursor is invalid."); + var dateComparison = sort == "newest" + ? filters.Lt(item => item.CreatedUtc, decodedCursor!.CreatedUtc) + : filters.Gt(item => item.CreatedUtc, decodedCursor!.CreatedUtc); + var idComparison = sort == "newest" + ? filters.Lt(item => item.Id, decodedCursor!.Id) + : filters.Gt(item => item.Id, decodedCursor!.Id); + filter &= dateComparison | (filters.Eq(item => item.CreatedUtc, decodedCursor!.CreatedUtc) & idComparison); + } + else if (legacyBefore is not null) + { + filter &= filters.Lt(item => item.CreatedUtc, legacyBefore.Value); + } + + var sortDefinition = sort == "newest" + ? Builders.Sort.Descending(item => item.CreatedUtc).Descending(item => item.Id) + : Builders.Sort.Ascending(item => item.CreatedUtc).Ascending(item => item.Id); + var simulations = await _simulations.Find(filter).Sort(sortDefinition).Limit(limit + 1).ToListAsync(); var hasMore = simulations.Count > limit; if (hasMore) simulations.RemoveAt(simulations.Count - 1); + var last = simulations.LastOrDefault(); return new ExamSimulationHistoryPage { Items = simulations.Select(ToHistorySummary).ToList(), - NextBefore = hasMore && simulations.Count > 0 ? simulations[^1].CreatedUtc : null + NextBefore = sort == "newest" && hasMore && last is not null ? last.CreatedUtc : null, + 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 decoded = Encoding.UTF8.GetString(Convert.FromBase64String(value)); + var parts = decoded.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 (FormatException) + { + return false; + } + catch (ArgumentOutOfRangeException) + { + return false; + } + } + + public sealed record HistoryCursor(DateTime CreatedUtc, string Id); + public static ExamSimulationHistorySummary ToHistorySummary(ExamSimulation item) => new() { Id = item.Id ?? "", @@ -228,6 +300,7 @@ public class ExamSimulationStore PredictedScore = item.Score?.PredictedScore, RangeLow = item.Score?.RangeLow, RangeHigh = item.Score?.RangeHigh, + PassProbability = item.Score?.PassProbability, Correct = item.Score?.Correct, Total = item.Score?.Total ?? item.Items.Count }; @@ -306,6 +379,8 @@ public class ExamSimulationStore CurrentBlock = simulation.CurrentBlock, BreakSecondsRemaining = simulation.BreakSecondsRemaining, BlockDeadlineUtc = simulation.BlockDeadlineUtc, + CreatedUtc = simulation.CreatedUtc, + UpdatedUtc = simulation.UpdatedUtc, Responses = simulation.Responses, Score = simulation.Score, BlueprintTargets = simulation.BlueprintTargets, diff --git a/assets/icons/lucide/activity.svg b/assets/icons/lucide/activity.svg new file mode 100644 index 0000000..c16ed39 --- /dev/null +++ b/assets/icons/lucide/activity.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/lucide/activity.svg.import b/assets/icons/lucide/activity.svg.import new file mode 100644 index 0000000..fa3fdc4 --- /dev/null +++ b/assets/icons/lucide/activity.svg.import @@ -0,0 +1,43 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cjr7ainlyclat" +path="res://.godot/imported/activity.svg-03374e5695feca58960a0f3e00909faf.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/icons/lucide/activity.svg" +dest_files=["res://.godot/imported/activity.svg-03374e5695feca58960a0f3e00909faf.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 +svg/scale=1.0 +editor/scale_with_editor_scale=false +editor/convert_colors_with_editor_theme=false diff --git a/assets/icons/lucide/eye-off.svg b/assets/icons/lucide/eye-off.svg new file mode 100644 index 0000000..a3b139a --- /dev/null +++ b/assets/icons/lucide/eye-off.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/assets/icons/lucide/eye-off.svg.import b/assets/icons/lucide/eye-off.svg.import new file mode 100644 index 0000000..04bda7c --- /dev/null +++ b/assets/icons/lucide/eye-off.svg.import @@ -0,0 +1,43 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://c1sqo76okyx6h" +path="res://.godot/imported/eye-off.svg-4c18f086a23d70d63fd78670d1d668cc.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/icons/lucide/eye-off.svg" +dest_files=["res://.godot/imported/eye-off.svg-4c18f086a23d70d63fd78670d1d668cc.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 +svg/scale=1.0 +editor/scale_with_editor_scale=false +editor/convert_colors_with_editor_theme=false diff --git a/assets/icons/lucide/eye.svg b/assets/icons/lucide/eye.svg new file mode 100644 index 0000000..0de479c --- /dev/null +++ b/assets/icons/lucide/eye.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/lucide/eye.svg.import b/assets/icons/lucide/eye.svg.import new file mode 100644 index 0000000..c56f8a8 --- /dev/null +++ b/assets/icons/lucide/eye.svg.import @@ -0,0 +1,43 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://c1f4hplgywfax" +path="res://.godot/imported/eye.svg-72e056ec6da0b1c5dfd394acac44abd5.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/icons/lucide/eye.svg" +dest_files=["res://.godot/imported/eye.svg-72e056ec6da0b1c5dfd394acac44abd5.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 +svg/scale=1.0 +editor/scale_with_editor_scale=false +editor/convert_colors_with_editor_theme=false diff --git a/assets/icons/lucide/heart-pulse.svg b/assets/icons/lucide/heart-pulse.svg new file mode 100644 index 0000000..a0a0501 --- /dev/null +++ b/assets/icons/lucide/heart-pulse.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/lucide/heart-pulse.svg.import b/assets/icons/lucide/heart-pulse.svg.import new file mode 100644 index 0000000..b37604e --- /dev/null +++ b/assets/icons/lucide/heart-pulse.svg.import @@ -0,0 +1,43 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dxs56l67dynao" +path="res://.godot/imported/heart-pulse.svg-263cbeb9dba2b0a1b69f5c658c8b630a.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/icons/lucide/heart-pulse.svg" +dest_files=["res://.godot/imported/heart-pulse.svg-263cbeb9dba2b0a1b69f5c658c8b630a.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 +svg/scale=1.0 +editor/scale_with_editor_scale=false +editor/convert_colors_with_editor_theme=false diff --git a/assets/icons/lucide/lungs.svg b/assets/icons/lucide/lungs.svg new file mode 100644 index 0000000..f4206f7 --- /dev/null +++ b/assets/icons/lucide/lungs.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/assets/icons/lucide/lungs.svg.import b/assets/icons/lucide/lungs.svg.import new file mode 100644 index 0000000..a1a9aa1 --- /dev/null +++ b/assets/icons/lucide/lungs.svg.import @@ -0,0 +1,43 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cip5iyv1ntyd7" +path="res://.godot/imported/lungs.svg-118d3d5f35e34200268b4eb64e1bd3c7.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/icons/lucide/lungs.svg" +dest_files=["res://.godot/imported/lungs.svg-118d3d5f35e34200268b4eb64e1bd3c7.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 +svg/scale=1.0 +editor/scale_with_editor_scale=false +editor/convert_colors_with_editor_theme=false diff --git a/assets/icons/lucide/moon.svg b/assets/icons/lucide/moon.svg new file mode 100644 index 0000000..3f02d27 --- /dev/null +++ b/assets/icons/lucide/moon.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/lucide/moon.svg.import b/assets/icons/lucide/moon.svg.import new file mode 100644 index 0000000..557beae --- /dev/null +++ b/assets/icons/lucide/moon.svg.import @@ -0,0 +1,43 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://b0tuh4vychbbl" +path="res://.godot/imported/moon.svg-a61106fc950b48c4027b6af9a8fad629.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/icons/lucide/moon.svg" +dest_files=["res://.godot/imported/moon.svg-a61106fc950b48c4027b6af9a8fad629.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 +svg/scale=1.0 +editor/scale_with_editor_scale=false +editor/convert_colors_with_editor_theme=false diff --git a/assets/icons/lucide/search.svg b/assets/icons/lucide/search.svg new file mode 100644 index 0000000..328412b --- /dev/null +++ b/assets/icons/lucide/search.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/lucide/search.svg.import b/assets/icons/lucide/search.svg.import new file mode 100644 index 0000000..60ad477 --- /dev/null +++ b/assets/icons/lucide/search.svg.import @@ -0,0 +1,43 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bh7xxfss5jgst" +path="res://.godot/imported/search.svg-ee2dd97d57de904dfbd38240481dc71f.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/icons/lucide/search.svg" +dest_files=["res://.godot/imported/search.svg-ee2dd97d57de904dfbd38240481dc71f.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 +svg/scale=1.0 +editor/scale_with_editor_scale=false +editor/convert_colors_with_editor_theme=false diff --git a/assets/icons/lucide/sun.svg b/assets/icons/lucide/sun.svg new file mode 100644 index 0000000..337c3cf --- /dev/null +++ b/assets/icons/lucide/sun.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/assets/icons/lucide/sun.svg.import b/assets/icons/lucide/sun.svg.import new file mode 100644 index 0000000..0e28504 --- /dev/null +++ b/assets/icons/lucide/sun.svg.import @@ -0,0 +1,43 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://c6w13ke7tnkd6" +path="res://.godot/imported/sun.svg-a42cbc40a87e89126b82b4a767273dc2.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/icons/lucide/sun.svg" +dest_files=["res://.godot/imported/sun.svg-a42cbc40a87e89126b82b4a767273dc2.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 +svg/scale=1.0 +editor/scale_with_editor_scale=false +editor/convert_colors_with_editor_theme=false diff --git a/assets/icons/lucide/thermometer.svg b/assets/icons/lucide/thermometer.svg new file mode 100644 index 0000000..110312d --- /dev/null +++ b/assets/icons/lucide/thermometer.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/lucide/thermometer.svg.import b/assets/icons/lucide/thermometer.svg.import new file mode 100644 index 0000000..29bfebc --- /dev/null +++ b/assets/icons/lucide/thermometer.svg.import @@ -0,0 +1,43 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://ddemec5sbox25" +path="res://.godot/imported/thermometer.svg-c41011f04a9698267d48e1670b537848.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/icons/lucide/thermometer.svg" +dest_files=["res://.godot/imported/thermometer.svg-c41011f04a9698267d48e1670b537848.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 +svg/scale=1.0 +editor/scale_with_editor_scale=false +editor/convert_colors_with_editor_theme=false diff --git a/scripts/autoload/exam_simulation_service.gd b/scripts/autoload/exam_simulation_service.gd index 568e42b..b43e51b 100644 --- a/scripts/autoload/exam_simulation_service.gd +++ b/scripts/autoload/exam_simulation_service.gd @@ -17,6 +17,7 @@ var simulation := {} var outcome := {} var history: Array = [] var history_next_before := "" +var history_next_cursor := "" var is_loading := false var pending_action := "" var request_queue: Array = [] @@ -40,11 +41,11 @@ func load_active() -> void: _send("active", HTTPClient.METHOD_GET, "/active") -func load_history(before: String = "") -> void: - var suffix := "/history?limit=20" - if not before.is_empty(): - suffix += "&before=%s" % before.uri_encode() - _send("history_append" if not before.is_empty() else "history", HTTPClient.METHOD_GET, suffix) +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_simulation(simulation_id: String) -> void: @@ -175,6 +176,8 @@ func _on_request_completed(result: int, response_code: int, _headers: PackedStri history = history + items if action == "history_append" else items var next_before: Variant = parsed.get("nextBefore", null) history_next_before = "" if next_before == null else str(next_before) + var next_cursor: Variant = parsed.get("nextCursor", null) + history_next_cursor = "" if next_cursor == null else str(next_cursor) history_changed.emit() return if action.begins_with("outcome_"): diff --git a/scripts/ui/ccs_player.gd b/scripts/ui/ccs_player.gd index fea3238..12e724f 100644 --- a/scripts/ui/ccs_player.gd +++ b/scripts/ui/ccs_player.gd @@ -1,215 +1,788 @@ extends VBoxContainer const Style := preload("res://scripts/ui/ui_style.gd") +const Brand := preload("res://scripts/ui/medmind_style.gd") +const ICON_ACTIVITY := preload("res://assets/icons/lucide/activity.svg") +const ICON_HEART := preload("res://assets/icons/lucide/heart-pulse.svg") +const ICON_LUNGS := preload("res://assets/icons/lucide/lungs.svg") +const ICON_SEARCH := preload("res://assets/icons/lucide/search.svg") +const ICON_STETHOSCOPE := preload("res://assets/icons/lucide/stethoscope.svg") +const ICON_THERMOMETER := preload("res://assets/icons/lucide/thermometer.svg") +const RESPONSIVE_BREAKPOINT := 1050.0 + var main: Control +var header_panel: PanelContainer +var header_layout: BoxContainer +var header_icon_panel: PanelContainer var heading: Label -var clock: Label +var toolbar: HFlowContainer +var workspace: BoxContainer +var chart_panel: PanelContainer +var clerk_panel: PanelContainer var summary: RichTextLabel +var history: RichTextLabel +var appearance: RichTextLabel var chart: RichTextLabel +var vitals_flow: HFlowContainer var orders: VBoxContainer var search: LineEdit +var search_button: Button +var search_results_panel: PanelContainer var search_results: VBoxContainer var actions: VBoxContainer +var locations: HFlowContainer +var time_row: GridContainer var status: Label +var final_orders_notice: Label +var clock: Label var timer: Timer var end_button: Button -var time_row: HBoxContainer var report_dialog: Control var report_button: Button var app_feedback_button: Button -var workspace: HSplitContainer var leave_button: Button var leaving := false var theme_service var theme_button: Button +var section_labels: Array[Label] = [] +var toolbar_buttons: Array[Button] = [] + func _ready() -> void: theme_service = get_node("/root/UIThemeService") - set_anchors_preset(Control.PRESET_FULL_RECT); add_theme_constant_override("separation", 8) - 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 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) - app_feedback_button = Button.new(); app_feedback_button.text = "Feedback"; app_feedback_button.tooltip_text = "Send general feedback or report a bug"; app_feedback_button.pressed.connect(func(): main.open_feedback_dialog()); top.add_child(app_feedback_button) - theme_button = Button.new(); theme_button.custom_minimum_size = Vector2(44, 44); theme_button.add_theme_font_size_override("font_size", 22); UISoundService.set_button_sound(theme_button, "toggle"); theme_button.pressed.connect(theme_service.toggle_theme); top.add_child(theme_button); _refresh_theme_button() - workspace = HSplitContainer.new(); workspace.size_flags_vertical = Control.SIZE_EXPAND_FILL; workspace.split_offset = 850; add_child(workspace) - var chart_scroll := ScrollContainer.new(); chart_scroll.custom_minimum_size.x = 500; chart_scroll.size_flags_horizontal = Control.SIZE_EXPAND_FILL; workspace.add_child(chart_scroll) - var chart_panel := VBoxContainer.new(); chart_panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL; chart_panel.add_theme_constant_override("separation", 18); chart_scroll.add_child(chart_panel) - summary = RichTextLabel.new(); summary.bbcode_enabled = true; summary.selection_enabled = true; summary.fit_content = true; summary.scroll_active = false; 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.fit_content = true; chart.scroll_active = false; 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) - var find := Button.new(); find.text = "Search"; find.pressed.connect(func(): CCSService.search_orders(search.text)); clerk.add_child(find) - search_results = VBoxContainer.new(); clerk.add_child(search_results) - var active_title := Label.new(); active_title.text = "Active orders"; Style.set_label(active_title, 18, Style.TEXT); clerk.add_child(active_title) - orders = VBoxContainer.new(); clerk.add_child(orders) - var exam_title := Label.new(); exam_title.text = "Physical examination"; Style.set_label(exam_title, 18, Style.TEXT); clerk.add_child(exam_title) - actions = VBoxContainer.new(); clerk.add_child(actions) - time_row = HBoxContainer.new(); clerk.add_child(time_row) + set_anchors_preset(Control.PRESET_FULL_RECT) + add_theme_constant_override("separation", 10) + _build_header() + _build_workspace() + _build_runtime_services() + resized.connect(_update_responsive_layout) + if not theme_service.theme_changed.is_connected(_on_theme_changed): + theme_service.theme_changed.connect(_on_theme_changed) + _apply_theme() + call_deferred("_update_responsive_layout") + + +func _build_header() -> void: + header_panel = PanelContainer.new() + add_child(header_panel) + var margin := MarginContainer.new() + margin.add_theme_constant_override("margin_left", 12) + margin.add_theme_constant_override("margin_right", 12) + margin.add_theme_constant_override("margin_top", 8) + margin.add_theme_constant_override("margin_bottom", 8) + header_panel.add_child(margin) + header_layout = BoxContainer.new() + header_layout.vertical = false + header_layout.add_theme_constant_override("separation", 12) + margin.add_child(header_layout) + + var title_row := HBoxContainer.new() + title_row.size_flags_horizontal = Control.SIZE_EXPAND_FILL + title_row.add_theme_constant_override("separation", 10) + header_layout.add_child(title_row) + header_icon_panel = PanelContainer.new() + header_icon_panel.custom_minimum_size = Vector2(38, 38) + title_row.add_child(header_icon_panel) + var header_icon := TextureRect.new() + header_icon.texture = ICON_STETHOSCOPE + header_icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE + header_icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED + header_icon.custom_minimum_size = Vector2(22, 22) + header_icon.modulate = Brand.SKY_BLUE + header_icon_panel.add_child(header_icon) + heading = Label.new() + heading.text = "CCS Case" + heading.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _set_label(heading, 18, Brand.text_color(true)) + title_row.add_child(heading) + + toolbar = HFlowContainer.new() + toolbar.size_flags_horizontal = Control.SIZE_SHRINK_END + toolbar.custom_minimum_size.x = 440 + toolbar.add_theme_constant_override("h_separation", 8) + toolbar.add_theme_constant_override("v_separation", 8) + header_layout.add_child(toolbar) + var smaller := _toolbar_button("A-", "Decrease case text size") + smaller.pressed.connect(_zoom.bind(-CCSService.TEXT_SCALE_STEP)) + var larger := _toolbar_button("A+", "Increase case text size") + larger.pressed.connect(_zoom.bind(CCSService.TEXT_SCALE_STEP)) + var clear_highlights := _toolbar_button("Clear", "Clear case highlights") + clear_highlights.pressed.connect(_clear_highlights) + report_button = _toolbar_button("Report", "Report this CCS case") + report_button.pressed.connect(_report) + app_feedback_button = _toolbar_button("Feedback", "Send general feedback or report a bug") + app_feedback_button.pressed.connect(func(): main.open_feedback_dialog()) + theme_button = _toolbar_button("", "Switch color theme") + theme_button.custom_minimum_size = Vector2(44, 38) + UISoundService.set_button_sound(theme_button, "toggle") + theme_button.pressed.connect(theme_service.toggle_theme) + + +func _toolbar_button(text_value: String, tooltip: String) -> Button: + var button := Button.new() + button.text = text_value + button.tooltip_text = tooltip + button.custom_minimum_size = Vector2(54, 38) + button.add_theme_font_override("font", Brand.font) + button.add_theme_font_size_override("font_size", 14) + toolbar.add_child(button) + toolbar_buttons.append(button) + return button + + +func _build_workspace() -> void: + var page_scroll := ScrollContainer.new() + page_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL + page_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED + add_child(page_scroll) + workspace = BoxContainer.new() + workspace.size_flags_horizontal = Control.SIZE_EXPAND_FILL + workspace.size_flags_vertical = Control.SIZE_EXPAND_FILL + workspace.add_theme_constant_override("separation", 10) + page_scroll.add_child(workspace) + _build_chart_panel() + _build_order_clerk() + + +func _build_chart_panel() -> void: + chart_panel = PanelContainer.new() + chart_panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL + chart_panel.size_flags_vertical = Control.SIZE_EXPAND_FILL + workspace.add_child(chart_panel) + var margin := MarginContainer.new() + margin.add_theme_constant_override("margin_left", 18) + margin.add_theme_constant_override("margin_right", 18) + margin.add_theme_constant_override("margin_top", 16) + margin.add_theme_constant_override("margin_bottom", 18) + chart_panel.add_child(margin) + var content := VBoxContainer.new() + content.add_theme_constant_override("separation", 14) + margin.add_child(content) + + summary = _clinical_text("summary", 17) + content.add_child(summary) + vitals_flow = HFlowContainer.new() + vitals_flow.add_theme_constant_override("h_separation", 10) + vitals_flow.add_theme_constant_override("v_separation", 8) + content.add_child(vitals_flow) + content.add_child(HSeparator.new()) + _add_chart_section(content, "History", "history") + history = _clinical_text("history", 16) + content.add_child(history) + _add_chart_section(content, "Appearance", "appearance") + appearance = _clinical_text("appearance", 16) + content.add_child(appearance) + _add_chart_section(content, "Chart", "chart") + chart = _clinical_text("chart", 16) + content.add_child(chart) + + +func _add_chart_section(parent: VBoxContainer, text_value: String, region: String) -> void: + var label := Label.new() + label.name = "%sHeading" % region.capitalize() + label.text = text_value + _set_label(label, 15, Brand.PURPLE) + parent.add_child(label) + section_labels.append(label) + + +func _clinical_text(region: String, font_size: int) -> RichTextLabel: + var label := RichTextLabel.new() + label.bbcode_enabled = true + label.selection_enabled = true + label.fit_content = true + label.scroll_active = false + label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + label.add_theme_font_override("normal_font", Brand.font) + label.add_theme_font_override("bold_font", Brand.font) + label.add_theme_font_size_override("normal_font_size", font_size) + label.add_theme_font_size_override("bold_font_size", font_size) + label.set_meta("ccs_base_font_size", font_size) + label.set_meta("ccs_region", region) + label.gui_input.connect(_text_input.bind(label)) + return label + + +func _build_order_clerk() -> void: + clerk_panel = PanelContainer.new() + clerk_panel.custom_minimum_size = Vector2(370, 0) + clerk_panel.size_flags_vertical = Control.SIZE_EXPAND_FILL + workspace.add_child(clerk_panel) + var margin := MarginContainer.new() + margin.add_theme_constant_override("margin_left", 14) + margin.add_theme_constant_override("margin_right", 14) + margin.add_theme_constant_override("margin_top", 12) + margin.add_theme_constant_override("margin_bottom", 14) + clerk_panel.add_child(margin) + var clerk := VBoxContainer.new() + clerk.add_theme_constant_override("separation", 8) + margin.add_child(clerk) + var order_title := Label.new() + order_title.text = "Order Clerk" + _set_label(order_title, 18, Brand.PURPLE) + clerk.add_child(order_title) + section_labels.append(order_title) + var search_help := Label.new() + search_help.text = "Search tests, treatments, and procedures" + _set_label(search_help, 12, Brand.muted_color(true)) + search_help.set_meta("ccs_muted_label", true) + clerk.add_child(search_help) + + var search_row := HBoxContainer.new() + search_row.add_theme_constant_override("separation", 6) + clerk.add_child(search_row) + search = LineEdit.new() + search.placeholder_text = "Search..." + search.size_flags_horizontal = Control.SIZE_EXPAND_FILL + search.text_submitted.connect(func(_value: String): _submit_search()) + search_row.add_child(search) + search_button = Button.new() + search_button.icon = ICON_SEARCH + search_button.tooltip_text = "Search order catalog" + search_button.custom_minimum_size = Vector2(42, 42) + search_button.pressed.connect(_submit_search) + search_row.add_child(search_button) + + search_results_panel = PanelContainer.new() + search_results_panel.visible = false + clerk.add_child(search_results_panel) + var results_scroll := ScrollContainer.new() + results_scroll.custom_minimum_size.y = 150 + search_results_panel.add_child(results_scroll) + search_results = VBoxContainer.new() + search_results.size_flags_horizontal = Control.SIZE_EXPAND_FILL + search_results.add_theme_constant_override("separation", 5) + results_scroll.add_child(search_results) + + var active_title := _clerk_heading("Active orders") + clerk.add_child(active_title) + orders = VBoxContainer.new() + orders.add_theme_constant_override("separation", 5) + clerk.add_child(orders) + var exam_title := _clerk_heading("Physical examination") + clerk.add_child(exam_title) + actions = VBoxContainer.new() + actions.add_theme_constant_override("separation", 5) + clerk.add_child(actions) + locations = HFlowContainer.new() + locations.add_theme_constant_override("h_separation", 5) + locations.add_theme_constant_override("v_separation", 5) + clerk.add_child(locations) + time_row = GridContainer.new() + time_row.columns = 4 + time_row.add_theme_constant_override("h_separation", 5) + time_row.add_theme_constant_override("v_separation", 5) + clerk.add_child(time_row) for minutes in [1, 10, 60, 1440]: - var button := Button.new(); button.text = "+%s" % ("1 day" if minutes == 1440 else "%d min" % minutes); button.pressed.connect(CCSService.send_command.bind("advance", "", minutes)); time_row.add_child(button) - var reassess := Button.new(); reassess.text = "Interval history"; reassess.pressed.connect(CCSService.send_command.bind("interval_history", "", 0)); clerk.add_child(reassess) - end_button = Button.new(); end_button.text = "End case"; Style.style_chip(end_button, Style.AMBER); end_button.pressed.connect(_end_case); clerk.add_child(end_button) - leave_button = Button.new(); leave_button.text = "Leave case"; Style.style_chip(leave_button, Style.RED); leave_button.pressed.connect(_leave); clerk.add_child(leave_button) - status = Label.new(); Style.set_label(status, 14, Style.RED); add_child(status) - timer = Timer.new(); timer.wait_time = 1; timer.timeout.connect(_tick); add_child(timer) + var button := Button.new() + button.text = "+%s" % ("1 day" if minutes == 1440 else "%d min" % minutes) + button.size_flags_horizontal = Control.SIZE_EXPAND_FILL + button.pressed.connect(CCSService.send_command.bind("advance", "", minutes)) + time_row.add_child(button) + var reassess := Button.new() + reassess.text = "Interval history" + reassess.pressed.connect(CCSService.send_command.bind("interval_history", "", 0)) + clerk.add_child(reassess) + end_button = Button.new() + end_button.text = "End case" + end_button.pressed.connect(_end_case) + clerk.add_child(end_button) + leave_button = Button.new() + leave_button.text = "Leave case" + leave_button.pressed.connect(_leave) + clerk.add_child(leave_button) + final_orders_notice = Label.new() + final_orders_notice.visible = false + final_orders_notice.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + clerk.add_child(final_orders_notice) + clock = Label.new() + clock.visible = false + clerk.add_child(clock) + status = Label.new() + status.visible = false + status.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + clerk.add_child(status) + + +func _clerk_heading(text_value: String) -> Label: + var label := Label.new() + label.text = text_value + _set_label(label, 13, Brand.PURPLE) + section_labels.append(label) + return label + + +func _build_runtime_services() -> void: + timer = Timer.new() + timer.wait_time = 1 + timer.timeout.connect(_tick) + add_child(timer) CCSService.attempt_changed.connect(_render) CCSService.attempt_abandoned.connect(_on_abandoned) CCSService.orders_changed.connect(_render_search) CCSService.request_failed.connect(_request_failed) CCSService.report_submitted.connect(_report_submitted) - if not theme_service.theme_changed.is_connected(_on_theme_changed): theme_service.theme_changed.connect(_on_theme_changed) - report_dialog = preload("res://scripts/ui/ccs_report_dialog.gd").new(); add_child(report_dialog) + report_dialog = preload("res://scripts/ui/ccs_report_dialog.gd").new() + add_child(report_dialog) + func on_show(payload: Variant = null) -> void: - status.text = "" + _set_status("") leaving = false leave_button.disabled = false - if typeof(payload) == TYPE_STRING and not str(payload).is_empty(): CCSService.load_attempt(str(payload)) - _render(); timer.start() + if typeof(payload) == TYPE_STRING and not str(payload).is_empty(): + CCSService.load_attempt(str(payload)) + _render() + timer.start() + + +func on_hide() -> void: + timer.stop() -func on_hide() -> void: timer.stop() 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 = "CCS Case | %s" % str(attempt.get("location", "")).capitalize() + if attempt.is_empty(): + return + if str(attempt.get("state", "")) == "completed": + main.show_screen("ccs_review", attempt) + return + heading.text = "CCS Case | %s" % _humanize_code(str(attempt.get("location", ""))) _render_study_text() - _clear(orders) - for code in attempt.get("activeOrderCodes", []): - var row := HBoxContainer.new(); orders.add_child(row) - var label := Label.new(); label.text = str(code); label.size_flags_horizontal = Control.SIZE_EXPAND_FILL; row.add_child(label) - var cancel := Button.new(); cancel.text = "Cancel"; cancel.pressed.connect(CCSService.send_command.bind("cancel_order", str(code), 0)); row.add_child(cancel) - _clear(actions) - for exam in attempt.get("availableExams", []): - var button := Button.new(); button.text = str(exam.get("name", exam.get("code", "Exam"))); button.pressed.connect(CCSService.send_command.bind("exam", str(exam.get("code", "")), 0)); actions.add_child(button) - for location in attempt.get("allowedLocations", []): - var button := Button.new(); button.text = "Move to %s" % str(location).capitalize(); button.pressed.connect(CCSService.send_command.bind("location", str(location), 0)); actions.add_child(button) + _render_vitals() + _render_orders() + _render_commands() + var final_orders := str(attempt.get("state", "")) == "final_orders" + end_button.text = "Submit final orders" if final_orders else "End case" + time_row.visible = not final_orders + final_orders_notice.visible = final_orders + final_orders_notice.text = "Finalize or cancel patient-care orders, then submit." _tick() - 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_vitals() -> void: + _clear(vitals_flow) + var vitals: Dictionary = CCSService.attempt.get("vitals", {}) + for key in _ordered_vital_keys(vitals): + var chip := PanelContainer.new() + chip.add_theme_stylebox_override("panel", _vital_chip_box()) + vitals_flow.add_child(chip) + var row := HBoxContainer.new() + row.add_theme_constant_override("separation", 7) + chip.add_child(row) + var icon := TextureRect.new() + icon.texture = _vital_icon(key) + icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE + icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED + icon.custom_minimum_size = Vector2(18, 18) + icon.modulate = _vital_color(key) + row.add_child(icon) + var label := Label.new() + label.text = "%s: %s" % [_vital_label(key), str(vitals[key])] + _set_label(label, 13, Brand.text_color(_is_dark())) + row.add_child(label) + + +func _ordered_vital_keys(vitals: Dictionary) -> Array[String]: + var ordered: Array[String] = [] + for key in ["temperature", "bloodPressure", "pulse", "respirations", "oxygenSaturation"]: + if vitals.has(key): + ordered.append(key) + var remaining: Array[String] = [] + for raw_key in vitals.keys(): + var key := str(raw_key) + if not key in ordered: + remaining.append(key) + remaining.sort() + ordered.append_array(remaining) + return ordered + + +func _vital_label(key: String) -> String: + match key: + "bloodPressure": return "Blood Pressure" + "oxygenSaturation": return "Oxygen Saturation" + _: return _humanize_code(key) + + +func _vital_icon(key: String) -> Texture2D: + match key: + "temperature": return ICON_THERMOMETER + "bloodPressure", "pulse": return ICON_HEART + "respirations": return ICON_LUNGS + _: return ICON_ACTIVITY + + +func _vital_color(key: String) -> Color: + match key: + "temperature", "oxygenSaturation": return Brand.SKY_BLUE + "bloodPressure", "pulse": return Color("fb7185") + _: return Brand.PURPLE + + +func _render_orders() -> void: + _clear(orders) + var active_codes: Array = CCSService.attempt.get("activeOrderCodes", []) + if active_codes.is_empty(): + var empty := Label.new() + empty.text = "No active orders" + _set_label(empty, 12, Brand.muted_color(_is_dark())) + orders.add_child(empty) + return + for raw_code in active_codes: + var code := str(raw_code) + var row := HBoxContainer.new() + row.add_theme_constant_override("separation", 6) + orders.add_child(row) + var label := Label.new() + label.text = _humanize_code(code) + label.size_flags_horizontal = Control.SIZE_EXPAND_FILL + label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + _set_label(label, 13, Brand.text_color(_is_dark())) + row.add_child(label) + var cancel := Button.new() + cancel.text = "Cancel" + cancel.pressed.connect(CCSService.send_command.bind("cancel_order", code, 0)) + _style_secondary_button(cancel) + row.add_child(cancel) + + +func _render_commands() -> void: + _clear(actions) + for exam in CCSService.attempt.get("availableExams", []): + if typeof(exam) != TYPE_DICTIONARY: + continue + var button := Button.new() + button.text = str(exam.get("name", _humanize_code(str(exam.get("code", "Exam"))))) + button.pressed.connect(CCSService.send_command.bind("exam", str(exam.get("code", "")), 0)) + _style_secondary_button(button) + actions.add_child(button) + _clear(locations) + for raw_location in CCSService.attempt.get("allowedLocations", []): + var location := str(raw_location) + var button := Button.new() + button.text = "Move to %s" % _humanize_code(location) + button.custom_minimum_size.x = 150 + button.size_flags_horizontal = Control.SIZE_EXPAND_FILL + button.pressed.connect(CCSService.send_command.bind("location", location, 0)) + _style_secondary_button(button) + locations.add_child(button) + _style_command_buttons() + + +func _submit_search() -> void: + if search.text.strip_edges().length() < 2: + _set_status("Enter at least two characters to search.") + return + _set_status("") + CCSService.search_orders(search.text) + + func _render_search() -> void: _clear(search_results) + search_results_panel.visible = not CCSService.orders.is_empty() 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) + if typeof(order) != TYPE_DICTIONARY: + continue + var button := Button.new() + button.text = "%s (%s)" % [order.get("canonicalName", ""), _humanize_code(str(order.get("category", "")))] + button.alignment = HORIZONTAL_ALIGNMENT_LEFT + button.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + button.pressed.connect(_place_order.bind(str(order.get("code", "")))) + _style_secondary_button(button) + search_results.add_child(button) + func _place_order(code: String) -> void: - CCSService.send_command("order", code); search.text = ""; CCSService.orders = []; _render_search() + CCSService.send_command("order", code) + search.text = "" + CCSService.orders = [] + _render_search() + func _tick() -> void: var deadline := CCSService.deadline_unix() + var final_orders := str(CCSService.attempt.get("state", "")) == "final_orders" + clock.visible = final_orders and deadline > 0 if deadline <= 0: - clock.visible = false clock.text = "" return - clock.visible = true var remaining := maxi(0, int(ceil(deadline - Time.get_unix_time_from_system()))) - clock.text = "%02d:%02d" % [floori(float(remaining) / 60.0), remaining % 60] - if remaining == 0 and not CCSService.is_loading: CCSService.load_attempt(str(CCSService.attempt.get("id", ""))) + if final_orders: + clock.text = "Final orders: %02d:%02d remaining" % [floori(float(remaining) / 60.0), remaining % 60] + if remaining == 0 and not CCSService.is_loading: + CCSService.load_attempt(str(CCSService.attempt.get("id", ""))) + func leave_case() -> void: _leave() + func _leave() -> void: - if leaving or CCSService.attempt.is_empty(): return + if leaving or CCSService.attempt.is_empty(): + return leaving = true leave_button.disabled = true - status.text = "Leaving case..." + _set_status("Leaving case...", false) CCSService.abandon() + func _on_abandoned() -> void: - if not leaving: return + if not leaving: + return leaving = false main.show_screen("selection", "step3") + func _request_failed(message: String) -> void: - if not visible: return - status.text = message + if not visible: + return + _set_status(message) if leaving: leaving = false leave_button.disabled = false + func _end_case() -> void: CCSService.send_command("exit_case" if str(CCSService.attempt.get("state", "")) == "final_orders" else "end_case") -func _clear(parent: Node) -> void: - for child in parent.get_children(): child.queue_free() -func _vitals_text(vitals: Dictionary) -> String: - var parts: Array[String] = [] - for key in vitals: parts.append("%s: %s" % [str(key), str(vitals[key])]) - return " | ".join(parts) +func _clear(parent: Node) -> void: + for child in parent.get_children(): + parent.remove_child(child) + child.queue_free() + 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", "")]) + for entry in entries: + if typeof(entry) != TYPE_DICTIONARY: + continue + var category := str(entry.get("category", "note")) + var category_label := "In Progress" if category == "progress" else _humanize_code(category) + lines.append("T+%d (%s): %s" % [entry.get("simulatedMinute", 0), category_label, 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", {}))] + return "%s\n%s" % [attempt.get("patientSummary", ""), attempt.get("reasonForEncounter", "")] -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()) + var attempt_id := str(CCSService.attempt.get("id", "")) + summary.text = CCSService.highlighted_text(attempt_id, "summary", _summary_plain()) + history.text = CCSService.highlighted_text(attempt_id, "history", str(CCSService.attempt.get("history", ""))) + appearance.text = CCSService.highlighted_text(attempt_id, "appearance", str(CCSService.attempt.get("appearance", ""))) + chart.text = CCSService.highlighted_text(attempt_id, "chart", _chart_text(CCSService.attempt.get("chart", []))) + 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() + 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() + CCSService.adjust_text_scale(delta) + _apply_text_scale() + func _apply_text_scale() -> void: - var font_size := int(round(18.0 * CCSService.text_scale)) - for label in [summary, chart]: - label.add_theme_font_size_override("normal_font_size", font_size); label.add_theme_font_size_override("bold_font_size", font_size) - _scale_controls(workspace) + for label in [summary, history, appearance, chart]: + var base_size := int(label.get_meta("ccs_base_font_size", 16)) + var font_size := int(round(float(base_size) * CCSService.text_scale)) + label.add_theme_font_size_override("normal_font_size", font_size) + label.add_theme_font_size_override("bold_font_size", font_size) -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() + 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" + report_button.tooltip_text = "Report submitted; report another issue" + _style_outline_button(report_button, Brand.SUCCESS) + + +func _set_status(message: String, is_error: bool = true) -> void: + if status == null: + return + status.text = message + status.visible = not message.is_empty() + status.add_theme_color_override("font_color", Color("fb7185") if is_error else Brand.SKY_BLUE) + + +func _humanize_code(value: String) -> String: + return value.replace("_", " ").replace("-", " ").capitalize() + + +func _update_responsive_layout() -> void: + if workspace == null or header_layout == null: + return + var narrow := size.x > 0 and size.x < RESPONSIVE_BREAKPOINT + workspace.vertical = narrow + header_layout.vertical = narrow + toolbar.custom_minimum_size.x = 0 if narrow else 440 + toolbar.size_flags_horizontal = Control.SIZE_EXPAND_FILL if narrow else Control.SIZE_SHRINK_END + chart_panel.custom_minimum_size.x = 0 if narrow else 600 + clerk_panel.custom_minimum_size.x = 0 if narrow else 370 + clerk_panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL if narrow else Control.SIZE_SHRINK_END + queue_sort() + func _on_theme_changed(_is_dark_mode: bool) -> void: - _refresh_theme_button() + call_deferred("_finish_theme_change") -func _refresh_theme_button() -> void: - if theme_button == null: return - theme_button.text = "☀" if theme_service.is_dark_mode() else "☾" - theme_button.tooltip_text = "Switch to light mode" if theme_service.is_dark_mode() else "Switch to dark mode" - Style.style_chip(theme_button, Style.CYAN) + +func _finish_theme_change() -> void: + _apply_theme() + if visible and not CCSService.attempt.is_empty(): + _render() + + +func _apply_theme() -> void: + var dark := _is_dark() + if header_panel == null: + return + header_panel.add_theme_stylebox_override("panel", Brand.content_card_box(dark, false)) + chart_panel.add_theme_stylebox_override("panel", Brand.content_card_box(dark, false)) + clerk_panel.add_theme_stylebox_override("panel", Brand.content_card_box(dark, false)) + header_icon_panel.add_theme_stylebox_override("panel", Brand.icon_circle_box(dark, Brand.PURPLE, 6, true)) + heading.add_theme_color_override("font_color", Brand.text_color(dark)) + for label in section_labels: + label.add_theme_color_override("font_color", Brand.PURPLE if dark else Brand.ROYAL_BLUE) + for node in find_children("*", "Label", true, false): + var label := node as Label + if label.has_meta("ccs_muted_label"): + label.add_theme_color_override("font_color", Brand.muted_color(dark)) + for label in [summary, history, appearance, chart]: + label.add_theme_color_override("default_color", Brand.text_color(dark)) + search.add_theme_font_override("font", Brand.font) + search.add_theme_color_override("font_color", Brand.text_color(dark)) + search.add_theme_color_override("font_placeholder_color", Brand.muted_color(dark)) + search.add_theme_stylebox_override("normal", _compact_field_box(dark)) + search.add_theme_stylebox_override("focus", _compact_field_box(dark, true)) + search_results_panel.add_theme_stylebox_override("panel", Brand.content_card_box(dark, false)) + for button in toolbar_buttons: + _style_secondary_button(button) + _style_icon_button(search_button) + _style_outline_button(end_button, Brand.AMBER) + _style_outline_button(leave_button, Color("fb7185")) + for button in time_row.get_children(): + if button is Button: + _style_secondary_button(button) + Brand.set_theme_toggle_icon(theme_button, dark) + theme_button.tooltip_text = "Switch to light mode" if dark else "Switch to dark mode" + final_orders_notice.add_theme_color_override("font_color", Brand.AMBER) + clock.add_theme_color_override("font_color", Brand.AMBER) + _render_vitals() + + +func _style_command_buttons() -> void: + for container in [actions, locations]: + for child in container.get_children(): + if child is Button: + _style_secondary_button(child) + var reassess := time_row.get_parent().get_child(time_row.get_index() + 1) as Button + if reassess != null: + _style_secondary_button(reassess) + + +func _style_secondary_button(button: Button) -> void: + var dark := _is_dark() + button.add_theme_font_override("font", Brand.font) + button.add_theme_font_size_override("font_size", 13) + button.add_theme_color_override("font_color", Brand.text_color(dark)) + button.add_theme_color_override("font_hover_color", Brand.text_color(dark)) + button.add_theme_stylebox_override("normal", _compact_button_box(dark)) + button.add_theme_stylebox_override("hover", _compact_button_box(dark, true)) + button.add_theme_stylebox_override("pressed", _compact_button_box(dark, true)) + + +func _style_icon_button(button: Button) -> void: + _style_secondary_button(button) + button.add_theme_constant_override("icon_max_width", 20) + var color := Brand.SKY_BLUE if _is_dark() else Brand.ROYAL_BLUE + for color_name in ["icon_normal_color", "icon_hover_color", "icon_pressed_color", "icon_focus_color"]: + button.add_theme_color_override(color_name, color) + + +func _style_outline_button(button: Button, accent: Color) -> void: + var dark := _is_dark() + var normal := _compact_button_box(dark) + normal.border_color = accent + var hover := normal.duplicate() as StyleBoxFlat + hover.bg_color = Color(accent, 0.1) + button.add_theme_font_override("font", Brand.font) + button.add_theme_font_size_override("font_size", 13) + button.add_theme_color_override("font_color", accent) + button.add_theme_color_override("font_hover_color", accent) + button.add_theme_stylebox_override("normal", normal) + button.add_theme_stylebox_override("hover", hover) + button.add_theme_stylebox_override("pressed", hover) + + +func _compact_field_box(dark: bool, focused: bool = false) -> StyleBoxFlat: + var box := StyleBoxFlat.new() + box.bg_color = Color("0d172a") if dark else Brand.LIGHT_SURFACE + box.border_color = Brand.ROYAL_BLUE if focused else (Color("33415d") if dark else Brand.LIGHT_BORDER) + box.set_border_width_all(1) + box.set_corner_radius_all(6) + box.content_margin_left = 12 + box.content_margin_right = 12 + box.content_margin_top = 8 + box.content_margin_bottom = 8 + return box + + +func _compact_button_box(dark: bool, hover: bool = false) -> StyleBoxFlat: + var box := _compact_field_box(dark, hover) + box.bg_color = Color("111b30") if dark else Brand.LIGHT_SURFACE + return box + + +func _vital_chip_box() -> StyleBoxFlat: + var dark := _is_dark() + var box := StyleBoxFlat.new() + box.bg_color = Color("111b30") if dark else Color("f8faff") + box.border_color = Color("33415d") if dark else Brand.LIGHT_BORDER + box.set_border_width_all(1) + box.set_corner_radius_all(6) + box.content_margin_left = 10 + box.content_margin_right = 10 + box.content_margin_top = 8 + box.content_margin_bottom = 8 + return box + + +func _set_label(label: Label, font_size: int, color: Color) -> void: + label.add_theme_font_override("font", Brand.font) + label.add_theme_font_size_override("font_size", font_size) + label.add_theme_color_override("font_color", color) + + +func _is_dark() -> bool: + return theme_service != null and theme_service.is_dark_mode() diff --git a/scripts/ui/ccs_review.gd b/scripts/ui/ccs_review.gd index 15941e4..9ca5067 100644 --- a/scripts/ui/ccs_review.gd +++ b/scripts/ui/ccs_review.gd @@ -1,6 +1,7 @@ extends VBoxContainer const Style := preload("res://scripts/ui/ui_style.gd") +const Brand := preload("res://scripts/ui/medmind_style.gd") var main: Control var title: Label var score_body: RichTextLabel @@ -170,6 +171,6 @@ func _on_theme_changed(_is_dark_mode: bool) -> void: func _refresh_theme_button() -> void: if theme_button == null: return - theme_button.text = "☀" if theme_service.is_dark_mode() else "☾" + Brand.set_theme_toggle_icon(theme_button, theme_service.is_dark_mode()) theme_button.tooltip_text = "Switch to light mode" if theme_service.is_dark_mode() else "Switch to dark mode" Style.style_chip(theme_button, Style.CYAN) diff --git a/scripts/ui/login_screen.gd b/scripts/ui/login_screen.gd index 2602226..ff2607c 100644 --- a/scripts/ui/login_screen.gd +++ b/scripts/ui/login_screen.gd @@ -21,6 +21,7 @@ var reset_view: VBoxContainer var steam_view: VBoxContainer var login_input: LineEdit var password_input: LineEdit +var password_reveal_button: Button var registration_username_input: LineEdit var registration_email_input: LineEdit var registration_password_input: LineEdit @@ -335,20 +336,32 @@ func _add_show_password(parent: VBoxContainer, targets: Array) -> void: func _add_password_reveal(input: LineEdit) -> void: var reveal := Button.new() - reveal.text = "\u25c9" + password_reveal_button = reveal + reveal.text = "" reveal.tooltip_text = "Show password" reveal.flat = true reveal.focus_mode = Control.FOCUS_NONE reveal.custom_minimum_size = Vector2(52, 52) reveal.set_anchors_preset(Control.PRESET_CENTER_RIGHT) reveal.position = Vector2(-60, -26) + _refresh_password_reveal(input, reveal) reveal.pressed.connect(func(): input.secret = not input.secret reveal.tooltip_text = "Show password" if input.secret else "Hide password" + _refresh_password_reveal(input, reveal) ) input.add_child(reveal) +func _refresh_password_reveal(input: LineEdit, reveal: Button) -> void: + reveal.icon = preload("res://assets/icons/lucide/eye.svg") if input.secret else preload("res://assets/icons/lucide/eye-off.svg") + reveal.expand_icon = false + reveal.add_theme_constant_override("icon_max_width", 24) + var icon_color := Brand.muted_color(UIThemeService.is_dark_mode()) + for color_name in ["icon_normal_color", "icon_hover_color", "icon_pressed_color", "icon_focus_color"]: + reveal.add_theme_color_override(color_name, icon_color) + + func _add_primary_button(parent: VBoxContainer, text_value: String, callback: Callable) -> Button: var button := Button.new() button.text = text_value @@ -442,7 +455,7 @@ func _apply_theme() -> void: background.color = Brand.background_color(dark) background.material = Brand.background_material(dark) auth_panel.add_theme_stylebox_override("panel", Brand.panel_box(dark)) - theme_button.text = "☀" if dark else "☾" + Brand.set_theme_toggle_icon(theme_button, dark) theme_button.tooltip_text = "Use light theme" if dark else "Use dark theme" theme_button.add_theme_font_override("font", Brand.font) for node in find_children("*", "Label", true, false): @@ -475,6 +488,8 @@ func _apply_theme() -> void: theme_button.add_theme_stylebox_override("normal", theme_normal) theme_button.add_theme_stylebox_override("hover", theme_normal) theme_button.add_theme_color_override("font_color", Brand.AMBER) + if password_reveal_button != null: + _refresh_password_reveal(password_input, password_reveal_button) subheading.add_theme_color_override("font_color", Brand.muted_color(dark)) message_label.add_theme_color_override("font_color", Brand.muted_color(dark)) if brand_tagline != null: diff --git a/scripts/ui/main.gd b/scripts/ui/main.gd index c4e1a07..c2eb237 100644 --- a/scripts/ui/main.gd +++ b/scripts/ui/main.gd @@ -685,7 +685,7 @@ func _on_theme_changed(is_dark_mode: bool) -> void: func _refresh_theme_button() -> void: if theme_button == null: return - theme_button.text = "☀" if theme_service.is_dark_mode() else "☾" + Brand.set_theme_toggle_icon(theme_button, theme_service.is_dark_mode()) theme_button.tooltip_text = "Switch to light mode" if theme_service.is_dark_mode() else "Switch to dark mode" _style_shell_theme_button(theme_button) diff --git a/scripts/ui/medmind_style.gd b/scripts/ui/medmind_style.gd index 6663323..2957486 100644 --- a/scripts/ui/medmind_style.gd +++ b/scripts/ui/medmind_style.gd @@ -8,6 +8,8 @@ const MAGENTA := Color("d946ef") const TEAL := Color("14b8a6") const SUCCESS := Color("22c55e") const AMBER := Color("f59e0b") +const SUN_ICON := preload("res://assets/icons/lucide/sun.svg") +const MOON_ICON := preload("res://assets/icons/lucide/moon.svg") const SLATE := Color("94a3b8") const DARK_TEXT := Color("f8fafc") const LIGHT_TEXT := Color("0f172a") @@ -169,3 +171,12 @@ static func gradient_button_box() -> StyleBoxTexture: static func apply_font(control: Control) -> void: control.add_theme_font_override("font", font) + + +static func set_theme_toggle_icon(button: Button, dark: bool) -> void: + button.text = "" + button.icon = SUN_ICON if dark else MOON_ICON + button.expand_icon = false + button.add_theme_constant_override("icon_max_width", 22) + for color_name in ["icon_normal_color", "icon_hover_color", "icon_pressed_color", "icon_focus_color"]: + button.add_theme_color_override(color_name, AMBER) diff --git a/scripts/ui/question_player.gd b/scripts/ui/question_player.gd index 620c411..df11860 100644 --- a/scripts/ui/question_player.gd +++ b/scripts/ui/question_player.gd @@ -716,7 +716,7 @@ func _teaching_card_box(dark: bool, accent: Color) -> StyleBoxFlat: return box -func _review_followup_box(dark: bool, accent: Color, answered: bool) -> StyleBoxFlat: +func _review_followup_box(_dark: bool, accent: Color, answered: bool) -> StyleBoxFlat: var box := StyleBoxFlat.new() box.bg_color = Color(accent, 0.075 if answered else 0.04) box.border_color = Color(accent, 0.5) @@ -1849,7 +1849,7 @@ func refresh_theme_controls() -> void: func _refresh_theme_button() -> void: if theme_button == null: return - theme_button.text = "☀" if theme_service.is_dark_mode() else "☾" + Brand.set_theme_toggle_icon(theme_button, theme_service.is_dark_mode()) theme_button.tooltip_text = "Switch to light mode" if theme_service.is_dark_mode() else "Switch to dark mode" Style.style_chip(theme_button, Style.CYAN) @@ -2868,7 +2868,7 @@ func _show_wrong_answer_followup(choice_id: String) -> void: question_scroll.call_deferred("ensure_control_visible", choice_followup_panels[index]) -func _build_followup_panel(index: int, choice_id: String) -> void: +func _build_followup_panel(_index: int, choice_id: String) -> void: var followup := _get_followup_for_choice(choice_id) var followup_choices: Array = followup.get("choices", []).duplicate(true) followup_choices.shuffle() @@ -2944,7 +2944,7 @@ func _answer_followup(option_id: String, selected_button: Button, followup: Dict _resolve_followup(followup, selected_button, true, is_correct) -func _resolve_followup(followup: Dictionary, selected_button: Button, style_selected: bool, is_correct: bool) -> void: +func _resolve_followup(_followup: Dictionary, selected_button: Button, _style_selected: bool, is_correct: bool) -> void: if not followup_required or followup_answered: return followup_answered = true diff --git a/scripts/ui/simulation_history.gd b/scripts/ui/simulation_history.gd index 81d1a1a..d8e9418 100644 --- a/scripts/ui/simulation_history.gd +++ b/scripts/ui/simulation_history.gd @@ -1,163 +1,474 @@ extends VBoxContainer const Style := preload("res://scripts/ui/ui_style.gd") +const Brand := preload("res://scripts/ui/medmind_style.gd") +const CLOCK_ICON := preload("res://assets/icons/lucide/clock-3.svg") +const REVIEW_ICON := preload("res://assets/icons/lucide/chart-no-axes-column-increasing.svg") var main: Control -var list: VBoxContainer +var page: VBoxContainer +var title_label: Label +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 launch_button: Button -var readiness_label: Label +var filter_option: OptionButton +var sort_option: OptionButton +var abandon_dialog: ConfirmationDialog var pending_simulation_id := "" var pending_launch := false +var pending_abandon := false +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", 12) + 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: 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) + + title_label = Label.new() + title_label.text = "Exams" + Brand.apply_font(title_label) + title_label.add_theme_font_size_override("font_size", 27) + page.add_child(title_label) + + active_section = PanelContainer.new() + active_section.name = "ActiveSimulationSection" + 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 = "SimulationHistorySection" + 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.vertical = false + history_header.add_theme_constant_override("separation", 10) + history_content.add_child(history_header) + var heading := Label.new() + heading.text = "Simulation 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(190, 42) + filter_option.add_item("All Simulations") + 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(170, 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 simulation?" + abandon_dialog.dialog_text = "This attempt cannot be resumed and will not receive a predicted score. Completed blocks will remain visible in your history." + abandon_dialog.ok_button_text = "Abandon Simulation" + abandon_dialog.cancel_button_text = "Keep Simulation" + abandon_dialog.confirmed.connect(_confirm_abandon) + add_child(abandon_dialog) + refresh_theme_controls() + + +func _connect_services() -> void: if not ExamSimulationService.history_changed.is_connected(_render_history): ExamSimulationService.history_changed.connect(_render_history) - if not ExamSimulationService.readiness_changed.is_connected(_refresh_readiness): - ExamSimulationService.readiness_changed.connect(_refresh_readiness) + if not ExamSimulationService.readiness_changed.is_connected(_render_active_section): + ExamSimulationService.readiness_changed.connect(_render_active_section) if not ExamSimulationService.state_changed.is_connected(_on_simulation_loaded): ExamSimulationService.state_changed.connect(_on_simulation_loaded) if not ExamSimulationService.request_failed.is_connected(_on_request_failed): ExamSimulationService.request_failed.connect(_on_request_failed) - var top := HBoxContainer.new() - add_child(top) - var back := Button.new() - back.text = "< Profile" - Style.style_chip(back, Style.CYAN) - back.pressed.connect(func(): main.show_screen("profile")) - top.add_child(back) - var title := Label.new() - title.text = "Simulation History" - title.size_flags_horizontal = Control.SIZE_EXPAND_FILL - title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER - Style.set_label(title, 30, Style.TEXT) - top.add_child(title) - var balance := Control.new() - balance.custom_minimum_size = Vector2(100, 0) - top.add_child(balance) - var launch_panel := PanelContainer.new() - launch_panel.add_theme_stylebox_override("panel", Style.panel_box(Color("07192c"), Style.CYAN)) - add_child(launch_panel) - var launch_content := HBoxContainer.new() - launch_content.add_theme_constant_override("separation", 18) - launch_panel.add_child(launch_content) - var launch_copy := VBoxContainer.new() - launch_copy.size_flags_horizontal = Control.SIZE_EXPAND_FILL - launch_copy.add_theme_constant_override("separation", 5) - launch_content.add_child(launch_copy) - var launch_title := Label.new() - launch_title.text = "Step 3 Day 1 Simulation" - Style.set_label(launch_title, 22, Style.TEXT) - launch_copy.add_child(launch_title) - readiness_label = Label.new() - readiness_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART - Style.set_label(readiness_label, 15, Style.MUTED) - launch_copy.add_child(readiness_label) - launch_button = Button.new() - launch_button.text = "Checking readiness..." - launch_button.custom_minimum_size = Vector2(210, 48) - launch_button.size_flags_vertical = Control.SIZE_SHRINK_CENTER - launch_button.disabled = true - Style.style_primary_button(launch_button) - launch_button.pressed.connect(_launch_or_resume) - launch_content.add_child(launch_button) - status_label = Label.new() - status_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART - Style.set_label(status_label, 16, Style.MUTED) - add_child(status_label) - var scroll := ScrollContainer.new() - scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL - scroll.size_flags_horizontal = Control.SIZE_EXPAND_FILL - add_child(scroll) - list = VBoxContainer.new() - list.size_flags_horizontal = Control.SIZE_EXPAND_FILL - list.add_theme_constant_override("separation", 8) - scroll.add_child(list) - load_more_button = Button.new() - load_more_button.text = "Load More" - load_more_button.visible = false - load_more_button.size_flags_horizontal = Control.SIZE_SHRINK_CENTER - Style.style_chip(load_more_button, Style.CYAN) - load_more_button.pressed.connect(_load_more) - add_child(load_more_button) -func on_show(payload: Variant = null) -> void: +func on_show(_payload: Variant = null) -> void: pending_simulation_id = "" pending_launch = false - _refresh_readiness() - ExamSimulationService.load_readiness() - if str(payload) == "preserve" and not ExamSimulationService.history.is_empty(): - return + pending_abandon = false status_label.text = "Loading simulation history..." load_more_button.disabled = true - ExamSimulationService.load_history() + ExamSimulationService.load_readiness() + ExamSimulationService.load_active() + _load_history_page() + + +func refresh_theme_controls() -> void: + if page == null: + return + var dark := UIThemeService.is_dark_mode() + title_label.add_theme_color_override("font_color", Brand.text_color(dark)) + 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 section_title := Label.new() + section_title.text = "Active Simulation" if _has_active_simulation() else "Step 3 Simulation" + Brand.apply_font(section_title) + section_title.add_theme_font_size_override("font_size", 18) + section_title.add_theme_color_override("font_color", Brand.PURPLE if not dark else Color("9b73ff")) + 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.PURPLE, 8)) + card_layout.add_child(icon_panel) + var clock := TextureRect.new() + clock.texture = CLOCK_ICON + clock.custom_minimum_size = Vector2(44, 44) + clock.expand_mode = TextureRect.EXPAND_IGNORE_SIZE + clock.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED + clock.modulate = Brand.PURPLE if not dark else Color("9668ff") + icon_panel.add_child(clock) + + 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 active := _has_active_simulation() + var heading := Label.new() + heading.text = "Step 3 Day 1 Simulation" + 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 active: + _build_active_details(details, dark) + else: + _build_start_details(details, dark) + + var actions := VBoxContainer.new() + actions.name = "ActiveActions" + actions.custom_minimum_size = Vector2(265, 0) if not compact_layout else Vector2.ZERO + 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 = "PrimarySimulationAction" + primary.text = "Resume Simulation" if active else "Start Simulation" + primary.custom_minimum_size = Vector2(0, 48) + primary.disabled = pending_launch or (not active and not bool(ExamSimulationService.readiness.get("ready", false))) + _style_gradient_button(primary) + primary.pressed.connect(_launch_or_resume) + actions.add_child(primary) + if active: + var abandon := Button.new() + abandon.name = "AbandonSimulation" + abandon.text = "Abandon Simulation" + 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) + + 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 will end your active simulation; completed blocks remain in history, but no score will be generated." if active else _readiness_message() + Brand.apply_font(notice_label) + notice_label.add_theme_font_size_override("font_size", 14) + notice_label.add_theme_color_override("font_color", Brand.muted_color(dark)) + notice.add_child(notice_label) + + +func _build_active_details(details: VBoxContainer, dark: bool) -> void: + var simulation := ExamSimulationService.simulation + var summary := _active_history_summary() + var started := str(summary.get("createdUtc", "")) + var started_label := Label.new() + started_label.text = "Started: %s" % _format_local_datetime(started) + _style_muted_label(started_label, dark, 14) + details.add_child(started_label) + + var progress_title := Label.new() + progress_title.text = "Progress" + _style_muted_label(progress_title, dark, 13) + details.add_child(progress_title) + var completed := completed_blocks(simulation if not simulation.is_empty() else summary) + var total := int((simulation if not simulation.is_empty() else summary).get("totalBlocks", 12)) + var progress_row := HBoxContainer.new() + progress_row.add_theme_constant_override("separation", 12) + details.add_child(progress_row) + var bar := ProgressBar.new() + bar.show_percentage = false + bar.max_value = max(1, total) + bar.value = completed + bar.custom_minimum_size = Vector2(120, 8) + bar.size_flags_horizontal = Control.SIZE_EXPAND_FILL + bar.size_flags_vertical = Control.SIZE_SHRINK_CENTER + bar.add_theme_stylebox_override("background", _progress_box(Color("28344f") if dark else Color("e2e8f0"))) + bar.add_theme_stylebox_override("fill", _progress_box(Brand.PURPLE)) + progress_row.add_child(bar) + var percent := Label.new() + percent.text = "%d%%" % progress_percent(completed, total) + _style_muted_label(percent, dark, 13) + progress_row.add_child(percent) + var progress_copy := Label.new() + progress_copy.text = "%d of %d blocks complete • %s" % [completed, total, remaining_time_text(simulation if not simulation.is_empty() else summary)] + _style_muted_label(progress_copy, dark, 14) + details.add_child(progress_copy) + + +func _build_start_details(details: VBoxContainer, dark: bool) -> void: + var copy := Label.new() + copy.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + copy.text = "A full-length, 12-block simulation with a provisional predicted Step 3 score. Progress is saved between blocks." + _style_muted_label(copy, dark, 15) + details.add_child(copy) func _render_history() -> void: - if not visible: + if history_list == null or not visible: return - for child in list.get_children(): - child.queue_free() - if ExamSimulationService.history.is_empty(): - status_label.text = "No simulations yet." + _clear(history_list) + var items: Array = ExamSimulationService.history + if items.is_empty(): + status_label.text = "No simulations match this filter." else: - status_label.text = "Select a completed simulation to review it, or resume an active simulation." - for raw_summary in ExamSimulationService.history: + status_label.text = "" + for raw_summary in items: var summary: Dictionary = raw_summary - var state := str(summary.get("state", "")) - var action := _action_for_state(state) - var button := Button.new() - button.text = _summary_text(summary) - button.alignment = HORIZONTAL_ALIGNMENT_LEFT - button.custom_minimum_size = Vector2(0, 70) - button.size_flags_horizontal = Control.SIZE_EXPAND_FILL - button.disabled = action == "none" - Style.style_chip(button, _state_color(state)) - if action != "none": - button.pressed.connect(_open_simulation.bind(str(summary.get("id", "")))) - list.add_child(button) - load_more_button.visible = not ExamSimulationService.history_next_before.is_empty() + if str(summary.get("state", "")) in ["block_active", "between_blocks"]: + continue + history_list.add_child(_build_history_row(summary)) + load_more_button.visible = not ExamSimulationService.history_next_cursor.is_empty() load_more_button.disabled = false + table_header.visible = not compact_layout and not items.is_empty() -func _summary_text(summary: Dictionary) -> String: +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 [["Simulation", 2.2], ["Date", 1.15], ["Status", 1.0], ["Blocks", 0.8], ["Score", 1.15], ["Pass chance", 1.0], ["Actions", 1.0]]: + var label := Label.new() + label.text = definition[0] + label.size_flags_horizontal = Control.SIZE_EXPAND_FILL + label.size_flags_stretch_ratio = 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(summary: Dictionary) -> Control: + var dark := UIThemeService.is_dark_mode() + var panel := PanelContainer.new() + panel.add_theme_stylebox_override("panel", _row_box(dark)) var state := str(summary.get("state", "")) - var date := str(summary.get("createdUtc", "")).left(10) - if state == "completed": - return "%s | %sCompleted\nPredicted %d (%d-%d) | %d/%d correct" % [date, "Sample | " if bool(summary.get("isSample", false)) else "", int(summary.get("predictedScore", 0)), int(summary.get("rangeLow", 0)), int(summary.get("rangeHigh", 0)), int(summary.get("correct", 0)), int(summary.get("total", 0))] - if state == "block_active": - return "%s | Active\nResume Block %d of %d (timer continues)" % [date, int(summary.get("currentBlock", 0)) + 1, int(summary.get("totalBlocks", 12))] - if state == "between_blocks": - return "%s | In progress\n%d of %d blocks complete" % [date, int(summary.get("currentBlock", -1)) + 1, int(summary.get("totalBlocks", 12))] - return "%s | Abandoned\nThis simulation cannot be resumed." % date + 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(_simulation_cell(summary, dark)) + var badge := _status_badge(state, dark) + top.add_child(badge) + var metrics := Label.new() + metrics.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + metrics.text = "%s • %d/%d blocks\n%s • %s" % [_format_local_datetime(str(summary.get("createdUtc", ""))), completed_blocks(summary), int(summary.get("totalBlocks", 12)), _score_text(summary), _pass_chance_text(summary)] + _style_muted_label(metrics, dark, 14) + card.add_child(metrics) + if state == "completed": + card.add_child(_review_button(summary, 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, _simulation_cell(summary, dark), 2.2) + _add_table_cell(row, _plain_cell(_format_local_datetime(str(summary.get("createdUtc", ""))), dark), 1.15) + _add_table_cell(row, _status_badge(state, dark), 1.0) + _add_table_cell(row, _plain_cell("%d / %d" % [completed_blocks(summary), int(summary.get("totalBlocks", 12))], dark), 0.8) + _add_table_cell(row, _plain_cell(_score_text(summary), dark), 1.15) + _add_table_cell(row, _plain_cell(_pass_chance_text(summary), dark), 1.0) + var action: Control = _review_button(summary, dark) if state == "completed" else _plain_cell("—", dark) + _add_table_cell(row, action, 1.0) + return panel + + +func _simulation_cell(summary: Dictionary, dark: bool) -> Control: + var column := VBoxContainer.new() + column.size_flags_horizontal = Control.SIZE_EXPAND_FILL + var name_label := Label.new() + name_label.text = "Step 3 Day 1 Simulation%s" % (" (Sample)" if bool(summary.get("isSample", false)) else "") + Brand.apply_font(name_label) + name_label.add_theme_font_size_override("font_size", 15) + name_label.add_theme_color_override("font_color", Brand.text_color(dark)) + column.add_child(name_label) + var blocks := Label.new() + blocks.text = "%d blocks" % int(summary.get("totalBlocks", 12)) + _style_muted_label(blocks, dark, 12) + column.add_child(blocks) + return column + + +func _plain_cell(text_value: String, dark: bool) -> Label: + var label := Label.new() + label.text = 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(summary: 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_simulation.bind(str(summary.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 _open_simulation(simulation_id: String) -> void: pending_launch = false pending_simulation_id = simulation_id - status_label.text = "Opening simulation..." + status_label.text = "Opening simulation results..." ExamSimulationService.load_simulation(simulation_id) func _on_simulation_loaded() -> void: - if not visible or ExamSimulationService.simulation.is_empty(): + if not visible: + return + if pending_abandon: + if ExamSimulationService.simulation.is_empty(): + pending_abandon = false + ExamSimulationService.load_readiness() + ExamSimulationService.load_active() + _load_history_page() + _render_active_section() return if pending_launch: pending_launch = false _route_loaded_simulation() return - if pending_simulation_id.is_empty(): + if not pending_simulation_id.is_empty(): + pending_simulation_id = "" + _route_loaded_simulation() return - pending_simulation_id = "" - _route_loaded_simulation() + _render_active_section() func _route_loaded_simulation() -> void: + if ExamSimulationService.simulation.is_empty(): + _on_request_failed("The simulation is no longer available.") + return var state := str(ExamSimulationService.simulation.get("state", "")) if state == "completed": main.show_screen("simulation_results", {"simulation": ExamSimulationService.simulation, "return_screen": "simulation_history"}) @@ -172,48 +483,52 @@ func _route_loaded_simulation() -> void: elif state == "between_blocks": main.show_screen("simulation_break") else: - status_label.text = "This simulation can no longer be resumed." - _refresh_readiness() + _on_request_failed("This simulation can no longer be resumed.") func _launch_or_resume() -> void: pending_launch = true pending_simulation_id = "" - launch_button.disabled = true - readiness_label.text = "Opening your simulation..." + _render_active_section() if _has_active_simulation(): - ExamSimulationService.load_active() + if not ExamSimulationService.simulation.is_empty(): + pending_launch = false + _route_loaded_simulation() + else: + ExamSimulationService.load_active() else: ExamSimulationService.create_simulation() -func _refresh_readiness() -> void: - if launch_button == null: +func _confirm_abandon() -> void: + if not _has_active_simulation() or ExamSimulationService.simulation.is_empty(): return - var simulation_ready := bool(ExamSimulationService.readiness.get("ready", false)) - var active := _has_active_simulation() - var available := int(ExamSimulationService.readiness.get("availableQuestions", 0)) - launch_button.text = "Resume Simulation" if active else "Start Simulation" - launch_button.disabled = pending_launch or (not simulation_ready and not active) - if pending_launch: - readiness_label.text = "Opening your simulation..." - elif active: - readiness_label.text = "Your progress is saved between blocks. Resume where you left off." - elif simulation_ready: - readiness_label.text = "Full-length, 12-block simulation with a provisional predicted score." - else: - readiness_label.text = "Unlocks when 232 published questions are available. Currently %d/232." % available + pending_abandon = true + ExamSimulationService.abandon() + _render_active_section() -func _has_active_simulation() -> bool: - return has_active_simulation( - ExamSimulationService.readiness, - ExamSimulationService.simulation) +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 simulation history..." + load_more_button.disabled = true + ExamSimulationService.load_history("", history_status, history_sort) func _load_more() -> void: + if ExamSimulationService.history_next_cursor.is_empty(): + return load_more_button.disabled = true - ExamSimulationService.load_history(ExamSimulationService.history_next_before) + ExamSimulationService.load_history(ExamSimulationService.history_next_cursor, history_status, history_sort) func _on_request_failed(message: String) -> void: @@ -221,21 +536,118 @@ func _on_request_failed(message: String) -> void: return pending_launch = false pending_simulation_id = "" + pending_abandon = false status_label.text = message load_more_button.disabled = false - _refresh_readiness() + _render_active_section() -static func _action_for_state(state: String) -> String: - if state == "completed": return "results" - if state == "block_active" or state == "between_blocks": return "resume" - return "none" +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() -static func _state_color(state: String) -> Color: - if state == "completed": return Style.LIME - if state == "block_active" or state == "between_blocks": return Style.CYAN - return Style.MUTED +func _has_active_simulation() -> bool: + return has_active_simulation(ExamSimulationService.readiness, ExamSimulationService.simulation) + + +func _active_history_summary() -> Dictionary: + for raw_summary in ExamSimulationService.history: + var summary: Dictionary = raw_summary + if str(summary.get("state", "")) in ["block_active", "between_blocks"]: + return summary + return ExamSimulationService.simulation + + +func _readiness_message() -> String: + if bool(ExamSimulationService.readiness.get("ready", false)): + return "The question bank is ready. Start when you have enough uninterrupted time for a full simulation." + var available := int(ExamSimulationService.readiness.get("availableQuestions", 0)) + var required := int(ExamSimulationService.readiness.get("requiredQuestions", 232)) + return "Simulation unlocks when %d published questions are available. Currently %d/%d." % [required, available, required] + + +func _score_text(summary: Dictionary) -> String: + if summary.get("predictedScore", null) == null: + return "—" + return "%d (%d–%d)" % [int(summary.get("predictedScore", 0)), int(summary.get("rangeLow", 0)), int(summary.get("rangeHigh", 0))] + + +func _pass_chance_text(summary: Dictionary) -> String: + if summary.get("passProbability", null) == null: + return "—" + return "%d%%" % int(round(float(summary.get("passProbability", 0.0)) * 100.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 completed_blocks(summary: Dictionary) -> int: + var state := str(summary.get("state", "")) + var current := int(summary.get("currentBlock", -1)) + var total: int = max(0, int(summary.get("totalBlocks", 12))) + if state == "completed": + return total + if state == "between_blocks": + return clamp(current + 1, 0, total) + if state == "block_active": + return clamp(current, 0, total) + return clamp(current + 1, 0, total) + + +static func progress_percent(completed: int, total: int) -> int: + if total <= 0: + return 0 + return int(round(float(completed) * 100.0 / float(total))) + + +static func remaining_time_text(summary: Dictionary, now_unix: float = -1.0) -> String: + var total: int = max(0, int(summary.get("totalBlocks", 12))) + var current := int(summary.get("currentBlock", -1)) + var state := str(summary.get("state", "")) + var seconds := 0 + if state == "block_active": + var future_blocks: int = max(0, total - current - 1) + seconds = future_blocks * 1800 + var deadline := str(summary.get("blockDeadlineUtc", "")) + if not deadline.is_empty(): + var current_unix := Time.get_unix_time_from_system() if now_unix < 0.0 else now_unix + seconds += max(0, int(Time.get_unix_time_from_datetime_string(deadline) - current_unix)) + else: + seconds += 1800 + else: + seconds = max(0, total - completed_blocks(summary)) * 1800 + var hours := int(floor(float(seconds) / 3600.0)) + var minutes := int(floor(float(seconds % 3600) / 60.0)) + if hours > 0 and minutes > 0: + return "~%dh %dm remaining" % [hours, minutes] + if hours > 0: + return "~%dh remaining" % hours + return "~%dm remaining" % minutes static func has_active_simulation(readiness: Dictionary, simulation: Dictionary) -> bool: @@ -244,3 +656,106 @@ static func has_active_simulation(readiness: Dictionary, simulation: Dictionary) if simulation.is_empty(): return false return str(simulation.get("state", "")) in ["between_blocks", "block_active"] + + +static func _action_for_state(state: String) -> String: + if state == "completed": + return "results" + if state == "block_active" or state == "between_blocks": + return "resume" + return "none" + + +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) + button.add_theme_stylebox_override("normal", Brand.gradient_button_box()) + button.add_theme_stylebox_override("hover", Brand.gradient_button_box()) + button.add_theme_stylebox_override("pressed", 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, Color("7455ff")) + box.bg_color = Color("101a38") if dark else Color("f8f7ff") + box.border_color = Color("3d326f") if dark else Color("d9d1ff") + 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 _progress_box(color: Color) -> StyleBoxFlat: + var box := StyleBoxFlat.new() + box.bg_color = color + box.set_corner_radius_all(4) + return box + + +func _clear(container: Node) -> void: + for child in container.get_children(): + container.remove_child(child) + child.queue_free() diff --git a/tests/ccs_player_test.gd b/tests/ccs_player_test.gd new file mode 100644 index 0000000..8b4d79b --- /dev/null +++ b/tests/ccs_player_test.gd @@ -0,0 +1,124 @@ +extends SceneTree + +const Style := preload("res://scripts/ui/ui_style.gd") +const Brand := preload("res://scripts/ui/medmind_style.gd") + +var failures := 0 +var ccs_service + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + ccs_service = root.get_node("CCSService") + var theme_service = root.get_node("UIThemeService") + var prior_dark_mode: bool = theme_service.is_dark_mode() + var prior_attempt: Dictionary = ccs_service.attempt.duplicate(true) + var prior_orders: Array = ccs_service.orders.duplicate(true) + var prior_scale: float = ccs_service.text_scale + ccs_service.text_scale = 1.0 + ccs_service.orders = [] + ccs_service.attempt = _sample_attempt() + + var host := Control.new() + host.size = Vector2(1440, 900) + root.add_child(host) + var player = load("res://scripts/ui/ccs_player.gd").new() + player.main = host + player.size = host.size + host.add_child(player) + await process_frame + player._render() + await process_frame + + _assert(player.heading.text == "CCS Case | Emergency Department", "CCS header should show the readable location without a case id") + _assert(player._ordered_vital_keys(ccs_service.attempt.vitals) == ["temperature", "bloodPressure", "pulse", "respirations", "oxygenSaturation"], "vital chips should use the clinical priority order") + _assert(player.vitals_flow.get_child_count() == 5, "every supplied vital should render as a chip") + _assert(player.search_button.icon != null, "Order Clerk search should use a bundled icon rather than a font glyph") + _assert(player.summary.get_meta("ccs_region", "") == "summary", "summary highlighting should use its own region") + _assert(player.history.get_meta("ccs_region", "") == "history", "history highlighting should use its own region") + _assert(player.appearance.get_meta("ccs_region", "") == "appearance", "appearance highlighting should use its own region") + _assert(player.chart.get_meta("ccs_region", "") == "chart", "chart highlighting should use its own region") + _assert(not player.workspace.vertical, "wide CCS screens should use chart and Order Clerk columns") + _assert(player.orders.get_child_count() == 1, "active orders should render in the Order Clerk") + var active_order_row := player.orders.get_child(0) as HBoxContainer + var active_order_label := active_order_row.get_child(0) as Label + _assert(active_order_label.text == "Normal Saline Bolus", "internal order codes should be human readable") + _assert(player.actions.get_child_count() == 2, "available physical examinations should render as actions") + _assert(player.locations.get_child_count() == 2, "allowed locations should render as move actions") + _assert(not player.clock.visible, "the normal active-case countdown should remain hidden") + + var clerk_font_size: int = player.search.get_theme_font_size("font_size") + var chart_font_size: int = player.summary.get_theme_font_size("normal_font_size") + player._zoom(ccs_service.TEXT_SCALE_STEP) + _assert(player.summary.get_theme_font_size("normal_font_size") > chart_font_size, "text zoom should enlarge clinical chart text") + _assert(player.search.get_theme_font_size("font_size") == clerk_font_size, "text zoom should not resize Order Clerk controls") + + player.size = Vector2(800, 900) + player._update_responsive_layout() + _assert(player.workspace.vertical, "narrow CCS screens should stack the Order Clerk below the chart") + ccs_service.attempt.state = "final_orders" + ccs_service.attempt.finalOrdersDeadlineUtc = Time.get_datetime_string_from_unix_time(Time.get_unix_time_from_system() + 90.0, true) + player._render() + _assert(player.final_orders_notice.visible and player.clock.visible, "final-orders mode should show instructions and its deadline") + _assert(not player.time_row.visible, "time advancement should be unavailable during final orders") + + theme_service.set_dark_mode(true) + await process_frame + theme_service.set_dark_mode(false) + Style.refresh_tree(player, true) + await process_frame + var light_panel := player.chart_panel.get_theme_stylebox("panel") as StyleBoxFlat + _assert(light_panel.bg_color.is_equal_approx(Brand.content_card_box(false, false).bg_color), "CCS light styles should win after the shell palette translation") + _assert(player.summary.get_theme_color("default_color").is_equal_approx(Brand.text_color(false)), "CCS light chart text should remain readable") + theme_service.set_dark_mode(true) + Style.refresh_tree(player, false) + await process_frame + var dark_panel := player.chart_panel.get_theme_stylebox("panel") as StyleBoxFlat + _assert(dark_panel.bg_color.is_equal_approx(Brand.content_card_box(true, false).bg_color), "CCS dark styles should win after the shell palette translation") + _assert(player.summary.get_theme_color("default_color").is_equal_approx(Brand.text_color(true)), "CCS dark chart text should remain readable") + + theme_service.set_dark_mode(prior_dark_mode) + await process_frame + host.queue_free() + await process_frame + ccs_service.attempt = prior_attempt + ccs_service.orders = prior_orders + ccs_service.text_scale = prior_scale + quit(1 if failures > 0 else 0) + + +func _sample_attempt() -> Dictionary: + return { + "id": "attempt-1", + "state": "active", + "location": "emergency_department", + "patientSummary": "24-year-old woman with type 1 diabetes mellitus", + "reasonForEncounter": "Two days of vomiting and progressive lethargy.", + "history": "She stopped insulin when oral intake declined.", + "appearance": "She is lethargic but arousable.", + "vitals": { + "oxygenSaturation": "98% on room air", + "pulse": "118/min", + "temperature": "37.3 C (99.1 F)", + "respirations": "28/min", + "bloodPressure": "88/56 mm Hg" + }, + "chart": [{"simulatedMinute": 0, "category": "progress", "text": "Initial evaluation is in progress."}], + "activeOrderCodes": ["normal_saline_bolus"], + "availableExams": [ + {"code": "general", "name": "General and hydration examination"}, + {"code": "neurologic", "name": "Neurologic examination"} + ], + "allowedLocations": ["emergency_department", "icu"], + "deadlineUtc": Time.get_datetime_string_from_unix_time(Time.get_unix_time_from_system() + 600.0, true) + } + + +func _assert(condition: bool, message: String) -> void: + if condition: + return + failures += 1 + push_error(message) diff --git a/tests/ccs_player_test.gd.uid b/tests/ccs_player_test.gd.uid new file mode 100644 index 0000000..a3fe897 --- /dev/null +++ b/tests/ccs_player_test.gd.uid @@ -0,0 +1 @@ +uid://sc7j07qkb1ci diff --git a/tests/simulation_history_test.gd b/tests/simulation_history_test.gd index 1f4425a..d22b2dd 100644 --- a/tests/simulation_history_test.gd +++ b/tests/simulation_history_test.gd @@ -13,6 +13,12 @@ func _initialize() -> void: _assert(history.has_active_simulation({}, {"state": "block_active"}), "a loaded active block should be resumable") _assert(history.has_active_simulation({}, {"state": "between_blocks"}), "a loaded between-block simulation should be resumable") _assert(not history.has_active_simulation({}, {"state": "completed"}), "completed simulations should not appear active") + _assert(history.completed_blocks({"state": "block_active", "currentBlock": 3, "totalBlocks": 12}) == 3, "active block progress should count only finished blocks") + _assert(history.completed_blocks({"state": "between_blocks", "currentBlock": 3, "totalBlocks": 12}) == 4, "between-block progress should include the submitted block") + _assert(history.completed_blocks({"state": "completed", "currentBlock": 11, "totalBlocks": 12}) == 12, "completed simulations should show every block") + _assert(history.progress_percent(3, 12) == 25, "progress percentage should use floating-point division") + _assert(history.remaining_time_text({"state": "between_blocks", "currentBlock": 2, "totalBlocks": 12}) == "~4h 30m remaining", "remaining time should reflect unstarted blocks") + _assert(history.format_datetime_parts({"year": 2026, "month": 7, "day": 16, "hour": 22, "minute": 4}) == "Jul 16, 2026\n10:04 PM", "history dates should use the compact mockup format") quit(1 if failures > 0 else 0) diff --git a/tests/simulation_history_ui_test.gd b/tests/simulation_history_ui_test.gd new file mode 100644 index 0000000..18ce497 --- /dev/null +++ b/tests/simulation_history_ui_test.gd @@ -0,0 +1,57 @@ +extends SceneTree + +var failures := 0 + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + var service = root.get_node("ExamSimulationService") + var theme_service = root.get_node("UIThemeService") + var prior_dark_mode: bool = theme_service.is_dark_mode() + service.readiness = {"ready": true, "hasActiveSimulation": true, "availableQuestions": 300, "requiredQuestions": 232} + service.simulation = {"id": "active", "state": "between_blocks", "currentBlock": 2, "totalBlocks": 12, "createdUtc": "2026-07-16T22:04:00Z"} + service.history = [{"id": "completed", "state": "completed", "currentBlock": 11, "totalBlocks": 12, "createdUtc": "2026-07-15T20:35:00Z", "predictedScore": 236, "rangeLow": 221, "rangeHigh": 248, "passProbability": 0.82}] + 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/simulation_history.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, "history should render completed attempts without duplicating the active simulation") + _assert(screen.load_more_button.visible, "history should expose pagination when the API returns a cursor") + var active_layout: BoxContainer = screen.find_child("ActiveCardLayout", true, false) + _assert(active_layout != null and not active_layout.vertical, "wide exam screens should use the table-oriented layout") + theme_service.set_dark_mode(false) + await process_frame + var light_active_box := screen.active_section.get_theme_stylebox("panel") as StyleBoxFlat + _assert(light_active_box.bg_color.is_equal_approx(screen._section_box(false).bg_color), "the Exams page should apply its light surface after a live theme change") + theme_service.set_dark_mode(true) + await process_frame + var dark_active_box := screen.active_section.get_theme_stylebox("panel") as StyleBoxFlat + _assert(dark_active_box.bg_color.is_equal_approx(screen._section_box(true).bg_color), "the Exams page should restore its dark surface after a live 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 exam screens should stack the active simulation card") + _assert(not screen.table_header.visible, "narrow exam screens should replace the table header with history cards") + 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) diff --git a/tests/ui_theme_test.gd b/tests/ui_theme_test.gd index 2f0cb41..d2d9e9c 100644 --- a/tests/ui_theme_test.gd +++ b/tests/ui_theme_test.gd @@ -28,6 +28,24 @@ func _initialize() -> void: _assert(light_card.border_color.is_equal_approx(Brand.ROYAL_BLUE), "active light cards should use the primary blue border") var light_field := Brand.field_box(false) _assert(light_field.border_color.is_equal_approx(Brand.LIGHT_BORDER), "light fields should use the semantic cool-gray border") + var theme_toggle := Button.new() + Brand.set_theme_toggle_icon(theme_toggle, true) + _assert(theme_toggle.text.is_empty() and theme_toggle.icon == Brand.SUN_ICON, "dark mode should use the bundled sun icon") + Brand.set_theme_toggle_icon(theme_toggle, false) + _assert(theme_toggle.text.is_empty() and theme_toggle.icon == Brand.MOON_ICON, "light mode should use the bundled moon icon") + var login = load("res://scripts/ui/login_screen.gd").new() + var password := LineEdit.new() + var password_reveal := Button.new() + password.secret = true + login._refresh_password_reveal(password, password_reveal) + var show_icon := password_reveal.icon + password.secret = false + login._refresh_password_reveal(password, password_reveal) + _assert(show_icon != null and password_reveal.icon != null and show_icon != password_reveal.icon, "password visibility should switch bundled eye icons") + password_reveal.free() + password.free() + login.free() + theme_toggle.free() Style.set_dark_mode(true) Style.refresh_tree(root, false)