Redesign exam and CCS experiences
@@ -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<ExamSimulationItem> BuildItems(int count, bool wasSeen = false) =>
|
||||
Enumerable.Range(0, count).Select(index => new ExamSimulationItem
|
||||
{
|
||||
|
||||
@@ -51,27 +51,33 @@ public class ExamSimulationsController(ExamSimulationStore simulations) : Contro
|
||||
[HttpGet("completed")]
|
||||
public async Task<IActionResult> 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<IActionResult> History([FromQuery] int limit = 20, [FromQuery] string? before = null)
|
||||
public async Task<IActionResult> 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}")]
|
||||
|
||||
@@ -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<ExamSimulationQuestionResponse> Questions { get; set; } = [];
|
||||
public List<ExamSimulationResponse> 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<ExamSimulationHistorySummary> Items { get; set; } = [];
|
||||
public DateTime? NextBefore { get; set; }
|
||||
public string? NextCursor { get; set; }
|
||||
}
|
||||
|
||||
public class Step3Outcome
|
||||
|
||||
@@ -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<ExamSimulation>.IndexKeys.Ascending(item => item.UserId).Ascending(item => item.State).Descending(item => item.CompletedUtc)));
|
||||
_simulations.Indexes.CreateOne(new CreateIndexModel<ExamSimulation>(
|
||||
Builders<ExamSimulation>.IndexKeys.Ascending(item => item.UserId).Descending(item => item.CreatedUtc)));
|
||||
_simulations.Indexes.CreateOne(new CreateIndexModel<ExamSimulation>(
|
||||
Builders<ExamSimulation>.IndexKeys.Ascending(item => item.UserId).Ascending(item => item.State).Descending(item => item.CreatedUtc).Descending(item => item.Id)));
|
||||
_simulations.Indexes.CreateOne(new CreateIndexModel<ExamSimulation>(
|
||||
Builders<ExamSimulation>.IndexKeys.Ascending(item => item.UpdatedUtc)));
|
||||
_outcomes.Indexes.CreateOne(new CreateIndexModel<Step3Outcome>(
|
||||
@@ -200,22 +203,91 @@ public class ExamSimulationStore
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<ExamSimulationHistoryPage> GetHistoryAsync(string userId, int limit, DateTime? before)
|
||||
public async Task<ExamSimulationHistoryPage> 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<ExamSimulation>.Filter.Eq(item => item.UserId, userId);
|
||||
if (before is not null)
|
||||
filter &= Builders<ExamSimulation>.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<ExamSimulation>.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<ExamSimulation>.Sort.Descending(item => item.CreatedUtc).Descending(item => item.Id)
|
||||
: Builders<ExamSimulation>.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,
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M22 12h-4l-3 9L9 3l-3 9H2" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 225 B |
@@ -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
|
||||
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="m15 18-.722-3.25" />
|
||||
<path d="M2 8a10.645 10.645 0 0 0 20 0" />
|
||||
<path d="m20 15-1.726-2.05" />
|
||||
<path d="m4 15 1.726-2.05" />
|
||||
<path d="m9 18 .722-3.25" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 357 B |
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 336 B |
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.29 1.51 4.04 3 5.5l7 7Z" />
|
||||
<path d="M3.22 12H9.5l.5-1 2 4.5 2-7 1.5 3.5h5.27" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 394 B |
@@ -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
|
||||
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M6.081 20C3.5 20 2 18 2 15.5c0-1.5.5-3 1.5-5L6 6v8l-2 2" />
|
||||
<path d="M17.919 20C20.5 20 22 18 22 15.5c0-1.5-.5-3-1.5-5L18 6v8l2 2" />
|
||||
<path d="M9 12a3 3 0 0 0-3 3v5" />
|
||||
<path d="M15 12a3 3 0 0 1 3 3v5" />
|
||||
<path d="M12 4v12" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 430 B |
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M20.985 12.486a9 9 0 1 1-9.473-9.472c.405-.022.617.46.402.803a6 6 0 0 0 8.268 8.268c.344-.215.825-.004.803.401" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 310 B |
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<path d="m21 21-4.3-4.3" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 249 B |
@@ -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
|
||||
@@ -0,0 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<path d="M12 2v2" />
|
||||
<path d="M12 20v2" />
|
||||
<path d="m4.93 4.93 1.42 1.42" />
|
||||
<path d="m17.66 17.66 1.41 1.41" />
|
||||
<path d="M2 12h2" />
|
||||
<path d="M20 12h2" />
|
||||
<path d="m6.34 17.66-1.41 1.41" />
|
||||
<path d="m19.07 4.93-1.41 1.41" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 461 B |
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M14 4v10.54a4 4 0 1 1-4 0V4a2 2 0 0 1 4 0Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 242 B |
@@ -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
|
||||
@@ -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_"):
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://sc7j07qkb1ci
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||