From 22b0df7aed712ced60c02546a8a4855b25c7ea5a Mon Sep 17 00:00:00 2001 From: hz Date: Tue, 28 Jul 2026 18:40:49 -0500 Subject: [PATCH 1/9] Add isolated question completion credits --- api/QuestionsApi.Tests/CreditEconomyTests.cs | 21 +++++ .../Controllers/CreditsController.cs | 20 +++++ .../Controllers/QuestionsController.cs | 10 ++- api/QuestionsApi/Models/CreditModels.cs | 35 +++++++++ api/QuestionsApi/Models/QuestionResponse.cs | 1 + api/QuestionsApi/Program.cs | 2 + api/QuestionsApi/Services/CreditStore.cs | 78 +++++++++++++++++++ api/QuestionsApi/Services/UserProfileStore.cs | 3 +- docs/QUESTION_COMPLETION_CREDITS.md | 19 +++++ 9 files changed, 186 insertions(+), 3 deletions(-) create mode 100644 api/QuestionsApi.Tests/CreditEconomyTests.cs create mode 100644 api/QuestionsApi/Controllers/CreditsController.cs create mode 100644 api/QuestionsApi/Models/CreditModels.cs create mode 100644 api/QuestionsApi/Services/CreditStore.cs create mode 100644 docs/QUESTION_COMPLETION_CREDITS.md diff --git a/api/QuestionsApi.Tests/CreditEconomyTests.cs b/api/QuestionsApi.Tests/CreditEconomyTests.cs new file mode 100644 index 0000000..c63772b --- /dev/null +++ b/api/QuestionsApi.Tests/CreditEconomyTests.cs @@ -0,0 +1,21 @@ +using QuestionsApi.Models; +using Xunit; + +namespace QuestionsApi.Tests; + +public sealed class CreditEconomyTests +{ + [Fact] + public void EarningPolicy_UsesTheAgreedRollingLimit() + { + Assert.Equal(20, CreditEconomy.RollingEarningLimit); + Assert.Equal(TimeSpan.FromHours(24), CreditEconomy.EarningWindow); + } + + [Fact] + public void CreditStatus_DoesNotExposeUserOrOperationIdentifiers() + { + Assert.Null(typeof(CreditStatusResponse).GetProperty(nameof(UserCreditAccount.UserId))); + Assert.Null(typeof(CreditStatusResponse).GetProperty(nameof(UserCreditAccount.RecentEarnings))); + } +} diff --git a/api/QuestionsApi/Controllers/CreditsController.cs b/api/QuestionsApi/Controllers/CreditsController.cs new file mode 100644 index 0000000..198fe60 --- /dev/null +++ b/api/QuestionsApi/Controllers/CreditsController.cs @@ -0,0 +1,20 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using QuestionsApi.Services; +using System.Security.Claims; + +namespace QuestionsApi.Controllers; + +[ApiController] +[Route("api/credits")] +[Authorize(Roles = "USER,SUPER")] +public sealed class CreditsController(CreditStore credits) : ControllerBase +{ + [HttpGet] + public async Task Get() => Ok(await credits.GetStatusAsync(UserId())); + + private string UserId() => User.FindFirstValue(ClaimTypes.NameIdentifier) + ?? User.FindFirstValue(ClaimTypes.Name) + ?? User.Identity?.Name + ?? throw new UnauthorizedAccessException(); +} diff --git a/api/QuestionsApi/Controllers/QuestionsController.cs b/api/QuestionsApi/Controllers/QuestionsController.cs index 221be3d..3df47a1 100644 --- a/api/QuestionsApi/Controllers/QuestionsController.cs +++ b/api/QuestionsApi/Controllers/QuestionsController.cs @@ -19,6 +19,7 @@ public class QuestionsController : ControllerBase private readonly ServiceMetrics _metrics; private readonly QuestionBookmarkStore _bookmarks; private readonly ReviewQueueStore _reviewQueue; + private readonly CreditStore _credits; public QuestionsController( QuestionStore questions, @@ -28,7 +29,8 @@ public class QuestionsController : ControllerBase AnswerStatisticsStore statistics, ServiceMetrics metrics, QuestionBookmarkStore bookmarks, - ReviewQueueStore reviewQueue) + ReviewQueueStore reviewQueue, + CreditStore credits) { _questions = questions; _attempts = attempts; @@ -38,6 +40,7 @@ public class QuestionsController : ControllerBase _metrics = metrics; _bookmarks = bookmarks; _reviewQueue = reviewQueue; + _credits = credits; } [HttpGet("systems")] @@ -172,6 +175,8 @@ public class QuestionsController : ControllerBase _metrics.AttemptDuplicate(); } await _reviewQueue.AddFromFirstAttemptAsync(record.Attempt); + if (record.Inserted && !User.IsInRole("SUPER")) + await _credits.AwardForFirstAnswerAsync(userId, record.Attempt.Id ?? ""); var ratingOwner = Guid.NewGuid().ToString("N"); var shouldRate = await _attempts.TryAcquireRatingLeaseAsync( @@ -204,7 +209,8 @@ public class QuestionsController : ControllerBase question, await _statistics.GetPercentagesForQuestionAsync(question.ExternalId)), QuestionElo = rating.QuestionElo, - Rating = rating + Rating = rating, + Credits = await _credits.GetStatusAsync(userId) }); } diff --git a/api/QuestionsApi/Models/CreditModels.cs b/api/QuestionsApi/Models/CreditModels.cs new file mode 100644 index 0000000..50f4aea --- /dev/null +++ b/api/QuestionsApi/Models/CreditModels.cs @@ -0,0 +1,35 @@ +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; + +namespace QuestionsApi.Models; + +public static class CreditEconomy +{ + public const int RollingEarningLimit = 20; + public static readonly TimeSpan EarningWindow = TimeSpan.FromHours(24); +} + +public sealed class CreditEvent +{ + public string OperationId { get; set; } = ""; + public DateTime CreatedUtc { get; set; } +} + +public sealed class UserCreditAccount +{ + [BsonId] + [BsonRepresentation(BsonType.ObjectId)] + public string? Id { get; set; } + public string UserId { get; set; } = ""; + public int Credits { get; set; } + public List RecentEarnings { get; set; } = []; + public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow; +} + +public sealed class CreditStatusResponse +{ + public int Credits { get; set; } + public int EarnedInRollingWindow { get; set; } + public int RemainingEarningCapacity { get; set; } + public int RollingWindowHours { get; set; } = 24; +} diff --git a/api/QuestionsApi/Models/QuestionResponse.cs b/api/QuestionsApi/Models/QuestionResponse.cs index 0c636ba..97e70fb 100644 --- a/api/QuestionsApi/Models/QuestionResponse.cs +++ b/api/QuestionsApi/Models/QuestionResponse.cs @@ -141,6 +141,7 @@ public class FirstQuestionAttemptResponse public Dictionary ChoiceSelectionPercentages { get; set; } = []; public int QuestionElo { get; set; } = 1000; public RatingChangeResponse? Rating { get; set; } + public CreditStatusResponse? Credits { get; set; } } public class SkipQuestionResponse diff --git a/api/QuestionsApi/Program.cs b/api/QuestionsApi/Program.cs index c04924b..7daeda7 100644 --- a/api/QuestionsApi/Program.cs +++ b/api/QuestionsApi/Program.cs @@ -66,6 +66,8 @@ builder.Services.Configure(options => }); builder.Services.AddSingleton(); +builder.Services.AddSingleton(TimeProvider.System); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/api/QuestionsApi/Services/CreditStore.cs b/api/QuestionsApi/Services/CreditStore.cs new file mode 100644 index 0000000..5914ed0 --- /dev/null +++ b/api/QuestionsApi/Services/CreditStore.cs @@ -0,0 +1,78 @@ +using MongoDB.Bson; +using MongoDB.Driver; +using QuestionsApi.Models; + +namespace QuestionsApi.Services; + +public sealed class CreditStore +{ + private readonly IMongoCollection _accounts; + private readonly TimeProvider _clock; + + public CreditStore(MongoDatabaseProvider mongo, TimeProvider clock) + { + _accounts = mongo.Database.GetCollection("UserCreditAccounts"); + _clock = clock; + _accounts.Indexes.CreateOne(new CreateIndexModel( + Builders.IndexKeys.Ascending(value => value.UserId), + new CreateIndexOptions { Unique = true, Name = "uq_credit_user" })); + } + + public async Task AwardForFirstAnswerAsync(string userId, string attemptId) + { + if (string.IsNullOrWhiteSpace(attemptId)) return false; + var now = _clock.GetUtcNow().UtcDateTime; + await EnsureAndCleanAsync(userId, now); + + var filters = Builders.Filter; + var belowCap = new BsonDocumentFilterDefinition( + new BsonDocument("$expr", new BsonDocument("$lt", new BsonArray + { + new BsonDocument("$size", "$RecentEarnings"), + CreditEconomy.RollingEarningLimit + }))); + var filter = filters.Eq(value => value.UserId, userId) & + filters.Not(filters.ElemMatch(value => value.RecentEarnings, + earning => earning.OperationId == attemptId)) & + belowCap; + var update = Builders.Update + .Inc(value => value.Credits, 1) + .Push(value => value.RecentEarnings, new CreditEvent + { + OperationId = attemptId, + CreatedUtc = now + }) + .Set(value => value.UpdatedUtc, now); + return (await _accounts.UpdateOneAsync(filter, update)).ModifiedCount == 1; + } + + public async Task GetStatusAsync(string userId) + { + var now = _clock.GetUtcNow().UtcDateTime; + await EnsureAndCleanAsync(userId, now); + var account = await _accounts.Find(value => value.UserId == userId).FirstAsync(); + return new CreditStatusResponse + { + Credits = account.Credits, + EarnedInRollingWindow = account.RecentEarnings.Count, + RemainingEarningCapacity = Math.Max( + 0, CreditEconomy.RollingEarningLimit - account.RecentEarnings.Count) + }; + } + + private async Task EnsureAndCleanAsync(string userId, DateTime now) + { + await _accounts.UpdateOneAsync( + value => value.UserId == userId, + Builders.Update + .SetOnInsert(value => value.UserId, userId) + .SetOnInsert(value => value.UpdatedUtc, now), + new UpdateOptions { IsUpsert = true }); + var cutoff = now - CreditEconomy.EarningWindow; + await _accounts.UpdateOneAsync( + value => value.UserId == userId, + Builders.Update.PullFilter( + value => value.RecentEarnings, + earning => earning.CreatedUtc <= cutoff)); + } +} diff --git a/api/QuestionsApi/Services/UserProfileStore.cs b/api/QuestionsApi/Services/UserProfileStore.cs index 2e3c086..50f9afe 100644 --- a/api/QuestionsApi/Services/UserProfileStore.cs +++ b/api/QuestionsApi/Services/UserProfileStore.cs @@ -19,7 +19,8 @@ public sealed class UserProfileStore "Step3Outcomes", "QuestionReports", "CcsCaseReports", "AppFeedback", "UserStudyProfiles", "ExternalIdentities", "ExternalAuthTransactions", "FlashcardDecks", "FlashcardNotes", "FlashcardSchedules", "FlashcardReviewLogs", - "FlashcardMediaAssets", "FlashcardSettings", "FlashcardImportPreviews" + "FlashcardMediaAssets", "FlashcardSettings", "FlashcardImportPreviews", + "UserCreditAccounts" ]; public UserProfileStore(MongoDatabaseProvider mongo) diff --git a/docs/QUESTION_COMPLETION_CREDITS.md b/docs/QUESTION_COMPLETION_CREDITS.md new file mode 100644 index 0000000..93f2dc3 --- /dev/null +++ b/docs/QUESTION_COMPLETION_CREDITS.md @@ -0,0 +1,19 @@ +# Question-completion credits + +This branch introduces credit earning only. It does not impose a question +limit, redeem credits, sell products, activate passes, consume exam tokens, or +reset QBank progress. + +- A successfully stored first answer earns one credit regardless of whether it + is correct. +- A repeated answer does not earn another credit. +- A skipped question does not earn credit. +- At most 20 credits can be earned in a rolling 24-hour window. +- Historical attempts are not backfilled. +- `GET /api/credits` returns the authenticated user's balance and current + rolling-window capacity. +- The first-attempt response also includes the updated credit status. + +The API is authoritative. Credit grants use the immutable attempt ID as their +idempotency key, so retrying the same completed question cannot grant another +credit. Credit accounts are included in account export and deletion. From 05a5298ed13c5e835dfe5fb2f65c2cbb3756466d Mon Sep 17 00:00:00 2001 From: hz Date: Tue, 28 Jul 2026 19:06:02 -0500 Subject: [PATCH 2/9] 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 --- scripts/ui/main.gd | 4 ++-- scripts/ui/question_player.gd | 16 +++++++++++++++- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/scripts/ui/main.gd b/scripts/ui/main.gd index dbbdc5f..1e5989c 100644 --- a/scripts/ui/main.gd +++ b/scripts/ui/main.gd @@ -896,7 +896,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 +943,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") diff --git a/scripts/ui/question_player.gd b/scripts/ui/question_player.gd index ec200db..b86078f 100644 --- a/scripts/ui/question_player.gd +++ b/scripts/ui/question_player.gd @@ -90,6 +90,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 @@ -317,6 +318,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" @@ -408,6 +421,7 @@ func _ready() -> void: # 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) @@ -1046,7 +1060,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", ""))) From efae7e0b2b4610dc95e41d3efb501cda01d02b3a Mon Sep 17 00:00:00 2001 From: hz Date: Tue, 28 Jul 2026 19:33:33 -0500 Subject: [PATCH 3/9] 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 --- project.godot | 1 + scripts/autoload/credits_service.gd | 67 +++++++++++++++++ scripts/autoload/question_bank_service.gd | 3 + scripts/ui/credits_badge.gd | 91 +++++++++++++++++++++++ scripts/ui/main.gd | 8 ++ scripts/ui/question_player.gd | 14 +++- 6 files changed, 181 insertions(+), 3 deletions(-) create mode 100644 scripts/autoload/credits_service.gd create mode 100644 scripts/ui/credits_badge.gd diff --git a/project.godot b/project.godot index 122d1a6..3149262 100644 --- a/project.godot +++ b/project.godot @@ -40,6 +40,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" diff --git a/scripts/autoload/credits_service.gd b/scripts/autoload/credits_service.gd new file mode 100644 index 0000000..4f05a7a --- /dev/null +++ b/scripts/autoload/credits_service.gd @@ -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() diff --git a/scripts/autoload/question_bank_service.gd b/scripts/autoload/question_bank_service.gd index 9e4d369..705013d 100644 --- a/scripts/autoload/question_bank_service.gd +++ b/scripts/autoload/question_bank_service.gd @@ -507,6 +507,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) diff --git a/scripts/ui/credits_badge.gd b/scripts/ui/credits_badge.gd new file mode 100644 index 0000000..c004aad --- /dev/null +++ b/scripts/ui/credits_badge.gd @@ -0,0 +1,91 @@ +extends PanelContainer + +const Brand := preload("res://scripts/ui/medmind_style.gd") +const STAR_ICON := preload("res://assets/icons/lucide/star.svg") +const CHEVRON_ICON := preload("res://assets/icons/lucide/chevron-down.svg") + +var icon_panel: PanelContainer +var icon_texture: TextureRect +var value_label: Label +var caption_label: Label + + +func _ready() -> void: + custom_minimum_size = Vector2(150, 44) + size_flags_vertical = Control.SIZE_SHRINK_CENTER + _build_ui() + refresh_theme_controls() + _on_credits_changed() + CreditsService.credits_changed.connect(_on_credits_changed) + + +func _build_ui() -> void: + var margin := MarginContainer.new() + margin.add_theme_constant_override("margin_left", 10) + margin.add_theme_constant_override("margin_right", 10) + margin.add_theme_constant_override("margin_top", 6) + margin.add_theme_constant_override("margin_bottom", 6) + add_child(margin) + + var row := HBoxContainer.new() + row.add_theme_constant_override("separation", 8) + margin.add_child(row) + + icon_panel = PanelContainer.new() + icon_panel.custom_minimum_size = Vector2(28, 28) + icon_panel.size_flags_vertical = Control.SIZE_SHRINK_CENTER + icon_texture = TextureRect.new() + icon_texture.texture = STAR_ICON + icon_texture.custom_minimum_size = Vector2(16, 16) + 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", 15) + copy.add_child(value_label) + caption_label = Label.new() + caption_label.text = "RZ Points" + caption_label.add_theme_font_override("font", Brand.font) + caption_label.add_theme_font_size_override("font_size", 10) + copy.add_child(caption_label) + row.add_child(copy) + + var chevron := TextureRect.new() + chevron.texture = CHEVRON_ICON + chevron.custom_minimum_size = Vector2(14, 14) + 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, false, Brand.AMBER)) + icon_panel.add_theme_stylebox_override("panel", Brand.icon_circle_box(dark, Brand.AMBER, 14)) + 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 _on_credits_changed() -> void: + value_label.text = _format_thousands(CreditsService.credits) if CreditsService.loaded else "--" + + +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 diff --git a/scripts/ui/main.gd b/scripts/ui/main.gd index 1e5989c..dacb8dc 100644 --- a/scripts/ui/main.gd +++ b/scripts/ui/main.gd @@ -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: diff --git a/scripts/ui/question_player.gd b/scripts/ui/question_player.gd index b86078f..58865f8 100644 --- a/scripts/ui/question_player.gd +++ b/scripts/ui/question_player.gd @@ -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") @@ -100,6 +101,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 @@ -417,6 +419,8 @@ 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 @@ -434,6 +438,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 @@ -802,9 +807,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 150px + # 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 900.0 if size.x < 560.0: nav_palette.columns = 5 else: @@ -1076,6 +1082,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()) From 6f41906101b86f768956841ef68f8592da7e013b Mon Sep 17 00:00:00 2001 From: hz Date: Tue, 28 Jul 2026 20:17:07 -0500 Subject: [PATCH 4/9] 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 --- .../Controllers/QuestionsController.cs | 2 +- api/QuestionsApi/Models/QuestionRequests.cs | 1 + project.godot | 9 ++-- scripts/autoload/credits_service.gd.uid | 1 + scripts/autoload/question_bank_service.gd | 10 ++-- scripts/ui/credits_badge.gd | 29 ++++++++++- scripts/ui/credits_badge.gd.uid | 1 + scripts/ui/question_player.gd | 48 ++++++++++++++++++- 8 files changed, 88 insertions(+), 13 deletions(-) create mode 100644 scripts/autoload/credits_service.gd.uid create mode 100644 scripts/ui/credits_badge.gd.uid diff --git a/api/QuestionsApi/Controllers/QuestionsController.cs b/api/QuestionsApi/Controllers/QuestionsController.cs index 3df47a1..97f2baf 100644 --- a/api/QuestionsApi/Controllers/QuestionsController.cs +++ b/api/QuestionsApi/Controllers/QuestionsController.cs @@ -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 && !User.IsInRole("SUPER")) await _credits.AwardForFirstAnswerAsync(userId, record.Attempt.Id ?? ""); var ratingOwner = Guid.NewGuid().ToString("N"); diff --git a/api/QuestionsApi/Models/QuestionRequests.cs b/api/QuestionsApi/Models/QuestionRequests.cs index 7004cef..288c01e 100644 --- a/api/QuestionsApi/Models/QuestionRequests.cs +++ b/api/QuestionsApi/Models/QuestionRequests.cs @@ -49,4 +49,5 @@ public class FirstQuestionAttemptRequest public string ChoiceId { get; set; } = ""; public List PresentedChoiceIds { get; set; } = []; public int DurationSeconds { get; set; } + public bool MetTimeLimit { get; set; } = true; } diff --git a/project.godot b/project.godot index 3149262..3fad8fc 100644 --- a/project.godot +++ b/project.godot @@ -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] @@ -58,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] diff --git a/scripts/autoload/credits_service.gd.uid b/scripts/autoload/credits_service.gd.uid new file mode 100644 index 0000000..c7a746f --- /dev/null +++ b/scripts/autoload/credits_service.gd.uid @@ -0,0 +1 @@ +uid://bi323du7o7dn2 diff --git a/scripts/autoload/question_bank_service.gd b/scripts/autoload/question_bank_service.gd index 705013d..ee15038 100644 --- a/scripts/autoload/question_bank_service.gd +++ b/scripts/autoload/question_bank_service.gd @@ -257,10 +257,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 +386,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 +400,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 +433,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()] diff --git a/scripts/ui/credits_badge.gd b/scripts/ui/credits_badge.gd index c004aad..633c3c5 100644 --- a/scripts/ui/credits_badge.gd +++ b/scripts/ui/credits_badge.gd @@ -8,6 +8,8 @@ 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: @@ -17,6 +19,7 @@ func _ready() -> void: refresh_theme_controls() _on_credits_changed() CreditsService.credits_changed.connect(_on_credits_changed) + resized.connect(func(): pivot_offset = size * 0.5) func _build_ui() -> void: @@ -50,7 +53,7 @@ func _build_ui() -> void: value_label.add_theme_font_size_override("font_size", 15) copy.add_child(value_label) caption_label = Label.new() - caption_label.text = "RZ Points" + caption_label.text = "Rz Coins" caption_label.add_theme_font_override("font", Brand.font) caption_label.add_theme_font_size_override("font_size", 10) copy.add_child(caption_label) @@ -75,8 +78,30 @@ func refresh_theme_controls() -> void: 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_credits_changed() -> void: - value_label.text = _format_thousands(CreditsService.credits) if CreditsService.loaded else "--" + 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: diff --git a/scripts/ui/credits_badge.gd.uid b/scripts/ui/credits_badge.gd.uid new file mode 100644 index 0000000..384aa68 --- /dev/null +++ b/scripts/ui/credits_badge.gd.uid @@ -0,0 +1 @@ +uid://doisoxqnk1mf3 diff --git a/scripts/ui/question_player.gd b/scripts/ui/question_player.gd index 58865f8..a97c040 100644 --- a/scripts/ui/question_player.gd +++ b/scripts/ui/question_player.gd @@ -15,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 @@ -1565,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": @@ -1593,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) @@ -2185,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) From dd203b24f953d2880764093ae5f365b9fa6c3444 Mon Sep 17 00:00:00 2001 From: hz Date: Tue, 28 Jul 2026 20:36:15 -0500 Subject: [PATCH 5/9] Allow SUPER accounts to earn Rz Coins --- api/QuestionsApi/Controllers/QuestionsController.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/QuestionsApi/Controllers/QuestionsController.cs b/api/QuestionsApi/Controllers/QuestionsController.cs index 97f2baf..50b18d6 100644 --- a/api/QuestionsApi/Controllers/QuestionsController.cs +++ b/api/QuestionsApi/Controllers/QuestionsController.cs @@ -175,7 +175,7 @@ public class QuestionsController : ControllerBase _metrics.AttemptDuplicate(); } await _reviewQueue.AddFromFirstAttemptAsync(record.Attempt); - if (record.Inserted && record.Attempt.IsCorrect && req.MetTimeLimit && !User.IsInRole("SUPER")) + if (record.Inserted && record.Attempt.IsCorrect && req.MetTimeLimit) await _credits.AwardForFirstAnswerAsync(userId, record.Attempt.Id ?? ""); var ratingOwner = Guid.NewGuid().ToString("N"); From 89dbe3e31a21b550da9f96304cb104aaf8413429 Mon Sep 17 00:00:00 2001 From: hz Date: Tue, 28 Jul 2026 20:52:20 -0500 Subject: [PATCH 6/9] Initialize credit earning ledger arrays --- api/QuestionsApi/Services/CreditStore.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/api/QuestionsApi/Services/CreditStore.cs b/api/QuestionsApi/Services/CreditStore.cs index 5914ed0..5e6ee86 100644 --- a/api/QuestionsApi/Services/CreditStore.cs +++ b/api/QuestionsApi/Services/CreditStore.cs @@ -66,8 +66,14 @@ public sealed class CreditStore value => value.UserId == userId, Builders.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.Filter.Eq(value => value.UserId, userId) & + Builders.Filter.Exists(nameof(UserCreditAccount.RecentEarnings), false), + Builders.Update.Set(value => value.RecentEarnings, [])); var cutoff = now - CreditEconomy.EarningWindow; await _accounts.UpdateOneAsync( value => value.UserId == userId, From 845907e8255ef17e9edb0c8f0168b3e0bba8808f Mon Sep 17 00:00:00 2001 From: hz Date: Wed, 29 Jul 2026 00:24:46 -0500 Subject: [PATCH 7/9] 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. --- api/QuestionsApi/Models/RatingModels.cs | 2 + api/QuestionsApi/Services/RatingStore.cs | 31 ++++++++++++- assets/icons/lucide/shield.svg | 13 ++++++ assets/icons/lucide/shield.svg.import | 43 +++++++++++++++++ scripts/autoload/question_bank_service.gd | 6 +++ scripts/ui/credits_badge.gd | 56 +++++++++++++++++++++-- 6 files changed, 147 insertions(+), 4 deletions(-) create mode 100644 assets/icons/lucide/shield.svg create mode 100644 assets/icons/lucide/shield.svg.import diff --git a/api/QuestionsApi/Models/RatingModels.cs b/api/QuestionsApi/Models/RatingModels.cs index fc6ff9d..7384330 100644 --- a/api/QuestionsApi/Models/RatingModels.cs +++ b/api/QuestionsApi/Models/RatingModels.cs @@ -47,4 +47,6 @@ public class RatingProfileResponse public int OverallElo { get; set; } public int RatedQuestionCount { get; set; } public Dictionary CategoryElos { get; set; } = []; + public string Tier { get; set; } = ""; + public int TierDivision { get; set; } } diff --git a/api/QuestionsApi/Services/RatingStore.cs b/api/QuestionsApi/Services/RatingStore.cs index 0d3d790..521d336 100644 --- a/api/QuestionsApi/Services/RatingStore.cs +++ b/api/QuestionsApi/Services/RatingStore.cs @@ -27,14 +27,43 @@ public class RatingStore public async Task 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 GetOrInitializeHistoryAsync(string userId, DateTime nowUtc) { var rating = await GetOrCreateUserRatingAsync(userId); diff --git a/assets/icons/lucide/shield.svg b/assets/icons/lucide/shield.svg new file mode 100644 index 0000000..779a29c --- /dev/null +++ b/assets/icons/lucide/shield.svg @@ -0,0 +1,13 @@ + + + diff --git a/assets/icons/lucide/shield.svg.import b/assets/icons/lucide/shield.svg.import new file mode 100644 index 0000000..33c657a --- /dev/null +++ b/assets/icons/lucide/shield.svg.import @@ -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 diff --git a/scripts/autoload/question_bank_service.gd b/scripts/autoload/question_bank_service.gd index ee15038..d1ec58e 100644 --- a/scripts/autoload/question_bank_service.gd +++ b/scripts/autoload/question_bank_service.gd @@ -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 = {} @@ -617,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() diff --git a/scripts/ui/credits_badge.gd b/scripts/ui/credits_badge.gd index 633c3c5..b56b3f4 100644 --- a/scripts/ui/credits_badge.gd +++ b/scripts/ui/credits_badge.gd @@ -1,9 +1,16 @@ 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 @@ -13,12 +20,14 @@ var _initialized := false func _ready() -> void: - custom_minimum_size = Vector2(150, 44) + custom_minimum_size = Vector2(230, 44) 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) @@ -34,6 +43,35 @@ func _build_ui() -> void: row.add_theme_constant_override("separation", 8) margin.add_child(row) + rank_icon_panel = PanelContainer.new() + rank_icon_panel.custom_minimum_size = Vector2(28, 28) + 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(16, 16) + 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", 15) + 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", 10) + rank_copy.add_child(rank_elo_label) + row.add_child(rank_copy) + + divider = ColorRect.new() + divider.custom_minimum_size = Vector2(1, 26) + divider.size_flags_vertical = Control.SIZE_SHRINK_CENTER + row.add_child(divider) + icon_panel = PanelContainer.new() icon_panel.custom_minimum_size = Vector2(28, 28) icon_panel.size_flags_vertical = Control.SIZE_SHRINK_CENTER @@ -53,7 +91,7 @@ func _build_ui() -> void: value_label.add_theme_font_size_override("font_size", 15) copy.add_child(value_label) caption_label = Label.new() - caption_label.text = "Rz Coins" + caption_label.text = "RZ Points" caption_label.add_theme_font_override("font", Brand.font) caption_label.add_theme_font_size_override("font_size", 10) copy.add_child(caption_label) @@ -71,7 +109,12 @@ func _build_ui() -> void: func refresh_theme_controls() -> void: var dark := UIThemeService.is_dark_mode() - add_theme_stylebox_override("panel", Brand.content_card_box(dark, false, Brand.AMBER)) + 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, 14)) + 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, 14)) icon_texture.modulate = Brand.AMBER value_label.add_theme_color_override("font_color", Brand.text_color(dark)) @@ -84,6 +127,13 @@ func bump() -> void: 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 From f082be38e099ff4978f0b9ffcbe78fe3854baf9d Mon Sep 17 00:00:00 2001 From: hz Date: Wed, 29 Jul 2026 01:16:47 -0500 Subject: [PATCH 8/9] 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. --- scripts/ui/credits_badge.gd | 37 +++++++++++++++++------------------ scripts/ui/question_player.gd | 8 ++++---- 2 files changed, 22 insertions(+), 23 deletions(-) diff --git a/scripts/ui/credits_badge.gd b/scripts/ui/credits_badge.gd index b56b3f4..583d3de 100644 --- a/scripts/ui/credits_badge.gd +++ b/scripts/ui/credits_badge.gd @@ -20,7 +20,6 @@ var _initialized := false func _ready() -> void: - custom_minimum_size = Vector2(230, 44) size_flags_vertical = Control.SIZE_SHRINK_CENTER _build_ui() refresh_theme_controls() @@ -33,22 +32,22 @@ func _ready() -> void: func _build_ui() -> void: var margin := MarginContainer.new() - margin.add_theme_constant_override("margin_left", 10) - margin.add_theme_constant_override("margin_right", 10) - margin.add_theme_constant_override("margin_top", 6) - margin.add_theme_constant_override("margin_bottom", 6) + 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", 8) + row.add_theme_constant_override("separation", 5) margin.add_child(row) rank_icon_panel = PanelContainer.new() - rank_icon_panel.custom_minimum_size = Vector2(28, 28) + 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(16, 16) + 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) @@ -59,25 +58,25 @@ func _build_ui() -> void: 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", 15) + 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", 10) + 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, 26) + 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(28, 28) + 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(16, 16) + 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) @@ -88,18 +87,18 @@ func _build_ui() -> void: 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", 15) + value_label.add_theme_font_size_override("font_size", 13) copy.add_child(value_label) caption_label = Label.new() - caption_label.text = "RZ Points" + caption_label.text = "Rz Coins" caption_label.add_theme_font_override("font", Brand.font) - caption_label.add_theme_font_size_override("font_size", 10) + 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(14, 14) + 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 @@ -110,12 +109,12 @@ func _build_ui() -> void: 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, 14)) + 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, 14)) + 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)) diff --git a/scripts/ui/question_player.gd b/scripts/ui/question_player.gd index a97c040..e631b5b 100644 --- a/scripts/ui/question_player.gd +++ b/scripts/ui/question_player.gd @@ -808,10 +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 - # 12 bare 44px icon buttons + the wider 92px Feedback button + the 150px - # 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 900.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: From c14ac1d65b913cef3c7efb2737b855723e2d61c4 Mon Sep 17 00:00:00 2001 From: hz Date: Wed, 29 Jul 2026 01:28:25 -0500 Subject: [PATCH 9/9] Require reference ranges for published question labs --- api/QuestionsApi.Tests/ChoicePoolTests.cs | 39 +++ .../Services/QuestionValidator.cs | 20 ++ tools/backfill_lab_references.py | 230 ++++++++++++++++++ 3 files changed, 289 insertions(+) create mode 100644 tools/backfill_lab_references.py diff --git a/api/QuestionsApi.Tests/ChoicePoolTests.cs b/api/QuestionsApi.Tests/ChoicePoolTests.cs index 8f80e14..15b447e 100644 --- a/api/QuestionsApi.Tests/ChoicePoolTests.cs +++ b/api/QuestionsApi.Tests/ChoicePoolTests.cs @@ -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)] diff --git a/api/QuestionsApi/Services/QuestionValidator.cs b/api/QuestionsApi/Services/QuestionValidator.cs index bf3e117..78feef6 100644 --- a/api/QuestionsApi/Services/QuestionValidator.cs +++ b/api/QuestionsApi/Services/QuestionValidator.cs @@ -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? 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? references, bool required) { if (references is null) diff --git a/tools/backfill_lab_references.py b/tools/backfill_lab_references.py new file mode 100644 index 0000000..d531d9d --- /dev/null +++ b/tools/backfill_lab_references.py @@ -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()