Author SHA1 Message Date
hz 0676c7f95e Require production approval for question media 2026-07-30 04:18:31 -05:00
hz f39bfb42be Merge pull request 'Main' (#13) from main into production
Reviewed-on: admin/step3#13
2026-07-29 01:35:56 -05:00
hz c14ac1d65b Require reference ranges for published question labs 2026-07-29 01:28:37 -05:00
hz f082be38e0 Restore Rz Coins caption and fix rank/ELO badge wrapping below toolbar
Reverts the coin caption back to "Rz Coins" and shrinks the badge's
icons/fonts/spacing so both segments fit in the same footprint budget
the toolbar reserves. That budget was a hardcoded 900px sized for the
old single-segment badge; bump it to 960px to match the wider badge
instead of letting it overflow onto its own row.
2026-07-29 01:16:47 -05:00
hz 845907e825 Show rank tier and ELO alongside RZ points in the coins badge
Adds a server-computed tier/division (Bronze..Diamond, III-I) to the
ratings profile endpoint and surfaces it next to the numeric ELO in the
shared header badge, matching the coin balance display already there.
2026-07-29 00:25:26 -05:00
hz 89dbe3e31a Initialize credit earning ledger arrays 2026-07-28 20:52:20 -05:00
hz dd203b24f9 Allow SUPER accounts to earn Rz Coins 2026-07-28 20:36:15 -05:00
hzandClaude Sonnet 5 6f41906101 Gate Rz Coins on correct-first-try-within-time-limit; match coin-flight animation to it
Credits were previously awarded on any first attempt regardless of
correctness or timing. The award now requires the first-ever attempt
to be correct (record.Attempt.IsCorrect) and submitted before the
adaptive per-question timer expired (new MetTimeLimit request field,
computed client-side from the same elapsed/target comparison that
already drives the visible countdown bar). The coin-flight animation
in question_player.gd now only plays when both conditions are met
(BlockSessionService.submit_core_attempt's existing "mastered" flag
plus met_time_limit), so the effect never promises a coin that isn't
actually earned.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-07-28 20:17:07 -05:00
hzandClaude Sonnet 5 efae7e0b2b Add live RZ Points balance widget to question and shell headers
Wires the recently-merged question-completion-credits backend
(GET /api/credits) into the client for the first time: a new
CreditsService autoload fetches/caches the balance, and the embedded
credits field on first-answer responses updates it instantly with no
extra round trip. A shared CreditsBadge widget (star icon, balance,
decorative chevron per mockup) surfaces it in both the question
screen toolbar and the main shell header.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-07-28 19:33:33 -05:00
hzandClaude Sonnet 5 05a5298ed1 Add home button to question screen toolbar to exit the run
Previously the only way out of an active question run was Escape to
open the pause overlay, then click Exit Run. Exposes main.gd's exit
logic publicly (_exit_run -> exit_current_run) so a house-icon button
in the question toolbar can trigger it directly.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-07-28 19:06:12 -05:00
hz b511d27a20 Merge question completion credits 2026-07-28 18:58:21 -05:00
hz 63fa073530 Merge pull request 'Enable HTML5 virtual keyboard for text field input on mobile Safari' (#12) from main into production
Reviewed-on: admin/step3#12
2026-07-27 21:58:41 -05:00
hzandClaude Sonnet 5 41731f5804 Enable HTML5 virtual keyboard for text field input on mobile Safari
iPhone users hit the Web build via Safari, but experimental_virtual_keyboard
was disabled, so focusing a LineEdit/TextEdit never summoned the on-screen
keyboard.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-07-27 21:42:12 -05:00
hz e3e18f6415 Merge pull request 'Fix login/calculator theme bugs, gate flashcard button, hide system label pre-answer' (#11) from main into production
Reviewed-on: admin/step3#11
2026-07-27 01:03:50 -05:00
hz 9f836c8765 Fix login/calculator theme bugs, gate flashcard button, hide system label pre-answer
- Steam-linked login no longer writes literal "<null>" into the email field
  (and no longer locks it read-only) when the verified identity has no email
- Login screen releases focus on app focus-out so the username caret stops
  blinking while the window is inactive
- Calculator display text stays legible in light mode; the LCD-style panel
  background is always dark, so its text should not follow the app theme
- Question player: gate the "save as flashcard" toolbar button behind
  Brand.is_uat_environment(), matching the nav item, so it's hidden in prod
- Hide the discipline/system label while a question is unanswered so it
  can't hint at the answer; reveal it once the explanation is shown
- Top-align question content instead of vertically centering it, with a
  20px top margin, so it sits closer to the progress indicator
2026-07-27 00:43:18 -05:00
hz 86ae650272 Merge pull request 'Main' (#10) from main into production
Reviewed-on: admin/step3#10
2026-07-26 18:37:53 -05:00
hz 23d573e221 Fix GDScript ternary and shadowed-identifier warnings in dashboard scripts
Godot's static checker can't unify a float branch with a null branch in
a ternary, so point_value() now uses explicit if/return; renaming the
local sign variable to sign_prefix avoids shadowing the builtin sign().
2026-07-26 18:28:55 -05:00
hzandClaude Sonnet 5 548b519d44 Vertically center the login screen wordmark against the logo
The RanaZe/STEP 3 TRAINER text block was top-aligned inside the
brand row's HBoxContainer, which is taller than the text due to the
190x190 icon beside it, so it hugged the top instead of centering
against the shield mark.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-07-26 18:08:36 -05:00
hz 1ff2d612a4 Merge pull request 'Main' (#9) from main into production
Reviewed-on: admin/step3#9
2026-07-26 16:53:15 -05:00
hz c0252a916d Merge pull request 'Main' (#7) from main into production
Reviewed-on: admin/step3#7
2026-07-25 22:12:40 -05:00
hz 81e543fdf5 Merge pull request 'Main' (#6) from main into production
Reviewed-on: admin/step3#6
2026-07-25 00:39:14 -05:00
hz ca218a6ae5 Merge pull request 'Main' (#5) from main into production
Reviewed-on: admin/step3#5
2026-07-24 23:46:10 -05:00
hz 7fb5f8fe78 Merge pull request 'main' (#4) from main into production
Reviewed-on: admin/step3#4
2026-07-20 22:09:30 -05:00
hz ee57dbc3e7 Merge pull request 'Move PRD web service to available NodePort' (#1) from main into production
Reviewed-on: admin/step3#1
2026-07-20 21:04:11 -05:00
27 changed files with 822 additions and 43 deletions
+1 -1
View File
@@ -71,7 +71,7 @@ The hostname change creates a new browser origin, so testers should expect to si
## Question promotion behavior
The manual workflow runs the deployed PRD API image as a short-lived pod. It copies only questions that are published and not flagged for review, plus their referenced media. It never copies CCS content, users, attempts, ratings, statistics, simulations, reports, feedback, notes, or donations.
The manual workflow runs the deployed PRD API image as a short-lived pod. It copies only questions that are published and not flagged for review, plus their referenced media. Any promoted question containing media is flagged again in PRD and remains hidden from students until a SUPER user explicitly approves it in production. UAT media approval is not treated as production approval. It never copies CCS content, users, attempts, ratings, statistics, simulations, reports, feedback, notes, or donations.
Promotion is idempotent and does not delete or implicitly unpublish PRD content. Its dry run reports inserts, updates, unchanged questions, and production extras. Apply mode verifies the promoted target records and writes a scoped `ContentPromotions` audit document. Corrective unpublishing remains an explicit SUPER/API or database operation.
+39
View File
@@ -148,6 +148,45 @@ public sealed class ChoicePoolTests
Assert.Null(QuestionValidator.Validate(request));
}
[Fact]
public void PublishedQuestionRequiresCompleteLabReferenceRanges()
{
var request = BuildRequest(5);
request.Labs =
[
new QuestionLab { Name = "Sodium", Value = "128", Unit = "mEq/L", Reference = "" }
];
Assert.Contains("requires a reference range", QuestionValidator.Validate(request));
request.Labs[0].Reference = "136–145 mEq/L";
Assert.Null(QuestionValidator.Validate(request));
}
[Fact]
public void DraftQuestionMaySaveLabWithoutReferenceRange()
{
var request = BuildRequest(5);
request.IsPublished = false;
request.Labs =
[
new QuestionLab { Name = "Sodium", Value = "128", Unit = "mEq/L", Reference = "" }
];
Assert.Null(QuestionValidator.Validate(request));
}
[Fact]
public void LabEntriesAlwaysRequireNamesAndValues()
{
var request = BuildRequest(5);
request.Labs = [new QuestionLab { Name = "", Value = "128", Reference = "136–145 mEq/L" }];
Assert.Contains("lab name and value", QuestionValidator.Validate(request));
request.Labs = [new QuestionLab { Name = "Sodium", Value = "", Reference = "136–145 mEq/L" }];
Assert.Contains("lab name and value", QuestionValidator.Validate(request));
}
[Theory]
[InlineData("http://example.org/guideline", "HTTPS")]
[InlineData("https://example.org/guideline", null)]
@@ -49,6 +49,44 @@ public sealed class ContentPromotionTests
Assert.False(QuestionPromotionCommand.IsEligible(new BsonDocument("IsPublished", false)));
}
[Fact]
public void ProductionPromotionFlagsMediaForSeparateApproval()
{
var updated = new DateTime(2026, 7, 30, 12, 0, 0, DateTimeKind.Utc);
var source = new BsonDocument
{
["ExternalId"] = "media-question",
["IsPublished"] = true,
["FlaggedForReviewUtc"] = BsonNull.Value,
["UpdatedUtc"] = updated,
["Media"] = new BsonArray { ObjectId.GenerateNewId().ToString() }
};
var prepared = QuestionPromotionCommand.PrepareForProduction(source);
Assert.Equal(updated, prepared["FlaggedForReviewUtc"].ToUniversalTime());
Assert.Equal(QuestionPromotionCommand.ProductionMediaReviewReason, prepared["FlagReason"].AsString);
Assert.Equal(QuestionPromotionCommand.ProductionMediaReviewActor, prepared["FlaggedByUserId"].AsString);
Assert.True(source["FlaggedForReviewUtc"].IsBsonNull);
}
[Fact]
public void ProductionPromotionLeavesMediaFreeQuestionApprovalStateAlone()
{
var source = new BsonDocument
{
["ExternalId"] = "text-question",
["IsPublished"] = true,
["FlaggedForReviewUtc"] = BsonNull.Value,
["Media"] = new BsonArray()
};
var prepared = QuestionPromotionCommand.PrepareForProduction(source);
Assert.True(prepared["FlaggedForReviewUtc"].IsBsonNull);
Assert.False(prepared.Contains("FlagReason"));
}
[Fact]
public void QuestionMediaIdsSupportStoredApiUrlsAndRawIds()
{
@@ -175,7 +175,7 @@ public class QuestionsController : ControllerBase
_metrics.AttemptDuplicate();
}
await _reviewQueue.AddFromFirstAttemptAsync(record.Attempt);
if (record.Inserted && !User.IsInRole("SUPER"))
if (record.Inserted && record.Attempt.IsCorrect && req.MetTimeLimit)
await _credits.AwardForFirstAnswerAsync(userId, record.Attempt.Id ?? "");
var ratingOwner = Guid.NewGuid().ToString("N");
@@ -49,4 +49,5 @@ public class FirstQuestionAttemptRequest
public string ChoiceId { get; set; } = "";
public List<string> PresentedChoiceIds { get; set; } = [];
public int DurationSeconds { get; set; }
public bool MetTimeLimit { get; set; } = true;
}
+2
View File
@@ -47,4 +47,6 @@ public class RatingProfileResponse
public int OverallElo { get; set; }
public int RatedQuestionCount { get; set; }
public Dictionary<string, int> CategoryElos { get; set; } = [];
public string Tier { get; set; } = "";
public int TierDivision { get; set; }
}
@@ -25,8 +25,11 @@ public static class ContentPromotionCommand
await source.RunCommandAsync<BsonDocument>(new BsonDocument("ping", 1));
await target.RunCommandAsync<BsonDocument>(new BsonDocument("ping", 1));
var questions = await source.GetCollection<BsonDocument>("Questions")
var sourceQuestions = await source.GetCollection<BsonDocument>("Questions")
.Find(Builders<BsonDocument>.Filter.Eq("IsPublished", true)).ToListAsync();
var questions = sourceQuestions
.Select(QuestionPromotionCommand.PrepareForProduction)
.ToList();
var mediaIds = CollectMediaIds(questions);
var media = mediaIds.Count == 0
? []
+6
View File
@@ -66,8 +66,14 @@ public sealed class CreditStore
value => value.UserId == userId,
Builders<UserCreditAccount>.Update
.SetOnInsert(value => value.UserId, userId)
.SetOnInsert(value => value.Credits, 0)
.SetOnInsert(value => value.RecentEarnings, [])
.SetOnInsert(value => value.UpdatedUtc, now),
new UpdateOptions { IsUpsert = true });
await _accounts.UpdateOneAsync(
Builders<UserCreditAccount>.Filter.Eq(value => value.UserId, userId) &
Builders<UserCreditAccount>.Filter.Exists(nameof(UserCreditAccount.RecentEarnings), false),
Builders<UserCreditAccount>.Update.Set(value => value.RecentEarnings, []));
var cutoff = now - CreditEconomy.EarningWindow;
await _accounts.UpdateOneAsync(
value => value.UserId == userId,
@@ -9,6 +9,8 @@ namespace QuestionsApi.Services;
public static class QuestionPromotionCommand
{
public const string Confirmation = "PROMOTE_TO_PRD";
internal const string ProductionMediaReviewReason = "Contains media - pending production review";
internal const string ProductionMediaReviewActor = "system:production-promotion";
public static async Task<int> RunAsync(string[] args, TextWriter output)
{
@@ -28,9 +30,9 @@ public static class QuestionPromotionCommand
var questionFilter = Builders<BsonDocument>.Filter.Eq("IsPublished", true)
& Builders<BsonDocument>.Filter.Eq("FlaggedForReviewUtc", BsonNull.Value);
var questions = await source.GetCollection<BsonDocument>("Questions")
var sourceQuestions = await source.GetCollection<BsonDocument>("Questions")
.Find(questionFilter).ToListAsync();
var duplicateIds = questions
var duplicateIds = sourceQuestions
.GroupBy(ExternalId, StringComparer.Ordinal)
.Where(group => group.Count() > 1)
.Select(group => group.Key)
@@ -38,6 +40,7 @@ public static class QuestionPromotionCommand
if (duplicateIds.Count > 0)
throw new InvalidOperationException($"duplicate eligible ExternalIds: {string.Join(", ", duplicateIds)}");
var questions = sourceQuestions.Select(PrepareForProduction).ToList();
var mediaIds = CollectMediaIds(questions);
var media = mediaIds.Count == 0
? []
@@ -95,7 +98,7 @@ public static class QuestionPromotionCommand
["SourceDatabase"] = options.SourceDatabase,
["TargetDatabase"] = options.TargetDatabase,
["Scope"] = "questions",
["Eligibility"] = "published_unflagged",
["Eligibility"] = "published_unflagged; media_requires_production_review",
["Questions"] = summary.EligibleQuestions,
["Inserted"] = summary.Inserted,
["Updated"] = summary.Updated,
@@ -118,6 +121,21 @@ public static class QuestionPromotionCommand
question.GetValue("IsPublished", false).ToBoolean()
&& (!question.TryGetValue("FlaggedForReviewUtc", out var flagged) || flagged.IsBsonNull);
internal static BsonDocument PrepareForProduction(BsonDocument source)
{
var prepared = new BsonDocument(source);
if (!HasMedia(prepared))
return prepared;
var reviewUtc = prepared.TryGetValue("UpdatedUtc", out var updated) && updated.IsValidDateTime
? updated
: new BsonDateTime(DateTime.UnixEpoch);
prepared["FlaggedForReviewUtc"] = reviewUtc;
prepared["FlagReason"] = ProductionMediaReviewReason;
prepared["FlaggedByUserId"] = ProductionMediaReviewActor;
return prepared;
}
internal static HashSet<ObjectId> CollectMediaIds(IEnumerable<BsonDocument> questions)
{
var ids = new HashSet<ObjectId>();
@@ -192,6 +210,11 @@ public static class QuestionPromotionCommand
private static string ExternalId(BsonDocument document) =>
document.GetValue("ExternalId", "").AsString;
private static bool HasMedia(BsonDocument question) =>
question.TryGetValue("Media", out var media)
&& media.IsBsonArray
&& media.AsBsonArray.Count > 0;
private static async Task UpsertQuestionsAsync(IMongoDatabase target, IEnumerable<BsonDocument> questions)
{
var collection = target.GetCollection<BsonDocument>("Questions");
@@ -224,7 +247,7 @@ public static class QuestionPromotionCommand
{
mode,
scope = "questions",
eligibility = "published_unflagged",
eligibility = "published_unflagged; media_requires_production_review",
source = options.SourceDatabase,
target = options.TargetDatabase,
eligibleQuestions = summary.EligibleQuestions,
@@ -68,11 +68,31 @@ public static class QuestionValidator
if (string.IsNullOrWhiteSpace(req.System)) return "system required";
if (string.IsNullOrWhiteSpace(req.Discipline)) return "discipline required";
if (req.Difficulty is < 1 or > 5) return "difficulty must be between 1 and 5";
var labValidation = ValidateLabs(req.Labs, req.IsPublished);
if (labValidation is not null) return labValidation;
var referenceValidation = ValidateReferences(req.References, req.IsPublished);
if (referenceValidation is not null) return referenceValidation;
return null;
}
public static string? ValidateLabs(IReadOnlyCollection<QuestionLab>? labs, bool requireComplete)
{
if (labs is null)
return "labs must be an array";
if (labs.Count > 50)
return "questions support at most fifty lab results";
foreach (var lab in labs)
{
if (string.IsNullOrWhiteSpace(lab.Name) || string.IsNullOrWhiteSpace(lab.Value))
return "lab name and value are required";
if (requireComplete && string.IsNullOrWhiteSpace(lab.Reference))
return $"published question lab '{lab.Name.Trim()}' requires a reference range";
}
return null;
}
public static string? ValidateReferences(IReadOnlyCollection<QuestionReference>? references, bool required)
{
if (references is null)
+30 -1
View File
@@ -27,14 +27,43 @@ public class RatingStore
public async Task<RatingProfileResponse> GetProfileAsync(string userId)
{
var rating = await GetOrCreateUserRatingAsync(userId);
var (tierName, tierDivision) = GetTier(rating.OverallElo);
return new RatingProfileResponse
{
OverallElo = rating.OverallElo,
RatedQuestionCount = rating.RatedQuestionCount,
CategoryElos = rating.CategoryElos
CategoryElos = rating.CategoryElos,
Tier = tierName,
TierDivision = tierDivision
};
}
private static readonly (string Name, int FloorElo)[] TierBands =
[
("Bronze", MinElo),
("Silver", 1000),
("Gold", 1300),
("Platinum", 1900),
("Diamond", 2200)
];
private static (string Name, int Division) GetTier(int elo)
{
var bandIndex = 0;
for (var i = 0; i < TierBands.Length; i++)
{
if (elo >= TierBands[i].FloorElo)
bandIndex = i;
}
var band = TierBands[bandIndex];
var nextFloor = bandIndex + 1 < TierBands.Length ? TierBands[bandIndex + 1].FloorElo : band.FloorElo + 300;
var bandWidth = nextFloor - band.FloorElo;
var progress = bandWidth > 0 ? (elo - band.FloorElo) / (double)bandWidth : 0;
var division = 3 - Math.Clamp((int)(progress * 3), 0, 2);
return (band.Name, division);
}
public async Task<UserRating> GetOrInitializeHistoryAsync(string userId, DateTime nowUtc)
{
var rating = await GetOrCreateUserRatingAsync(userId);
+13
View File
@@ -0,0 +1,13 @@
<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 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.79 17 5 19 5a1 1 0 0 1 1 1z" />
</svg>

After

Width:  |  Height:  |  Size: 381 B

+43
View File
@@ -0,0 +1,43 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://b6wxo6lr3s8ll"
path="res://.godot/imported/shield.svg-fbfc812301b797aa0266f7b4e83e6ef3.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/icons/lucide/shield.svg"
dest_files=["res://.godot/imported/shield.svg-fbfc812301b797aa0266f7b4e83e6ef3.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
+1 -1
View File
@@ -25,7 +25,7 @@ html/custom_html_shell="res://web/medmind_shell.html"
html/head_include="<meta name=\"description\" content=\"RanaZe: Step 3 Trainer pre-alpha - Focused practice for better clinical decisions.\">"
html/canvas_resize_policy=2
html/focus_canvas_on_start=true
html/experimental_virtual_keyboard=false
html/experimental_virtual_keyboard=true
progressive_web_app/enabled=false
[preset.1]
+5 -5
View File
@@ -13,14 +13,13 @@ config_version=5
config/name="RanaZe: Step 3 Trainer"
config/version="pre-alpha"
run/main_scene="res://scenes/main.tscn"
config/features=PackedStringArray("4.6", "GL Compatibility")
config/icon="res://assets/branding/ranaze-app-icon.png"
config/use_custom_user_dir=true
config/custom_user_dir_name="MedMind"
config/features=PackedStringArray("4.6", "GL Compatibility")
boot_splash/bg_color=Color(0.019607844, 0.02745098, 0.05882353, 1)
boot_splash/stretch_mode=0
boot_splash/image="res://assets/branding/ranaze-mark.png"
boot_splash/fullsize=false
boot_splash/use_filter=true
config/icon="res://assets/branding/ranaze-app-icon.png"
[autoload]
@@ -40,6 +39,7 @@ NotesService="*res://scripts/autoload/notes_service.gd"
BookmarkService="*res://scripts/autoload/bookmark_service.gd"
ReviewQueueService="*res://scripts/autoload/review_queue_service.gd"
DashboardService="*res://scripts/autoload/dashboard_service.gd"
CreditsService="*res://scripts/autoload/credits_service.gd"
FlashcardService="*res://scripts/autoload/flashcard_service.gd"
AccountProfileService="*res://scripts/autoload/account_profile_service.gd"
DonationService="*res://scripts/autoload/donation_service.gd"
@@ -57,9 +57,9 @@ window/stretch/aspect="expand"
[rendering]
rendering_device/driver.windows="d3d12"
renderer/rendering_method="gl_compatibility"
renderer/rendering_method.mobile="gl_compatibility"
rendering_device/driver.windows="d3d12"
textures/vram_compression/import_etc2_astc=true
[step3]
+67
View File
@@ -0,0 +1,67 @@
extends Node
signal credits_changed
signal credits_failed(message: String)
const EndpointConfig := preload("res://scripts/autoload/api_endpoint_config.gd")
const DEFAULT_URL := "https://us3q.ranaze.com/api/credits"
var credits: int = 0
var loaded := false
var loading := false
var request: HTTPRequest
var request_token := ""
var api_url := DEFAULT_URL
func _ready() -> void:
api_url = EndpointConfig.resolve(
str(ProjectSettings.get_setting("step3/credits_api_url", DEFAULT_URL)),
"/api/credits")
request = HTTPRequest.new()
request.timeout = 20.0
request.request_completed.connect(_on_completed)
add_child(request)
AuthState.auth_changed.connect(_on_auth_changed)
func refresh() -> void:
if loading or not AuthState.can_send_authenticated_request():
return
loading = true
request_token = AuthState.access_token
var headers := PackedStringArray(["Authorization: Bearer %s" % request_token])
var error := request.request(api_url, headers, HTTPClient.METHOD_GET)
if error != OK:
loading = false
credits_failed.emit("Points balance could not be loaded. Check your connection and try again.")
func apply_status(parsed: Dictionary) -> void:
credits = int(parsed.get("credits", credits))
loaded = true
credits_changed.emit()
func _on_completed(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
loading = false
if response_code == 401:
AuthState.handle_unauthorized(request_token)
return
if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300:
credits_failed.emit("Points balance could not be loaded. Check your connection and try again.")
return
var parsed: Variant = JSON.parse_string(body.get_string_from_utf8())
if typeof(parsed) != TYPE_DICTIONARY:
credits_failed.emit("The credits service returned an invalid response.")
return
apply_status(parsed)
func _on_auth_changed() -> void:
if not AuthState.is_logged_in:
credits = 0
loaded = false
loading = false
if request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED:
request.cancel_request()
+1
View File
@@ -0,0 +1 @@
uid://bi323du7o7dn2
+15 -4
View File
@@ -38,6 +38,8 @@ var load_state := "idle"
var retry_attempt := 0
var next_retry_seconds := 0
var overall_elo := 1000
var rank_tier := "Bronze"
var rank_division := 3
var rated_question_count := 0
var category_elos := {}
var last_rating_change := {}
@@ -246,6 +248,8 @@ func clear_questions() -> void:
retry_attempt = 0
next_retry_seconds = 0
overall_elo = 1000
rank_tier = "Bronze"
rank_division = 3
rated_question_count = 0
category_elos = {}
last_rating_change = {}
@@ -257,10 +261,10 @@ func clear_questions() -> void:
ratings_changed.emit()
func record_first_attempt(question_id: String, choice_id: String, presented_choice_ids: Array = [], duration_seconds: int = 0) -> void:
func record_first_attempt(question_id: String, choice_id: String, presented_choice_ids: Array = [], duration_seconds: int = 0, met_time_limit: bool = true) -> void:
if not AuthState.can_send_authenticated_request() or question_id == "" or choice_id == "":
return
_enqueue_attempt("answer", question_id, choice_id, presented_choice_ids, duration_seconds)
_enqueue_attempt("answer", question_id, choice_id, presented_choice_ids, duration_seconds, met_time_limit)
func skip_question(question_id: String) -> void:
@@ -386,7 +390,7 @@ func _on_retry_timer_timeout() -> void:
load_questions_from_api(false)
func _enqueue_attempt(kind: String, question_id: String, choice_id: String, presented_choice_ids: Array = [], duration_seconds: int = 0) -> void:
func _enqueue_attempt(kind: String, question_id: String, choice_id: String, presented_choice_ids: Array = [], duration_seconds: int = 0, met_time_limit: bool = true) -> void:
var account := AuthState.username.strip_edges()
if account == "":
return
@@ -400,6 +404,7 @@ func _enqueue_attempt(kind: String, question_id: String, choice_id: String, pres
"choice_id": choice_id,
"presented_choice_ids": presented_choice_ids.duplicate(),
"duration_seconds": maxi(0, duration_seconds),
"met_time_limit": met_time_limit,
"retry_count": 0
})
_save_attempt_queue()
@@ -432,7 +437,8 @@ func _process_attempt_queue() -> void:
"questionExternalId": question_id,
"choiceId": str(queued.get("choice_id", "")),
"presentedChoiceIds": queued.get("presented_choice_ids", []),
"durationSeconds": int(queued.get("duration_seconds", 0))
"durationSeconds": int(queued.get("duration_seconds", 0)),
"metTimeLimit": bool(queued.get("met_time_limit", true))
})
if str(queued.get("kind", "answer")) == "skip":
url = "%s/%s/skip" % [question_api_base_url.rstrip("/"), question_id.uri_encode()]
@@ -507,6 +513,9 @@ func _apply_first_attempt_response(parsed: Dictionary) -> void:
var rating: Dictionary = parsed.get("rating", {})
_update_question_elo(question_id, question_elo)
_apply_rating_change(rating)
var credits_status: Variant = parsed.get("credits", null)
if typeof(credits_status) == TYPE_DICTIONARY:
CreditsService.apply_status(credits_status)
choice_percentages_updated.emit(question_id, percentages)
question_rating_updated.emit(question_id, question_elo, rating)
@@ -612,6 +621,8 @@ func _on_ratings_loaded(result: int, response_code: int, _headers: PackedStringA
return
overall_elo = int(parsed.get("overallElo", parsed.get("overall_elo", 1000)))
rank_tier = str(parsed.get("tier", rank_tier))
rank_division = int(parsed.get("tierDivision", parsed.get("tier_division", rank_division)))
rated_question_count = int(parsed.get("ratedQuestionCount", parsed.get("rated_question_count", 0)))
category_elos = parsed.get("categoryElos", parsed.get("category_elos", {}))
ratings_changed.emit()
+2 -2
View File
@@ -101,8 +101,8 @@ func refresh_theme_controls() -> void:
return
var dark := UIThemeService.is_dark_mode()
add_theme_stylebox_override("panel", Brand.content_card_box(dark, true, Brand.ROYAL_BLUE))
display_label.add_theme_color_override("font_color", Brand.text_color(dark))
operation_label.add_theme_color_override("font_color", Brand.muted_color(dark))
display_label.add_theme_color_override("font_color", Color.WHITE)
operation_label.add_theme_color_override("font_color", Brand.muted_color(true))
for button in keypad_buttons:
var accent := Brand.ROYAL_BLUE if str(button.get_meta("calculator_token", "")) in ["+", "−", "×", "÷", "="] else Brand.SLATE
button.add_theme_stylebox_override("normal", Brand.badge_box(dark, accent))
+165
View File
@@ -0,0 +1,165 @@
extends PanelContainer
const Brand := preload("res://scripts/ui/medmind_style.gd")
const SHIELD_ICON := preload("res://assets/icons/lucide/shield.svg")
const STAR_ICON := preload("res://assets/icons/lucide/star.svg")
const CHEVRON_ICON := preload("res://assets/icons/lucide/chevron-down.svg")
var rank_icon_panel: PanelContainer
var rank_icon_texture: TextureRect
var rank_tier_label: Label
var rank_elo_label: Label
var divider: ColorRect
var icon_panel: PanelContainer
var icon_texture: TextureRect
var value_label: Label
var caption_label: Label
var displayed_value := 0
var _initialized := false
func _ready() -> void:
size_flags_vertical = Control.SIZE_SHRINK_CENTER
_build_ui()
refresh_theme_controls()
_on_credits_changed()
_on_ratings_changed()
CreditsService.credits_changed.connect(_on_credits_changed)
QuestionBankService.ratings_changed.connect(_on_ratings_changed)
resized.connect(func(): pivot_offset = size * 0.5)
func _build_ui() -> void:
var margin := MarginContainer.new()
margin.add_theme_constant_override("margin_left", 7)
margin.add_theme_constant_override("margin_right", 7)
margin.add_theme_constant_override("margin_top", 5)
margin.add_theme_constant_override("margin_bottom", 5)
add_child(margin)
var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 5)
margin.add_child(row)
rank_icon_panel = PanelContainer.new()
rank_icon_panel.custom_minimum_size = Vector2(22, 22)
rank_icon_panel.size_flags_vertical = Control.SIZE_SHRINK_CENTER
rank_icon_texture = TextureRect.new()
rank_icon_texture.texture = SHIELD_ICON
rank_icon_texture.custom_minimum_size = Vector2(13, 13)
rank_icon_texture.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
rank_icon_texture.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
rank_icon_panel.add_child(rank_icon_texture)
row.add_child(rank_icon_panel)
var rank_copy := VBoxContainer.new()
rank_copy.size_flags_vertical = Control.SIZE_SHRINK_CENTER
rank_copy.add_theme_constant_override("separation", 0)
rank_tier_label = Label.new()
rank_tier_label.add_theme_font_override("font", Brand.font)
rank_tier_label.add_theme_font_size_override("font_size", 13)
rank_copy.add_child(rank_tier_label)
rank_elo_label = Label.new()
rank_elo_label.add_theme_font_override("font", Brand.font)
rank_elo_label.add_theme_font_size_override("font_size", 9)
rank_copy.add_child(rank_elo_label)
row.add_child(rank_copy)
divider = ColorRect.new()
divider.custom_minimum_size = Vector2(1, 22)
divider.size_flags_vertical = Control.SIZE_SHRINK_CENTER
row.add_child(divider)
icon_panel = PanelContainer.new()
icon_panel.custom_minimum_size = Vector2(22, 22)
icon_panel.size_flags_vertical = Control.SIZE_SHRINK_CENTER
icon_texture = TextureRect.new()
icon_texture.texture = STAR_ICON
icon_texture.custom_minimum_size = Vector2(13, 13)
icon_texture.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
icon_texture.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
icon_panel.add_child(icon_texture)
row.add_child(icon_panel)
var copy := VBoxContainer.new()
copy.size_flags_vertical = Control.SIZE_SHRINK_CENTER
copy.add_theme_constant_override("separation", 0)
value_label = Label.new()
value_label.add_theme_font_override("font", Brand.font)
value_label.add_theme_font_size_override("font_size", 13)
copy.add_child(value_label)
caption_label = Label.new()
caption_label.text = "Rz Coins"
caption_label.add_theme_font_override("font", Brand.font)
caption_label.add_theme_font_size_override("font_size", 9)
copy.add_child(caption_label)
row.add_child(copy)
var chevron := TextureRect.new()
chevron.texture = CHEVRON_ICON
chevron.custom_minimum_size = Vector2(12, 12)
chevron.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
chevron.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
chevron.mouse_filter = Control.MOUSE_FILTER_IGNORE
chevron.size_flags_vertical = Control.SIZE_SHRINK_CENTER
row.add_child(chevron)
func refresh_theme_controls() -> void:
var dark := UIThemeService.is_dark_mode()
add_theme_stylebox_override("panel", Brand.content_card_box(dark, true, Brand.ROYAL_BLUE))
rank_icon_panel.add_theme_stylebox_override("panel", Brand.icon_circle_box(dark, Brand.ROYAL_BLUE, 11))
rank_icon_texture.modulate = Brand.ROYAL_BLUE
rank_tier_label.add_theme_color_override("font_color", Brand.text_color(dark))
rank_elo_label.add_theme_color_override("font_color", Brand.ROYAL_BLUE)
divider.color = Color("2a365a") if dark else Brand.LIGHT_BORDER
icon_panel.add_theme_stylebox_override("panel", Brand.icon_circle_box(dark, Brand.AMBER, 11))
icon_texture.modulate = Brand.AMBER
value_label.add_theme_color_override("font_color", Brand.text_color(dark))
caption_label.add_theme_color_override("font_color", Brand.muted_color(dark))
func bump() -> void:
var tween := create_tween()
tween.tween_property(self, "scale", Vector2(1.12, 1.12), 0.12).set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT)
tween.tween_property(self, "scale", Vector2.ONE, 0.18).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_IN)
func _on_ratings_changed() -> void:
var roman := {1: "I", 2: "II", 3: "III"}
var division_label: String = roman.get(QuestionBankService.rank_division, "")
rank_tier_label.text = "%s %s" % [QuestionBankService.rank_tier, division_label]
rank_elo_label.text = "%d ELO" % QuestionBankService.overall_elo
func _on_credits_changed() -> void:
if not CreditsService.loaded:
_initialized = false
displayed_value = 0
value_label.text = "--"
return
if not _initialized:
_initialized = true
displayed_value = CreditsService.credits
value_label.text = _format_thousands(displayed_value)
return
var tween := create_tween()
tween.tween_method(_set_displayed_value, displayed_value, CreditsService.credits, 0.5).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
func _set_displayed_value(value: int) -> void:
displayed_value = value
value_label.text = _format_thousands(value)
func _format_thousands(value: int) -> String:
var digits := str(absi(value))
var grouped := ""
var count := 0
for index in range(digits.length() - 1, -1, -1):
grouped = digits[index] + grouped
count += 1
if count % 3 == 0 and index != 0:
grouped = "," + grouped
return ("-" if value < 0 else "") + grouped
+1
View File
@@ -0,0 +1 @@
uid://doisoxqnk1mf3
+6 -2
View File
@@ -99,9 +99,13 @@ func _draw() -> void:
static func point_value(point: Dictionary, series_mode: String) -> Variant:
if series_mode == "accuracy":
return float(point.get("accuracy", 0)) if int(point.get("total", 0)) > 0 else null
if int(point.get("total", 0)) > 0:
return float(point.get("accuracy", 0))
return null
var value: Variant = point.get("questionBankRating", null)
return null if value == null else float(value)
if value == null:
return null
return float(value)
static func axis_bounds(series_points: Array, series_mode: String) -> Vector2:
+4 -4
View File
@@ -229,8 +229,8 @@ func _render_rating_card(key: String, sparkline: Control, accent: Color, rating_
if delta_value != null:
var delta := int(delta_value)
var arrow := "↗" if delta >= 0 else "↘"
var sign := "+" if delta >= 0 else ""
trend_text = "%s %s%d this week" % [arrow, sign, delta]
var sign_prefix := "+" if delta >= 0 else ""
trend_text = "%s %s%d this week" % [arrow, sign_prefix, delta]
trend_color = Brand.SUCCESS if delta >= 0 else Brand.MAGENTA
if percentile_value != null:
caption = "Top %d%% of users" % int(percentile_value)
@@ -269,8 +269,8 @@ func _render_accuracy_card(data: Dictionary) -> void:
if delta_value != null:
var delta := int(delta_value)
var arrow := "↗" if delta >= 0 else "↘"
var sign := "+" if delta >= 0 else ""
trend_text = "%s %s%d%% vs last 100" % [arrow, sign, delta]
var sign_prefix := "+" if delta >= 0 else ""
trend_text = "%s %s%d%% vs last 100" % [arrow, sign_prefix, delta]
trend_color = Brand.SUCCESS if delta >= 0 else Brand.MAGENTA
_set_stat_card("accuracy", "%d%%" % accuracy, trend_text, trend_color, "Last %d questions" % int(data.get("accuracySampleSize", 0)))
accuracy_progress_bar.value = accuracy
+10 -1
View File
@@ -65,6 +65,13 @@ var compact_layout := false
var layout_updating := false
func _notification(what: int) -> void:
if what == NOTIFICATION_APPLICATION_FOCUS_OUT:
var focus_owner := get_viewport().gui_get_focus_owner()
if focus_owner != null:
focus_owner.release_focus()
func _ready() -> void:
auth_base_url = EndpointConfig.resolve(AUTH_BASE, "/api/Auth")
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
@@ -151,6 +158,7 @@ func _build_ui() -> void:
large_icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
brand_row.add_child(large_icon)
var wordmark := VBoxContainer.new()
wordmark.size_flags_vertical = Control.SIZE_SHRINK_CENTER
wordmark.add_theme_constant_override("separation", 4)
brand_row.add_child(wordmark)
var name_row := HBoxContainer.new()
@@ -850,7 +858,8 @@ func _on_steam_status(result: int, code: int, _headers: PackedStringArray, body:
"requires_account":
poll_timer.stop()
_close_steam_popup()
external_verified_email = str(data.get("email", ""))
var verified_email_value: Variant = data.get("email")
external_verified_email = "" if verified_email_value == null else str(verified_email_value)
steam_email_input.text = external_verified_email
steam_email_input.editable = external_verified_email.is_empty()
_set_mode("steam")
+10 -2
View File
@@ -3,6 +3,7 @@ extends Control
const Style := preload("res://scripts/ui/ui_style.gd")
const Brand := preload("res://scripts/ui/medmind_style.gd")
const NavigationRoutes := preload("res://scripts/ui/navigation_routes.gd")
const CreditsBadge := preload("res://scripts/ui/credits_badge.gd")
var screens := {}
var _account_profile_synced := false
@@ -29,6 +30,7 @@ var drawer_open := false
var menu_button: Button
var account_button: Button
var account_chevron_button: Button
var credits_badge: PanelContainer
var account_menu: PanelContainer
var nav_buttons: Dictionary = {}
var current_nav_key := "home"
@@ -204,6 +206,9 @@ func _build_ui() -> void:
_style_account_chevron(account_chevron_button)
header.add_child(account_chevron_button)
credits_badge = CreditsBadge.new()
header.add_child(credits_badge)
var screens_container := MarginContainer.new()
screens_margin = screens_container
screens_host = screens_container
@@ -769,6 +774,7 @@ func _on_auth_changed() -> void:
if not _account_profile_synced:
_account_profile_synced = true
AccountProfileService.refresh()
CreditsService.refresh()
else:
_account_profile_synced = false
if AuthState.is_logged_in and AuthState.requires_legal_acceptance and current_screen_name not in ["login", "legal_consent"]:
@@ -797,6 +803,8 @@ func _on_theme_changed(is_dark_mode: bool) -> void:
_style_account_avatar(account_button)
if account_chevron_button != null:
_style_account_chevron(account_chevron_button)
if credits_badge != null:
credits_badge.refresh_theme_controls()
if sidebar != null:
sidebar.add_theme_stylebox_override("panel", _sidebar_box())
if header_panel != null:
@@ -896,7 +904,7 @@ func _build_app_menu() -> void:
menu_exit_button.text = "Exit Run"
menu_exit_button.custom_minimum_size = Vector2(0, 48)
UISoundService.set_button_sound(menu_exit_button, "back")
menu_exit_button.pressed.connect(_exit_run)
menu_exit_button.pressed.connect(exit_current_run)
content.add_child(menu_exit_button)
var logout := Button.new()
@@ -943,7 +951,7 @@ func _navigate_from_pause(target_screen: String) -> void:
BlockSessionService.pause_current_question_timer()
func _exit_run() -> void:
func exit_current_run() -> void:
_close_app_menu()
if current_screen_name == "ccs_player":
var ccs_player = screens.get("ccs_player")
+80 -14
View File
@@ -4,6 +4,7 @@ const Style := preload("res://scripts/ui/ui_style.gd")
const Brand := preload("res://scripts/ui/medmind_style.gd")
const FlashcardPrefillDialog := preload("res://scripts/ui/flashcard_prefill_dialog.gd")
const CalculatorPanel := preload("res://scripts/ui/calculator_panel.gd")
const CreditsBadge := preload("res://scripts/ui/credits_badge.gd")
const CLEAR_HIGHLIGHT_DARK_ICON := preload("res://assets/icons/medmind/clear-highlight-dark.svg")
const CLEAR_HIGHLIGHT_LIGHT_ICON := preload("res://assets/icons/medmind/clear-highlight-light.svg")
const LABS_DARK_ICON := preload("res://assets/icons/medmind/labs-dark.svg")
@@ -14,6 +15,7 @@ const FLAG_DARK_ICON := preload("res://assets/icons/medmind/flag-dark.svg")
const FLAG_LIGHT_ICON := preload("res://assets/icons/medmind/flag-light.svg")
const BADGE_CIRCLE_OUTLINE_ICON := preload("res://assets/icons/medmind/badge-circle-outline.svg")
const BADGE_CIRCLE_FILLED_ICON := preload("res://assets/icons/medmind/badge-circle-filled.svg")
const COIN_ICON := preload("res://assets/icons/lucide/star.svg")
const FEEDBACK_FADE_SECONDS := 0.45
const CORRECT_ADVANCE_DELAY_SECONDS := 1.25
const TEXT_SCALE_MIN := 0.85
@@ -90,6 +92,7 @@ var text_smaller_button: Button
var text_larger_button: Button
var labs_button: Button
var calculator_button: Button
var exit_button: Button
var clear_highlight_button: Button
var flag_button: Button
var report_button: Button
@@ -99,6 +102,7 @@ var flashcard_prefill_dialog: Control
var bookmark_button: Button
var app_feedback_button: Button
var theme_button: Button
var credits_badge: PanelContainer
var question_toolbar: HFlowContainer
var header_stack: VBoxContainer
var header_row: HBoxContainer
@@ -317,6 +321,18 @@ func _ready() -> void:
UISoundService.set_button_sound(text_larger_button, "toggle")
text_larger_button.pressed.connect(func(): _adjust_question_text_scale(TEXT_SCALE_STEP))
exit_button = Button.new()
exit_button.name = "ExitButton"
exit_button.tooltip_text = "Return to main area"
exit_button.icon = preload("res://assets/icons/lucide/house.svg")
exit_button.custom_minimum_size = Vector2(44, 44)
exit_button.add_theme_constant_override("icon_max_width", 22)
exit_button.alignment = HORIZONTAL_ALIGNMENT_CENTER
exit_button.icon_alignment = HORIZONTAL_ALIGNMENT_CENTER
exit_button.focus_mode = Control.FOCUS_NONE
UISoundService.set_button_sound(exit_button, "back")
exit_button.pressed.connect(func(): main.exit_current_run())
calculator_button = Button.new()
calculator_button.name = "CalculatorButton"
calculator_button.tooltip_text = "Open calculator"
@@ -404,10 +420,13 @@ func _ready() -> void:
Style.style_chip(app_feedback_button, Style.CYAN)
app_feedback_button.pressed.connect(func(): main.open_feedback_dialog())
credits_badge = CreditsBadge.new()
# Every action is directly visible - no "More" overflow or popups hiding
# controls behind an extra click. No text labels either (their varying widths
# broke even spacing when a button hid itself) - each button's existing
# tooltip_text carries the label as hover text instead.
question_toolbar.add_child(exit_button)
question_toolbar.add_child(text_smaller_button)
question_toolbar.add_child(text_larger_button)
question_toolbar.add_child(calculator_button)
@@ -420,6 +439,7 @@ func _ready() -> void:
question_toolbar.add_child(notes_button)
question_toolbar.add_child(theme_button)
question_toolbar.add_child(app_feedback_button)
question_toolbar.add_child(credits_badge)
header_toolbar_host = HBoxContainer.new()
header_toolbar_host.alignment = BoxContainer.ALIGNMENT_END
@@ -467,6 +487,7 @@ func _ready() -> void:
content_margin.size_flags_vertical = Control.SIZE_EXPAND_FILL
content_margin.add_theme_constant_override("margin_left", 4)
content_margin.add_theme_constant_override("margin_right", 12)
content_margin.add_theme_constant_override("margin_top", 20)
question_scroll.add_child(content_margin)
question_content = VBoxContainer.new()
@@ -787,9 +808,10 @@ func _refresh_responsive_layout() -> void:
header_toolbar_host.reparent(target_parent)
header_toolbar_host.size_flags_horizontal = Control.SIZE_EXPAND_FILL if compact else Control.SIZE_SHRINK_END
question_toolbar.size_flags_horizontal = Control.SIZE_EXPAND_FILL
# 11 bare 44px icon buttons + the wider 92px Feedback button, with 8px gaps
# between all 12 - anything less than this wraps onto a second row.
header_toolbar_host.custom_minimum_size.x = 0.0 if compact else 680.0
# 12 bare 44px icon buttons + the wider 92px Feedback button + the ~194px
# rank/ELO + points badge, with 8px gaps between all 14 - anything less
# than this wraps onto a second row.
header_toolbar_host.custom_minimum_size.x = 0.0 if compact else 960.0
if size.x < 560.0:
nav_palette.columns = 5
else:
@@ -1045,7 +1067,7 @@ func _apply_question_theme() -> void:
var navigator_heading := navigator_popup.find_child("NavigatorHeading", true, false) as Label
if navigator_heading != null:
navigator_heading.add_theme_color_override("font_color", Brand.text_color(dark))
for control in [text_smaller_button, text_larger_button, calculator_button, bookmark_button, notes_button, flashcard_button, app_feedback_button]:
for control in [exit_button, text_smaller_button, text_larger_button, calculator_button, bookmark_button, notes_button, flashcard_button, app_feedback_button]:
if control == null:
continue
var active: bool = control == bookmark_button and BookmarkService.is_bookmarked(str(BlockSessionService.get_current_question().get("id", "")))
@@ -1061,6 +1083,8 @@ func _apply_question_theme() -> void:
_style_theme_toggle_button(dark)
if calculator_panel != null:
calculator_panel.refresh_theme_controls()
if credits_badge != null:
credits_badge.refresh_theme_controls()
if skip_button != null:
var skip_link_color := Brand.SKY_BLUE if dark else Brand.ROYAL_BLUE
skip_button.add_theme_stylebox_override("normal", StyleBoxEmpty.new())
@@ -1305,7 +1329,7 @@ func _load_question() -> void:
if category_text.is_empty():
category_text = str(question.get("system", "")).strip_edges()
category_label.text = category_text
category_label.visible = not category_text.is_empty()
category_label.visible = false
_refresh_labs_button()
presented_choices = question.get("presented_choices", question.get("choices", [])).duplicate(true)
@@ -1542,12 +1566,15 @@ func _submit_or_next() -> void:
return
if BlockSessionService.session_kind in ["core", "bookmark", "review"]:
var attempted_choice := selected_choice
var met_time_limit := true
if BlockSessionService.session_kind == "core":
met_time_limit = BlockSessionService.get_current_elapsed_seconds() <= BlockSessionService.core_current_target_seconds
QuestionBankService.record_first_attempt(
question_id_for_attempt,
attempted_choice,
_presented_choice_ids(),
int(floor(BlockSessionService.get_core_question_total_elapsed_seconds()))
int(floor(BlockSessionService.get_core_question_total_elapsed_seconds())),
met_time_limit
)
var result: Dictionary
if BlockSessionService.session_kind == "core":
@@ -1570,6 +1597,15 @@ func _submit_or_next() -> void:
_update_question_elo_label(true)
_show_answer_review()
_trigger_correct_feedback()
var earned_coin := (
BlockSessionService.session_kind == "core"
and bool(result.get("mastered", false))
and met_time_limit
)
if earned_coin:
var correct_choice_index := _choice_index_for_id(selected_choice)
if correct_choice_index >= 0 and correct_choice_index < choice_rows.size():
_play_coin_burst(choice_rows[correct_choice_index].get_global_rect())
return
_lock_wrong_choice(attempted_choice)
_show_wrong_answer_followup(attempted_choice)
@@ -1716,6 +1752,7 @@ func _show_explanation() -> void:
explanation_plain_text = str(question.get("explanation", ""))
_render_explanation()
explanation_label.visible = true
category_label.visible = not category_label.text.strip_edges().is_empty()
_update_question_elo_label(true)
_refresh_nav_palette()
_update_content_vertical_alignment()
@@ -1740,6 +1777,8 @@ func _clear_answer_review() -> void:
explanation_label.visible = false
if correct_review_panel != null:
correct_review_panel.visible = false
if category_label != null:
category_label.visible = false
_clear_container_children(teaching_cards)
_clear_container_children(review_followups_content)
review_followup_states.clear()
@@ -1751,13 +1790,7 @@ func _clear_answer_review() -> void:
func _update_content_vertical_alignment() -> void:
if question_content == null:
return
# Before an answer is submitted the stem+choices block is usually shorter than the
# available panel height; center it so it doesn't sit pinned to the top with a slab
# of empty space below. Once the answer review content appears, go back to top
# alignment so the growing explanation/follow-up sections read top-down and scroll
# naturally instead of being centered mid-panel.
var answered := correct_review_panel != null and correct_review_panel.visible
question_content.alignment = BoxContainer.ALIGNMENT_BEGIN if answered else BoxContainer.ALIGNMENT_CENTER
question_content.alignment = BoxContainer.ALIGNMENT_BEGIN
func _clear_container_children(container: Control) -> void:
@@ -2165,6 +2198,39 @@ func _trigger_correct_feedback() -> void:
feedback_fog.color = Color(0.25, 1.0, 0.25, _feedback_overlay_alpha(true))
func _play_coin_burst(origin_rect: Rect2) -> void:
if credits_badge == null:
return
var origin := origin_rect.get_center()
var target := credits_badge.get_global_rect().get_center()
var coin_count := 6
var flight_time := 0.55
var stagger := 0.035
for index in range(coin_count):
var coin := TextureRect.new()
coin.texture = COIN_ICON
coin.modulate = Brand.AMBER
coin.custom_minimum_size = Vector2(16, 16)
coin.size = Vector2(16, 16)
coin.top_level = true
coin.mouse_filter = Control.MOUSE_FILTER_IGNORE
coin.pivot_offset = Vector2(8, 8)
var jitter := Vector2(randf_range(-24.0, 24.0), randf_range(-16.0, 16.0))
coin.global_position = origin + jitter - coin.size * 0.5
add_child(coin)
var tween := create_tween()
tween.tween_interval(index * stagger)
tween.set_parallel(true)
tween.tween_property(coin, "global_position", target - coin.size * 0.5, flight_time).set_trans(Tween.TRANS_CUBIC).set_ease(Tween.EASE_IN)
tween.tween_property(coin, "scale", Vector2(0.6, 0.6), flight_time).set_trans(Tween.TRANS_CUBIC).set_ease(Tween.EASE_IN)
tween.set_parallel(false)
tween.tween_callback(coin.queue_free)
var settle_time := (coin_count - 1) * stagger + flight_time
get_tree().create_timer(settle_time).timeout.connect(func():
if credits_badge != null:
credits_badge.bump())
func _update_answer_feedback(delta: float) -> void:
if shake_time > 0.0:
shake_time = max(0.0, shake_time - delta)
@@ -2279,7 +2345,7 @@ func _refresh_toolbar_state() -> void:
if notes_button != null:
notes_button.visible = BlockSessionService.session_kind not in ["simulation", "dynamic"]
if flashcard_button != null:
flashcard_button.visible = BlockSessionService.session_kind not in ["simulation", "dynamic"]
flashcard_button.visible = Brand.is_uat_environment() and BlockSessionService.session_kind not in ["simulation", "dynamic"]
flashcard_button.disabled = current_answer.is_empty()
if bookmark_button != null:
bookmark_button.visible = BlockSessionService.session_kind not in ["simulation", "dynamic"]
+230
View File
@@ -0,0 +1,230 @@
#!/usr/bin/env python3
"""Audit or backfill missing question lab reference ranges.
Ranges are based primarily on the NBME Laboratory Reference Values published
April 2026. The command is dry-run by default and writes a recoverable MongoDB
backup plus an audit record before applying changes.
"""
import argparse
import copy
import uuid
from datetime import datetime, timezone
from pymongo import MongoClient
NBME = {
"sodium": "136–146 mEq/L", "serum sodium": "136–146 mEq/L",
"potassium": "3.5–5.0 mEq/L", "serum potassium": "3.5–5.0 mEq/L",
"chloride": "95–105 mEq/L", "serum chloride": "95–105 mEq/L",
"bicarbonate": "22–28 mEq/L", "serum bicarbonate": "22–28 mEq/L", "hco3-": "22–28 mEq/L",
"blood urea nitrogen": "7–18 mg/dL", "serum urea nitrogen": "7–18 mg/dL",
"urea nitrogen": "7–18 mg/dL", "bun": "7–18 mg/dL",
"creatinine": "0.6–1.2 mg/dL", "serum creatinine": "0.6–1.2 mg/dL",
"baseline creatinine": "0.6–1.2 mg/dL", "postprocedure day 1 creatinine": "0.6–1.2 mg/dL",
"postprocedure day 2 creatinine": "0.6–1.2 mg/dL",
"glucose": "Fasting 70–100; random <140 mg/dL", "serum glucose": "Fasting 70–100; random <140 mg/dL",
"fasting glucose": "70–100 mg/dL", "fingerstick glucose": "70–100 mg/dL",
"point-of-care glucose": "70–100 mg/dL", "plasma glucose during episode": "70–100 mg/dL fasting",
"calcium": "8.4–10.2 mg/dL", "magnesium": "1.5–2.0 mg/dL",
"phosphorus": "3.0–4.5 mg/dL", "albumin": "3.5–5.5 g/dL",
"ast": "12–38 U/L", "aspartate aminotransferase": "12–38 U/L",
"alt": "10–40 U/L", "alanine aminotransferase": "10–40 U/L",
"alkaline phosphatase": "25–100 U/L", "amylase": "25–125 U/L", "lipase": "13–60 U/L",
"total bilirubin": "0.1–1.0 mg/dL", "serum bilirubin": "0.1–1.0 mg/dL",
"direct bilirubin": "0.0–0.3 mg/dL", "lactate dehydrogenase": "45–200 U/L",
"serum osmolality": "275–295 mOsm/kg H2O", "serum uric acid": "3.0–8.2 mg/dL",
"creatine kinase": "Male 25–90; female 10–70 U/L",
"total cholesterol": "Normal <200 mg/dL", "ldl cholesterol": "<160 mg/dL",
"ldl-cholesterol": "<160 mg/dL", "hdl cholesterol": "40–60 mg/dL",
"triglycerides": "Normal <150 mg/dL", "serum ferritin": "Male 20–250; female 10–120 ng/mL",
"tsh": "0.4–4.0 mIU/L", "thyroid-stimulating hormone": "0.4–4.0 mIU/L",
"free t4": "0.9–1.7 ng/dL", "total t3": "100–200 ng/dL",
"serum ige": "0–380 IU/mL", "ph": "7.35–7.45", "pco2": "33–45 mm Hg",
"paco2": "33–45 mm Hg", "arterial pco2": "33–45 mm Hg",
"pao2": "75–105 mm Hg", "po2": "75–105 mm Hg", "arterial po2": "75–105 mm Hg",
"arterial ph": "7.35–7.45",
"hemoglobin": "Male 13.5–17.5; female 12.0–16.0 g/dL",
"hematocrit": "Male 41%–53%; female 36%–46%",
"mean corpuscular volume": "80–100 fL", "mcv": "80–100 fL",
"leukocyte count": "4,500–11,000/mm3", "white blood cell count": "4,500–11,000/mm3",
"wbc count": "4,500–11,000/mm3", "wbc": "4,500–11,000/mm3",
"platelet count": "150,000–400,000/mm3", "platelets": "150,000–400,000/mm3",
"segmented neutrophils": "54%–62%", "band neutrophils": "3%–5%",
"lymphocytes": "25%–33%", "blood eosinophils": "1%–3%",
"pt": "11–15 seconds", "prothrombin time": "11–15 seconds",
"ptt": "25–40 seconds", "activated partial thromboplastin time": "25–40 seconds",
"inr": "0.8–1.2", "d-dimer": "≤250 ng/mL", "fibrinogen": "200–400 mg/dL",
"reticulocyte count": "0.5%–1.5%", "cd4 count": "≥500/mm3",
"hemoglobin a1c": "≤6%", "erythrocyte sedimentation rate": "Male 0–15; female 0–20 mm/h",
"urine osmolality": "50–1200 mOsm/kg H2O", "urine specific gravity": "1.005–1.030",
"urine albumin/creatinine ratio": "<30 mg/g", "urine albumin-to-creatinine ratio": "<30 mg/g",
"urine albumin-creatinine ratio": "<30 mg/g", "urine protein": "Negative; total <150 mg/24 h",
"urine wbc": "0–5/hpf", "urine wbcs": "0–5/hpf", "urine white blood cells": "0–5/hpf",
"urine leukocytes": "0–5/hpf", "urine rbc": "0–2/hpf", "urine rbcs": "0–2/hpf",
"urine erythrocytes": "0–2/hpf", "rbcs": "0–2/hpf", "wbcs": "0–5/hpf",
"bmi": "Adult 19–25 kg/m2", "cd4+ t-lymphocyte count": "≥500/mm3",
"c-reactive protein": "<1.0 mg/dL", "lactate": "0.5–2.2 mmol/L",
"vitamin b12": "200–900 pg/mL", "haptoglobin": "30–200 mg/dL",
"25-hydroxyvitamin d": "20–50 ng/mL", "aldolase": "1.0–7.5 U/L",
"bnp": "<100 pg/mL", "high-sensitivity troponin i": "Assay-specific; typically <99th percentile",
"oxygen saturation": "95%–100%", "room-air oxygen saturation": "95%–100%",
"temperature": "36.5–37.5°C", "pulse": "60–100/min", "respirations": "12–20/min",
"blood pressure": "Adult <120/80 mm Hg", "left ventricular ejection fraction": "55%–70%",
"qrs duration": "≤120 milliseconds", "pr interval": "120–200 milliseconds",
"estimated glomerular filtration rate": "≥60 mL/min/1.73 m2", "estimated gfr": "≥60 mL/min/1.73 m2",
"salicylate": "Therapeutic 10–30 mg/dL", "c3": "90–180 mg/dL", "c4": "10–40 mg/dL",
"c1q": "12–22 mg/dL", "synovial fluid leukocyte count": "<200/mm3",
}
QUALITATIVE = {
"urinalysis", "peripheral blood smear", "serial cardiac troponins", "resting ecg between episodes",
"exercise treadmill test", "serum electrolytes", "urinalysis - leukocyte esterase",
"urinalysis - nitrites", "urinalysis - white blood cells", "compression ultrasonography",
"stool occult blood", "gastric biopsy", "urine toxicology screen", "repeat liver studies",
"synovial fluid gram stain", "synovial fluid bacterial culture", "synovial fluid crystal analysis",
"pulmonary function testing", "chest ct", "adrenal ct", "urine beta-hcg", "lithium level",
"serum beta-hydroxybutyrate", "antistreptolysin o titer", "fev1", "cbc",
"complete blood count", "comprehensive metabolic panel", "ecg", "echocardiogram",
"maternal hepatitis b surface antigen", "leukocyte esterase", "nitrite", "bacteria",
"urine blood", "urine leukocyte esterase", "urine nitrite", "urine casts",
"urine dipstick blood", "pregnancy test", "urine pregnancy test",
"potassium hydroxide preparation from scale scraping", "fetal karyotype",
"glucose and electrolytes", "cbc, ferritin, zinc, and venous lead",
}
CUSTOM = {
"urine sodium": "40–220 mEq/24 h (diet dependent)",
"hiv rna": "Not detected",
"urine erythrocytes": "0–2/hpf", "urine leukocytes": "0–5/hpf",
"urine calcium": "100–300 mg/24 h", "urine blood": "Negative",
"urine nitrite": "Negative", "urine leukocyte esterase": "Negative",
"urine dipstick blood": "Negative", "pregnancy test": "Negative",
"urine pregnancy test": "Negative", "urine beta-hcg": "Negative",
"urine casts": "None to rare hyaline casts",
"urine albumin-creatinine ratio": "<30 mg/g",
"urine albumin-to-creatinine ratio": "<30 mg/g",
"urine albumin/creatinine ratio": "<30 mg/g",
"urine protein": "Negative; total <150 mg/24 h",
"ascitic fluid albumin": "Interpret with serum albumin (SAAG)",
"ascitic fluid total protein": "Clinical-context dependent",
"home glucose values": "Individualized target; commonly 80–130 mg/dL premeal",
"oxygen saturation": "95%–100%", "estimated pulmonary artery systolic pressure": "15–30 mm Hg",
"pulmonary-to-systemic flow ratio (qp:qs)": "Approximately 1:1",
"glucose infusion rate": "Age- and clinical-context dependent",
"tuberculin skin test": "Positive at ≥5, ≥10, or ≥15 mm depending on risk",
"urine output": "Adult target ≥0.5 mL/kg/h",
}
def embedded_test(value):
lowered = value.lower().strip()
aliases = [
("thyroid-stimulating hormone", "thyroid-stimulating hormone"), ("complete metabolic panel", None),
("complete blood count", None), ("liver function tests", None), ("erythrocyte sedimentation rate", "erythrocyte sedimentation rate"),
("measured serum osmolality", "serum osmolality"), ("activated partial thromboplastin time", "activated partial thromboplastin time"),
("platelet count", "platelet count"), ("white blood cell count", "white blood cell count"),
("blood urea nitrogen", "blood urea nitrogen"), ("oxygen saturation", "oxygen saturation"),
("prothrombin time", "prothrombin time"),
("segmented neutrophils", "segmented neutrophils"), ("creatine kinase", "creatine kinase"),
("hematocrit", "hematocrit"), ("hemoglobin", "hemoglobin"), ("leukocyte count", "leukocyte count"),
("arterial ph", "ph"), ("serum lipase", "lipase"), ("serum albumin", "albumin"),
("total cholesterol", "total cholesterol"), ("urine output", "urine output"),
("tuberculin skin test induration", "tuberculin skin test"), ("hiv rna", "hiv rna"),
("cd4 count", "cd4 count"), ("pco2", "pco2"), ("po2", "po2"), ("sodium", "sodium"),
("potassium", "potassium"), ("chloride", "chloride"), ("bicarbonate", "bicarbonate"),
("hco3", "bicarbonate"), ("glucose", "glucose"), ("creatinine", "creatinine"),
("calcium", "calcium"), ("magnesium", "magnesium"), ("phosphorus", "phosphorus"),
("albumin", "albumin"), ("lipase", "lipase"), ("bands", "band neutrophils"),
("lymphocytes", "lymphocytes"), ("inr", "inr"), ("na ", "sodium"), ("cl ", "chloride"),
("k ", "potassium"), ("bun ", "bun"),
]
for prefix, key in aliases:
if lowered.startswith(prefix) or lowered.startswith("admission " + prefix) or lowered.startswith("repeat " + prefix):
return key
return None
def reference_for(name, value):
key = name.strip().lower()
if key == "laboratory result":
key = embedded_test(value)
if key is None:
if " normal" in value.lower() or "within reference range" in value.lower():
return "Normal/within the laboratory reference interval"
return None
if key in CUSTOM:
return CUSTOM[key]
if key in NBME:
return NBME[key]
if key in QUALITATIVE:
return "Expected: normal/negative unless otherwise specified"
return None
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--uri", required=True)
parser.add_argument("--database", required=True)
parser.add_argument("--apply", action="store_true")
args = parser.parse_args()
client = MongoClient(args.uri, serverSelectionTimeoutMS=5000)
db = client[args.database]
questions = db["Questions"]
changes = []
unresolved = []
for question in questions.find({"Labs.0": {"$exists": True}}):
labs = question.get("Labs") or []
original_labs = copy.deepcopy(labs)
updated = False
for lab in labs:
if (lab.get("Reference") or "").strip():
continue
reference = reference_for(str(lab.get("Name") or ""), str(lab.get("Value") or ""))
if reference is None:
unresolved.append((question.get("ExternalId"), lab.get("Name"), lab.get("Value")))
else:
lab["Reference"] = reference
updated = True
if updated:
changes.append((question, original_labs, labs))
print(f"database={args.database} questions_to_update={len(changes)} unresolved_labs={len(unresolved)}")
for item in unresolved:
print(f"UNRESOLVED externalId={item[0]!r} name={item[1]!r} value={item[2]!r}")
if unresolved:
raise SystemExit("Refusing to apply while lab entries remain unresolved")
if not args.apply:
return
batch_id = f"lab-reference-{datetime.now(timezone.utc):%Y%m%dT%H%M%SZ}-{uuid.uuid4().hex[:8]}"
backups = db["QuestionLabReferenceBackups"]
audit = db["ContentMaintenance"]
now = datetime.now(timezone.utc)
for question, original_labs, labs in changes:
backups.insert_one({
"BatchId": batch_id, "QuestionId": question["_id"], "ExternalId": question.get("ExternalId"),
"Labs": original_labs, "BackedUpUtc": now,
})
questions.update_one({"_id": question["_id"]}, {"$set": {"Labs": labs, "UpdatedUtc": now}})
remaining = questions.count_documents({
"IsPublished": True,
"Labs": {"$elemMatch": {"$or": [
{"Reference": {"$exists": False}}, {"Reference": ""}, {"Reference": None}
]}}
})
audit.insert_one({
"BatchId": batch_id, "Operation": "BackfillQuestionLabReferences",
"Database": args.database, "QuestionsUpdated": len(changes),
"RemainingPublishedQuestionsWithMissingReferences": remaining, "CompletedUtc": now,
"Source": "NBME Laboratory Reference Values (April 2026)",
})
print(f"applied batch={batch_id} questions_updated={len(changes)} remaining_published={remaining}")
if remaining:
raise SystemExit("Post-update verification failed")
if __name__ == "__main__":
main()