Merge pull request 'Main' (#13) from main into production
Reviewed-on: admin/step3#13
This commit is contained in:
@@ -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)]
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
@@ -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<IActionResult> Get() => Ok(await credits.GetStatusAsync(UserId()));
|
||||
|
||||
private string UserId() => User.FindFirstValue(ClaimTypes.NameIdentifier)
|
||||
?? User.FindFirstValue(ClaimTypes.Name)
|
||||
?? User.Identity?.Name
|
||||
?? throw new UnauthorizedAccessException();
|
||||
}
|
||||
@@ -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 && record.Attempt.IsCorrect && req.MetTimeLimit)
|
||||
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)
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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<CreditEvent> 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -141,6 +141,7 @@ public class FirstQuestionAttemptResponse
|
||||
public Dictionary<string, int> ChoiceSelectionPercentages { get; set; } = [];
|
||||
public int QuestionElo { get; set; } = 1000;
|
||||
public RatingChangeResponse? Rating { get; set; }
|
||||
public CreditStatusResponse? Credits { get; set; }
|
||||
}
|
||||
|
||||
public class SkipQuestionResponse
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -66,6 +66,8 @@ builder.Services.Configure<ForwardedHeadersOptions>(options =>
|
||||
});
|
||||
|
||||
builder.Services.AddSingleton<MongoDatabaseProvider>();
|
||||
builder.Services.AddSingleton(TimeProvider.System);
|
||||
builder.Services.AddSingleton<CreditStore>();
|
||||
builder.Services.AddSingleton<QuestionStore>();
|
||||
builder.Services.AddSingleton<QuestionAttemptStore>();
|
||||
builder.Services.AddSingleton<AnswerStatisticsStore>();
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Driver;
|
||||
using QuestionsApi.Models;
|
||||
|
||||
namespace QuestionsApi.Services;
|
||||
|
||||
public sealed class CreditStore
|
||||
{
|
||||
private readonly IMongoCollection<UserCreditAccount> _accounts;
|
||||
private readonly TimeProvider _clock;
|
||||
|
||||
public CreditStore(MongoDatabaseProvider mongo, TimeProvider clock)
|
||||
{
|
||||
_accounts = mongo.Database.GetCollection<UserCreditAccount>("UserCreditAccounts");
|
||||
_clock = clock;
|
||||
_accounts.Indexes.CreateOne(new CreateIndexModel<UserCreditAccount>(
|
||||
Builders<UserCreditAccount>.IndexKeys.Ascending(value => value.UserId),
|
||||
new CreateIndexOptions { Unique = true, Name = "uq_credit_user" }));
|
||||
}
|
||||
|
||||
public async Task<bool> AwardForFirstAnswerAsync(string userId, string attemptId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(attemptId)) return false;
|
||||
var now = _clock.GetUtcNow().UtcDateTime;
|
||||
await EnsureAndCleanAsync(userId, now);
|
||||
|
||||
var filters = Builders<UserCreditAccount>.Filter;
|
||||
var belowCap = new BsonDocumentFilterDefinition<UserCreditAccount>(
|
||||
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<UserCreditAccount>.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<CreditStatusResponse> 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<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,
|
||||
Builders<UserCreditAccount>.Update.PullFilter(
|
||||
value => value.RecentEarnings,
|
||||
earning => earning.CreatedUtc <= cutoff));
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 |
@@ -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
|
||||
@@ -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.
|
||||
+5
-5
@@ -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]
|
||||
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
uid://bi323du7o7dn2
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://doisoxqnk1mf3
|
||||
+10
-2
@@ -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")
|
||||
|
||||
@@ -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
|
||||
@@ -788,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:
|
||||
@@ -1046,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", "")))
|
||||
@@ -1062,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())
|
||||
@@ -1543,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":
|
||||
@@ -1571,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)
|
||||
@@ -2163,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)
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user