Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0676c7f95e | ||
|
|
f39bfb42be | ||
|
|
c14ac1d65b | ||
|
|
f082be38e0 | ||
|
|
845907e825 | ||
|
|
89dbe3e31a | ||
|
|
dd203b24f9 | ||
|
|
6f41906101 | ||
|
|
efae7e0b2b | ||
|
|
05a5298ed1 | ||
|
|
b511d27a20 | ||
|
|
63fa073530 | ||
|
|
41731f5804 | ||
|
|
e3e18f6415 | ||
|
|
9f836c8765 | ||
|
|
86ae650272 | ||
|
|
23d573e221 | ||
|
|
548b519d44 | ||
|
|
1ff2d612a4 | ||
|
|
c0252a916d | ||
|
|
81e543fdf5 | ||
|
|
ca218a6ae5 | ||
|
|
7fb5f8fe78 | ||
|
|
ee57dbc3e7 |
+1
-1
@@ -71,7 +71,7 @@ The hostname change creates a new browser origin, so testers should expect to si
|
|||||||
|
|
||||||
## Question promotion behavior
|
## Question promotion behavior
|
||||||
|
|
||||||
The manual workflow runs the deployed PRD API image as a short-lived pod. It copies only questions that are published and not flagged for review, plus their referenced media. It never copies CCS content, users, attempts, ratings, statistics, simulations, reports, feedback, notes, or donations.
|
The manual workflow runs the deployed PRD API image as a short-lived pod. It copies only questions that are published and not flagged for review, plus their referenced media. Any promoted question containing media is flagged again in PRD and remains hidden from students until a SUPER user explicitly approves it in production. UAT media approval is not treated as production approval. It never copies CCS content, users, attempts, ratings, statistics, simulations, reports, feedback, notes, or donations.
|
||||||
|
|
||||||
Promotion is idempotent and does not delete or implicitly unpublish PRD content. Its dry run reports inserts, updates, unchanged questions, and production extras. Apply mode verifies the promoted target records and writes a scoped `ContentPromotions` audit document. Corrective unpublishing remains an explicit SUPER/API or database operation.
|
Promotion is idempotent and does not delete or implicitly unpublish PRD content. Its dry run reports inserts, updates, unchanged questions, and production extras. Apply mode verifies the promoted target records and writes a scoped `ContentPromotions` audit document. Corrective unpublishing remains an explicit SUPER/API or database operation.
|
||||||
|
|
||||||
|
|||||||
@@ -148,6 +148,45 @@ public sealed class ChoicePoolTests
|
|||||||
Assert.Null(QuestionValidator.Validate(request));
|
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]
|
[Theory]
|
||||||
[InlineData("http://example.org/guideline", "HTTPS")]
|
[InlineData("http://example.org/guideline", "HTTPS")]
|
||||||
[InlineData("https://example.org/guideline", null)]
|
[InlineData("https://example.org/guideline", null)]
|
||||||
|
|||||||
@@ -49,6 +49,44 @@ public sealed class ContentPromotionTests
|
|||||||
Assert.False(QuestionPromotionCommand.IsEligible(new BsonDocument("IsPublished", false)));
|
Assert.False(QuestionPromotionCommand.IsEligible(new BsonDocument("IsPublished", false)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ProductionPromotionFlagsMediaForSeparateApproval()
|
||||||
|
{
|
||||||
|
var updated = new DateTime(2026, 7, 30, 12, 0, 0, DateTimeKind.Utc);
|
||||||
|
var source = new BsonDocument
|
||||||
|
{
|
||||||
|
["ExternalId"] = "media-question",
|
||||||
|
["IsPublished"] = true,
|
||||||
|
["FlaggedForReviewUtc"] = BsonNull.Value,
|
||||||
|
["UpdatedUtc"] = updated,
|
||||||
|
["Media"] = new BsonArray { ObjectId.GenerateNewId().ToString() }
|
||||||
|
};
|
||||||
|
|
||||||
|
var prepared = QuestionPromotionCommand.PrepareForProduction(source);
|
||||||
|
|
||||||
|
Assert.Equal(updated, prepared["FlaggedForReviewUtc"].ToUniversalTime());
|
||||||
|
Assert.Equal(QuestionPromotionCommand.ProductionMediaReviewReason, prepared["FlagReason"].AsString);
|
||||||
|
Assert.Equal(QuestionPromotionCommand.ProductionMediaReviewActor, prepared["FlaggedByUserId"].AsString);
|
||||||
|
Assert.True(source["FlaggedForReviewUtc"].IsBsonNull);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ProductionPromotionLeavesMediaFreeQuestionApprovalStateAlone()
|
||||||
|
{
|
||||||
|
var source = new BsonDocument
|
||||||
|
{
|
||||||
|
["ExternalId"] = "text-question",
|
||||||
|
["IsPublished"] = true,
|
||||||
|
["FlaggedForReviewUtc"] = BsonNull.Value,
|
||||||
|
["Media"] = new BsonArray()
|
||||||
|
};
|
||||||
|
|
||||||
|
var prepared = QuestionPromotionCommand.PrepareForProduction(source);
|
||||||
|
|
||||||
|
Assert.True(prepared["FlaggedForReviewUtc"].IsBsonNull);
|
||||||
|
Assert.False(prepared.Contains("FlagReason"));
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void QuestionMediaIdsSupportStoredApiUrlsAndRawIds()
|
public void QuestionMediaIdsSupportStoredApiUrlsAndRawIds()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -175,7 +175,7 @@ public class QuestionsController : ControllerBase
|
|||||||
_metrics.AttemptDuplicate();
|
_metrics.AttemptDuplicate();
|
||||||
}
|
}
|
||||||
await _reviewQueue.AddFromFirstAttemptAsync(record.Attempt);
|
await _reviewQueue.AddFromFirstAttemptAsync(record.Attempt);
|
||||||
if (record.Inserted && !User.IsInRole("SUPER"))
|
if (record.Inserted && record.Attempt.IsCorrect && req.MetTimeLimit)
|
||||||
await _credits.AwardForFirstAnswerAsync(userId, record.Attempt.Id ?? "");
|
await _credits.AwardForFirstAnswerAsync(userId, record.Attempt.Id ?? "");
|
||||||
|
|
||||||
var ratingOwner = Guid.NewGuid().ToString("N");
|
var ratingOwner = Guid.NewGuid().ToString("N");
|
||||||
|
|||||||
@@ -49,4 +49,5 @@ public class FirstQuestionAttemptRequest
|
|||||||
public string ChoiceId { get; set; } = "";
|
public string ChoiceId { get; set; } = "";
|
||||||
public List<string> PresentedChoiceIds { get; set; } = [];
|
public List<string> PresentedChoiceIds { get; set; } = [];
|
||||||
public int DurationSeconds { get; set; }
|
public int DurationSeconds { get; set; }
|
||||||
|
public bool MetTimeLimit { get; set; } = true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,4 +47,6 @@ public class RatingProfileResponse
|
|||||||
public int OverallElo { get; set; }
|
public int OverallElo { get; set; }
|
||||||
public int RatedQuestionCount { get; set; }
|
public int RatedQuestionCount { get; set; }
|
||||||
public Dictionary<string, int> CategoryElos { get; set; } = [];
|
public Dictionary<string, int> CategoryElos { get; set; } = [];
|
||||||
|
public string Tier { get; set; } = "";
|
||||||
|
public int TierDivision { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,8 +25,11 @@ public static class ContentPromotionCommand
|
|||||||
await source.RunCommandAsync<BsonDocument>(new BsonDocument("ping", 1));
|
await source.RunCommandAsync<BsonDocument>(new BsonDocument("ping", 1));
|
||||||
await target.RunCommandAsync<BsonDocument>(new BsonDocument("ping", 1));
|
await target.RunCommandAsync<BsonDocument>(new BsonDocument("ping", 1));
|
||||||
|
|
||||||
var questions = await source.GetCollection<BsonDocument>("Questions")
|
var sourceQuestions = await source.GetCollection<BsonDocument>("Questions")
|
||||||
.Find(Builders<BsonDocument>.Filter.Eq("IsPublished", true)).ToListAsync();
|
.Find(Builders<BsonDocument>.Filter.Eq("IsPublished", true)).ToListAsync();
|
||||||
|
var questions = sourceQuestions
|
||||||
|
.Select(QuestionPromotionCommand.PrepareForProduction)
|
||||||
|
.ToList();
|
||||||
var mediaIds = CollectMediaIds(questions);
|
var mediaIds = CollectMediaIds(questions);
|
||||||
var media = mediaIds.Count == 0
|
var media = mediaIds.Count == 0
|
||||||
? []
|
? []
|
||||||
|
|||||||
@@ -66,8 +66,14 @@ public sealed class CreditStore
|
|||||||
value => value.UserId == userId,
|
value => value.UserId == userId,
|
||||||
Builders<UserCreditAccount>.Update
|
Builders<UserCreditAccount>.Update
|
||||||
.SetOnInsert(value => value.UserId, userId)
|
.SetOnInsert(value => value.UserId, userId)
|
||||||
|
.SetOnInsert(value => value.Credits, 0)
|
||||||
|
.SetOnInsert(value => value.RecentEarnings, [])
|
||||||
.SetOnInsert(value => value.UpdatedUtc, now),
|
.SetOnInsert(value => value.UpdatedUtc, now),
|
||||||
new UpdateOptions { IsUpsert = true });
|
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;
|
var cutoff = now - CreditEconomy.EarningWindow;
|
||||||
await _accounts.UpdateOneAsync(
|
await _accounts.UpdateOneAsync(
|
||||||
value => value.UserId == userId,
|
value => value.UserId == userId,
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ namespace QuestionsApi.Services;
|
|||||||
public static class QuestionPromotionCommand
|
public static class QuestionPromotionCommand
|
||||||
{
|
{
|
||||||
public const string Confirmation = "PROMOTE_TO_PRD";
|
public const string Confirmation = "PROMOTE_TO_PRD";
|
||||||
|
internal const string ProductionMediaReviewReason = "Contains media - pending production review";
|
||||||
|
internal const string ProductionMediaReviewActor = "system:production-promotion";
|
||||||
|
|
||||||
public static async Task<int> RunAsync(string[] args, TextWriter output)
|
public static async Task<int> RunAsync(string[] args, TextWriter output)
|
||||||
{
|
{
|
||||||
@@ -28,9 +30,9 @@ public static class QuestionPromotionCommand
|
|||||||
|
|
||||||
var questionFilter = Builders<BsonDocument>.Filter.Eq("IsPublished", true)
|
var questionFilter = Builders<BsonDocument>.Filter.Eq("IsPublished", true)
|
||||||
& Builders<BsonDocument>.Filter.Eq("FlaggedForReviewUtc", BsonNull.Value);
|
& Builders<BsonDocument>.Filter.Eq("FlaggedForReviewUtc", BsonNull.Value);
|
||||||
var questions = await source.GetCollection<BsonDocument>("Questions")
|
var sourceQuestions = await source.GetCollection<BsonDocument>("Questions")
|
||||||
.Find(questionFilter).ToListAsync();
|
.Find(questionFilter).ToListAsync();
|
||||||
var duplicateIds = questions
|
var duplicateIds = sourceQuestions
|
||||||
.GroupBy(ExternalId, StringComparer.Ordinal)
|
.GroupBy(ExternalId, StringComparer.Ordinal)
|
||||||
.Where(group => group.Count() > 1)
|
.Where(group => group.Count() > 1)
|
||||||
.Select(group => group.Key)
|
.Select(group => group.Key)
|
||||||
@@ -38,6 +40,7 @@ public static class QuestionPromotionCommand
|
|||||||
if (duplicateIds.Count > 0)
|
if (duplicateIds.Count > 0)
|
||||||
throw new InvalidOperationException($"duplicate eligible ExternalIds: {string.Join(", ", duplicateIds)}");
|
throw new InvalidOperationException($"duplicate eligible ExternalIds: {string.Join(", ", duplicateIds)}");
|
||||||
|
|
||||||
|
var questions = sourceQuestions.Select(PrepareForProduction).ToList();
|
||||||
var mediaIds = CollectMediaIds(questions);
|
var mediaIds = CollectMediaIds(questions);
|
||||||
var media = mediaIds.Count == 0
|
var media = mediaIds.Count == 0
|
||||||
? []
|
? []
|
||||||
@@ -95,7 +98,7 @@ public static class QuestionPromotionCommand
|
|||||||
["SourceDatabase"] = options.SourceDatabase,
|
["SourceDatabase"] = options.SourceDatabase,
|
||||||
["TargetDatabase"] = options.TargetDatabase,
|
["TargetDatabase"] = options.TargetDatabase,
|
||||||
["Scope"] = "questions",
|
["Scope"] = "questions",
|
||||||
["Eligibility"] = "published_unflagged",
|
["Eligibility"] = "published_unflagged; media_requires_production_review",
|
||||||
["Questions"] = summary.EligibleQuestions,
|
["Questions"] = summary.EligibleQuestions,
|
||||||
["Inserted"] = summary.Inserted,
|
["Inserted"] = summary.Inserted,
|
||||||
["Updated"] = summary.Updated,
|
["Updated"] = summary.Updated,
|
||||||
@@ -118,6 +121,21 @@ public static class QuestionPromotionCommand
|
|||||||
question.GetValue("IsPublished", false).ToBoolean()
|
question.GetValue("IsPublished", false).ToBoolean()
|
||||||
&& (!question.TryGetValue("FlaggedForReviewUtc", out var flagged) || flagged.IsBsonNull);
|
&& (!question.TryGetValue("FlaggedForReviewUtc", out var flagged) || flagged.IsBsonNull);
|
||||||
|
|
||||||
|
internal static BsonDocument PrepareForProduction(BsonDocument source)
|
||||||
|
{
|
||||||
|
var prepared = new BsonDocument(source);
|
||||||
|
if (!HasMedia(prepared))
|
||||||
|
return prepared;
|
||||||
|
|
||||||
|
var reviewUtc = prepared.TryGetValue("UpdatedUtc", out var updated) && updated.IsValidDateTime
|
||||||
|
? updated
|
||||||
|
: new BsonDateTime(DateTime.UnixEpoch);
|
||||||
|
prepared["FlaggedForReviewUtc"] = reviewUtc;
|
||||||
|
prepared["FlagReason"] = ProductionMediaReviewReason;
|
||||||
|
prepared["FlaggedByUserId"] = ProductionMediaReviewActor;
|
||||||
|
return prepared;
|
||||||
|
}
|
||||||
|
|
||||||
internal static HashSet<ObjectId> CollectMediaIds(IEnumerable<BsonDocument> questions)
|
internal static HashSet<ObjectId> CollectMediaIds(IEnumerable<BsonDocument> questions)
|
||||||
{
|
{
|
||||||
var ids = new HashSet<ObjectId>();
|
var ids = new HashSet<ObjectId>();
|
||||||
@@ -192,6 +210,11 @@ public static class QuestionPromotionCommand
|
|||||||
private static string ExternalId(BsonDocument document) =>
|
private static string ExternalId(BsonDocument document) =>
|
||||||
document.GetValue("ExternalId", "").AsString;
|
document.GetValue("ExternalId", "").AsString;
|
||||||
|
|
||||||
|
private static bool HasMedia(BsonDocument question) =>
|
||||||
|
question.TryGetValue("Media", out var media)
|
||||||
|
&& media.IsBsonArray
|
||||||
|
&& media.AsBsonArray.Count > 0;
|
||||||
|
|
||||||
private static async Task UpsertQuestionsAsync(IMongoDatabase target, IEnumerable<BsonDocument> questions)
|
private static async Task UpsertQuestionsAsync(IMongoDatabase target, IEnumerable<BsonDocument> questions)
|
||||||
{
|
{
|
||||||
var collection = target.GetCollection<BsonDocument>("Questions");
|
var collection = target.GetCollection<BsonDocument>("Questions");
|
||||||
@@ -224,7 +247,7 @@ public static class QuestionPromotionCommand
|
|||||||
{
|
{
|
||||||
mode,
|
mode,
|
||||||
scope = "questions",
|
scope = "questions",
|
||||||
eligibility = "published_unflagged",
|
eligibility = "published_unflagged; media_requires_production_review",
|
||||||
source = options.SourceDatabase,
|
source = options.SourceDatabase,
|
||||||
target = options.TargetDatabase,
|
target = options.TargetDatabase,
|
||||||
eligibleQuestions = summary.EligibleQuestions,
|
eligibleQuestions = summary.EligibleQuestions,
|
||||||
|
|||||||
@@ -68,11 +68,31 @@ public static class QuestionValidator
|
|||||||
if (string.IsNullOrWhiteSpace(req.System)) return "system required";
|
if (string.IsNullOrWhiteSpace(req.System)) return "system required";
|
||||||
if (string.IsNullOrWhiteSpace(req.Discipline)) return "discipline required";
|
if (string.IsNullOrWhiteSpace(req.Discipline)) return "discipline required";
|
||||||
if (req.Difficulty is < 1 or > 5) return "difficulty must be between 1 and 5";
|
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);
|
var referenceValidation = ValidateReferences(req.References, req.IsPublished);
|
||||||
if (referenceValidation is not null) return referenceValidation;
|
if (referenceValidation is not null) return referenceValidation;
|
||||||
return null;
|
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)
|
public static string? ValidateReferences(IReadOnlyCollection<QuestionReference>? references, bool required)
|
||||||
{
|
{
|
||||||
if (references is null)
|
if (references is null)
|
||||||
|
|||||||
@@ -27,14 +27,43 @@ public class RatingStore
|
|||||||
public async Task<RatingProfileResponse> GetProfileAsync(string userId)
|
public async Task<RatingProfileResponse> GetProfileAsync(string userId)
|
||||||
{
|
{
|
||||||
var rating = await GetOrCreateUserRatingAsync(userId);
|
var rating = await GetOrCreateUserRatingAsync(userId);
|
||||||
|
var (tierName, tierDivision) = GetTier(rating.OverallElo);
|
||||||
return new RatingProfileResponse
|
return new RatingProfileResponse
|
||||||
{
|
{
|
||||||
OverallElo = rating.OverallElo,
|
OverallElo = rating.OverallElo,
|
||||||
RatedQuestionCount = rating.RatedQuestionCount,
|
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)
|
public async Task<UserRating> GetOrInitializeHistoryAsync(string userId, DateTime nowUtc)
|
||||||
{
|
{
|
||||||
var rating = await GetOrCreateUserRatingAsync(userId);
|
var rating = await GetOrCreateUserRatingAsync(userId);
|
||||||
|
|||||||
@@ -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
|
||||||
+1
-1
@@ -25,7 +25,7 @@ html/custom_html_shell="res://web/medmind_shell.html"
|
|||||||
html/head_include="<meta name=\"description\" content=\"RanaZe: Step 3 Trainer pre-alpha - Focused practice for better clinical decisions.\">"
|
html/head_include="<meta name=\"description\" content=\"RanaZe: Step 3 Trainer pre-alpha - Focused practice for better clinical decisions.\">"
|
||||||
html/canvas_resize_policy=2
|
html/canvas_resize_policy=2
|
||||||
html/focus_canvas_on_start=true
|
html/focus_canvas_on_start=true
|
||||||
html/experimental_virtual_keyboard=false
|
html/experimental_virtual_keyboard=true
|
||||||
progressive_web_app/enabled=false
|
progressive_web_app/enabled=false
|
||||||
|
|
||||||
[preset.1]
|
[preset.1]
|
||||||
|
|||||||
+5
-5
@@ -13,14 +13,13 @@ config_version=5
|
|||||||
config/name="RanaZe: Step 3 Trainer"
|
config/name="RanaZe: Step 3 Trainer"
|
||||||
config/version="pre-alpha"
|
config/version="pre-alpha"
|
||||||
run/main_scene="res://scenes/main.tscn"
|
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/use_custom_user_dir=true
|
||||||
config/custom_user_dir_name="MedMind"
|
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/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/image="res://assets/branding/ranaze-mark.png"
|
||||||
boot_splash/fullsize=false
|
config/icon="res://assets/branding/ranaze-app-icon.png"
|
||||||
boot_splash/use_filter=true
|
|
||||||
|
|
||||||
[autoload]
|
[autoload]
|
||||||
|
|
||||||
@@ -40,6 +39,7 @@ NotesService="*res://scripts/autoload/notes_service.gd"
|
|||||||
BookmarkService="*res://scripts/autoload/bookmark_service.gd"
|
BookmarkService="*res://scripts/autoload/bookmark_service.gd"
|
||||||
ReviewQueueService="*res://scripts/autoload/review_queue_service.gd"
|
ReviewQueueService="*res://scripts/autoload/review_queue_service.gd"
|
||||||
DashboardService="*res://scripts/autoload/dashboard_service.gd"
|
DashboardService="*res://scripts/autoload/dashboard_service.gd"
|
||||||
|
CreditsService="*res://scripts/autoload/credits_service.gd"
|
||||||
FlashcardService="*res://scripts/autoload/flashcard_service.gd"
|
FlashcardService="*res://scripts/autoload/flashcard_service.gd"
|
||||||
AccountProfileService="*res://scripts/autoload/account_profile_service.gd"
|
AccountProfileService="*res://scripts/autoload/account_profile_service.gd"
|
||||||
DonationService="*res://scripts/autoload/donation_service.gd"
|
DonationService="*res://scripts/autoload/donation_service.gd"
|
||||||
@@ -57,9 +57,9 @@ window/stretch/aspect="expand"
|
|||||||
|
|
||||||
[rendering]
|
[rendering]
|
||||||
|
|
||||||
|
rendering_device/driver.windows="d3d12"
|
||||||
renderer/rendering_method="gl_compatibility"
|
renderer/rendering_method="gl_compatibility"
|
||||||
renderer/rendering_method.mobile="gl_compatibility"
|
renderer/rendering_method.mobile="gl_compatibility"
|
||||||
rendering_device/driver.windows="d3d12"
|
|
||||||
textures/vram_compression/import_etc2_astc=true
|
textures/vram_compression/import_etc2_astc=true
|
||||||
|
|
||||||
[step3]
|
[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 retry_attempt := 0
|
||||||
var next_retry_seconds := 0
|
var next_retry_seconds := 0
|
||||||
var overall_elo := 1000
|
var overall_elo := 1000
|
||||||
|
var rank_tier := "Bronze"
|
||||||
|
var rank_division := 3
|
||||||
var rated_question_count := 0
|
var rated_question_count := 0
|
||||||
var category_elos := {}
|
var category_elos := {}
|
||||||
var last_rating_change := {}
|
var last_rating_change := {}
|
||||||
@@ -246,6 +248,8 @@ func clear_questions() -> void:
|
|||||||
retry_attempt = 0
|
retry_attempt = 0
|
||||||
next_retry_seconds = 0
|
next_retry_seconds = 0
|
||||||
overall_elo = 1000
|
overall_elo = 1000
|
||||||
|
rank_tier = "Bronze"
|
||||||
|
rank_division = 3
|
||||||
rated_question_count = 0
|
rated_question_count = 0
|
||||||
category_elos = {}
|
category_elos = {}
|
||||||
last_rating_change = {}
|
last_rating_change = {}
|
||||||
@@ -257,10 +261,10 @@ func clear_questions() -> void:
|
|||||||
ratings_changed.emit()
|
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 == "":
|
if not AuthState.can_send_authenticated_request() or question_id == "" or choice_id == "":
|
||||||
return
|
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:
|
func skip_question(question_id: String) -> void:
|
||||||
@@ -386,7 +390,7 @@ func _on_retry_timer_timeout() -> void:
|
|||||||
load_questions_from_api(false)
|
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()
|
var account := AuthState.username.strip_edges()
|
||||||
if account == "":
|
if account == "":
|
||||||
return
|
return
|
||||||
@@ -400,6 +404,7 @@ func _enqueue_attempt(kind: String, question_id: String, choice_id: String, pres
|
|||||||
"choice_id": choice_id,
|
"choice_id": choice_id,
|
||||||
"presented_choice_ids": presented_choice_ids.duplicate(),
|
"presented_choice_ids": presented_choice_ids.duplicate(),
|
||||||
"duration_seconds": maxi(0, duration_seconds),
|
"duration_seconds": maxi(0, duration_seconds),
|
||||||
|
"met_time_limit": met_time_limit,
|
||||||
"retry_count": 0
|
"retry_count": 0
|
||||||
})
|
})
|
||||||
_save_attempt_queue()
|
_save_attempt_queue()
|
||||||
@@ -432,7 +437,8 @@ func _process_attempt_queue() -> void:
|
|||||||
"questionExternalId": question_id,
|
"questionExternalId": question_id,
|
||||||
"choiceId": str(queued.get("choice_id", "")),
|
"choiceId": str(queued.get("choice_id", "")),
|
||||||
"presentedChoiceIds": queued.get("presented_choice_ids", []),
|
"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":
|
if str(queued.get("kind", "answer")) == "skip":
|
||||||
url = "%s/%s/skip" % [question_api_base_url.rstrip("/"), question_id.uri_encode()]
|
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", {})
|
var rating: Dictionary = parsed.get("rating", {})
|
||||||
_update_question_elo(question_id, question_elo)
|
_update_question_elo(question_id, question_elo)
|
||||||
_apply_rating_change(rating)
|
_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)
|
choice_percentages_updated.emit(question_id, percentages)
|
||||||
question_rating_updated.emit(question_id, question_elo, rating)
|
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
|
return
|
||||||
|
|
||||||
overall_elo = int(parsed.get("overallElo", parsed.get("overall_elo", 1000)))
|
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)))
|
rated_question_count = int(parsed.get("ratedQuestionCount", parsed.get("rated_question_count", 0)))
|
||||||
category_elos = parsed.get("categoryElos", parsed.get("category_elos", {}))
|
category_elos = parsed.get("categoryElos", parsed.get("category_elos", {}))
|
||||||
ratings_changed.emit()
|
ratings_changed.emit()
|
||||||
|
|||||||
@@ -101,8 +101,8 @@ func refresh_theme_controls() -> void:
|
|||||||
return
|
return
|
||||||
var dark := UIThemeService.is_dark_mode()
|
var dark := UIThemeService.is_dark_mode()
|
||||||
add_theme_stylebox_override("panel", Brand.content_card_box(dark, true, Brand.ROYAL_BLUE))
|
add_theme_stylebox_override("panel", Brand.content_card_box(dark, true, Brand.ROYAL_BLUE))
|
||||||
display_label.add_theme_color_override("font_color", Brand.text_color(dark))
|
display_label.add_theme_color_override("font_color", Color.WHITE)
|
||||||
operation_label.add_theme_color_override("font_color", Brand.muted_color(dark))
|
operation_label.add_theme_color_override("font_color", Brand.muted_color(true))
|
||||||
for button in keypad_buttons:
|
for button in keypad_buttons:
|
||||||
var accent := Brand.ROYAL_BLUE if str(button.get_meta("calculator_token", "")) in ["+", "−", "×", "÷", "="] else Brand.SLATE
|
var accent := Brand.ROYAL_BLUE if str(button.get_meta("calculator_token", "")) in ["+", "−", "×", "÷", "="] else Brand.SLATE
|
||||||
button.add_theme_stylebox_override("normal", Brand.badge_box(dark, accent))
|
button.add_theme_stylebox_override("normal", Brand.badge_box(dark, accent))
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -99,9 +99,13 @@ func _draw() -> void:
|
|||||||
|
|
||||||
static func point_value(point: Dictionary, series_mode: String) -> Variant:
|
static func point_value(point: Dictionary, series_mode: String) -> Variant:
|
||||||
if series_mode == "accuracy":
|
if series_mode == "accuracy":
|
||||||
return float(point.get("accuracy", 0)) if int(point.get("total", 0)) > 0 else null
|
if int(point.get("total", 0)) > 0:
|
||||||
|
return float(point.get("accuracy", 0))
|
||||||
|
return null
|
||||||
var value: Variant = point.get("questionBankRating", null)
|
var value: Variant = point.get("questionBankRating", null)
|
||||||
return null if value == null else float(value)
|
if value == null:
|
||||||
|
return null
|
||||||
|
return float(value)
|
||||||
|
|
||||||
|
|
||||||
static func axis_bounds(series_points: Array, series_mode: String) -> Vector2:
|
static func axis_bounds(series_points: Array, series_mode: String) -> Vector2:
|
||||||
|
|||||||
@@ -229,8 +229,8 @@ func _render_rating_card(key: String, sparkline: Control, accent: Color, rating_
|
|||||||
if delta_value != null:
|
if delta_value != null:
|
||||||
var delta := int(delta_value)
|
var delta := int(delta_value)
|
||||||
var arrow := "↗" if delta >= 0 else "↘"
|
var arrow := "↗" if delta >= 0 else "↘"
|
||||||
var sign := "+" if delta >= 0 else ""
|
var sign_prefix := "+" if delta >= 0 else ""
|
||||||
trend_text = "%s %s%d this week" % [arrow, sign, delta]
|
trend_text = "%s %s%d this week" % [arrow, sign_prefix, delta]
|
||||||
trend_color = Brand.SUCCESS if delta >= 0 else Brand.MAGENTA
|
trend_color = Brand.SUCCESS if delta >= 0 else Brand.MAGENTA
|
||||||
if percentile_value != null:
|
if percentile_value != null:
|
||||||
caption = "Top %d%% of users" % int(percentile_value)
|
caption = "Top %d%% of users" % int(percentile_value)
|
||||||
@@ -269,8 +269,8 @@ func _render_accuracy_card(data: Dictionary) -> void:
|
|||||||
if delta_value != null:
|
if delta_value != null:
|
||||||
var delta := int(delta_value)
|
var delta := int(delta_value)
|
||||||
var arrow := "↗" if delta >= 0 else "↘"
|
var arrow := "↗" if delta >= 0 else "↘"
|
||||||
var sign := "+" if delta >= 0 else ""
|
var sign_prefix := "+" if delta >= 0 else ""
|
||||||
trend_text = "%s %s%d%% vs last 100" % [arrow, sign, delta]
|
trend_text = "%s %s%d%% vs last 100" % [arrow, sign_prefix, delta]
|
||||||
trend_color = Brand.SUCCESS if delta >= 0 else Brand.MAGENTA
|
trend_color = Brand.SUCCESS if delta >= 0 else Brand.MAGENTA
|
||||||
_set_stat_card("accuracy", "%d%%" % accuracy, trend_text, trend_color, "Last %d questions" % int(data.get("accuracySampleSize", 0)))
|
_set_stat_card("accuracy", "%d%%" % accuracy, trend_text, trend_color, "Last %d questions" % int(data.get("accuracySampleSize", 0)))
|
||||||
accuracy_progress_bar.value = accuracy
|
accuracy_progress_bar.value = accuracy
|
||||||
|
|||||||
@@ -65,6 +65,13 @@ var compact_layout := false
|
|||||||
var layout_updating := false
|
var layout_updating := false
|
||||||
|
|
||||||
|
|
||||||
|
func _notification(what: int) -> void:
|
||||||
|
if what == NOTIFICATION_APPLICATION_FOCUS_OUT:
|
||||||
|
var focus_owner := get_viewport().gui_get_focus_owner()
|
||||||
|
if focus_owner != null:
|
||||||
|
focus_owner.release_focus()
|
||||||
|
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
auth_base_url = EndpointConfig.resolve(AUTH_BASE, "/api/Auth")
|
auth_base_url = EndpointConfig.resolve(AUTH_BASE, "/api/Auth")
|
||||||
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||||
@@ -151,6 +158,7 @@ func _build_ui() -> void:
|
|||||||
large_icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
large_icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
||||||
brand_row.add_child(large_icon)
|
brand_row.add_child(large_icon)
|
||||||
var wordmark := VBoxContainer.new()
|
var wordmark := VBoxContainer.new()
|
||||||
|
wordmark.size_flags_vertical = Control.SIZE_SHRINK_CENTER
|
||||||
wordmark.add_theme_constant_override("separation", 4)
|
wordmark.add_theme_constant_override("separation", 4)
|
||||||
brand_row.add_child(wordmark)
|
brand_row.add_child(wordmark)
|
||||||
var name_row := HBoxContainer.new()
|
var name_row := HBoxContainer.new()
|
||||||
@@ -850,7 +858,8 @@ func _on_steam_status(result: int, code: int, _headers: PackedStringArray, body:
|
|||||||
"requires_account":
|
"requires_account":
|
||||||
poll_timer.stop()
|
poll_timer.stop()
|
||||||
_close_steam_popup()
|
_close_steam_popup()
|
||||||
external_verified_email = str(data.get("email", ""))
|
var verified_email_value: Variant = data.get("email")
|
||||||
|
external_verified_email = "" if verified_email_value == null else str(verified_email_value)
|
||||||
steam_email_input.text = external_verified_email
|
steam_email_input.text = external_verified_email
|
||||||
steam_email_input.editable = external_verified_email.is_empty()
|
steam_email_input.editable = external_verified_email.is_empty()
|
||||||
_set_mode("steam")
|
_set_mode("steam")
|
||||||
|
|||||||
+10
-2
@@ -3,6 +3,7 @@ extends Control
|
|||||||
const Style := preload("res://scripts/ui/ui_style.gd")
|
const Style := preload("res://scripts/ui/ui_style.gd")
|
||||||
const Brand := preload("res://scripts/ui/medmind_style.gd")
|
const Brand := preload("res://scripts/ui/medmind_style.gd")
|
||||||
const NavigationRoutes := preload("res://scripts/ui/navigation_routes.gd")
|
const NavigationRoutes := preload("res://scripts/ui/navigation_routes.gd")
|
||||||
|
const CreditsBadge := preload("res://scripts/ui/credits_badge.gd")
|
||||||
|
|
||||||
var screens := {}
|
var screens := {}
|
||||||
var _account_profile_synced := false
|
var _account_profile_synced := false
|
||||||
@@ -29,6 +30,7 @@ var drawer_open := false
|
|||||||
var menu_button: Button
|
var menu_button: Button
|
||||||
var account_button: Button
|
var account_button: Button
|
||||||
var account_chevron_button: Button
|
var account_chevron_button: Button
|
||||||
|
var credits_badge: PanelContainer
|
||||||
var account_menu: PanelContainer
|
var account_menu: PanelContainer
|
||||||
var nav_buttons: Dictionary = {}
|
var nav_buttons: Dictionary = {}
|
||||||
var current_nav_key := "home"
|
var current_nav_key := "home"
|
||||||
@@ -204,6 +206,9 @@ func _build_ui() -> void:
|
|||||||
_style_account_chevron(account_chevron_button)
|
_style_account_chevron(account_chevron_button)
|
||||||
header.add_child(account_chevron_button)
|
header.add_child(account_chevron_button)
|
||||||
|
|
||||||
|
credits_badge = CreditsBadge.new()
|
||||||
|
header.add_child(credits_badge)
|
||||||
|
|
||||||
var screens_container := MarginContainer.new()
|
var screens_container := MarginContainer.new()
|
||||||
screens_margin = screens_container
|
screens_margin = screens_container
|
||||||
screens_host = screens_container
|
screens_host = screens_container
|
||||||
@@ -769,6 +774,7 @@ func _on_auth_changed() -> void:
|
|||||||
if not _account_profile_synced:
|
if not _account_profile_synced:
|
||||||
_account_profile_synced = true
|
_account_profile_synced = true
|
||||||
AccountProfileService.refresh()
|
AccountProfileService.refresh()
|
||||||
|
CreditsService.refresh()
|
||||||
else:
|
else:
|
||||||
_account_profile_synced = false
|
_account_profile_synced = false
|
||||||
if AuthState.is_logged_in and AuthState.requires_legal_acceptance and current_screen_name not in ["login", "legal_consent"]:
|
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)
|
_style_account_avatar(account_button)
|
||||||
if account_chevron_button != null:
|
if account_chevron_button != null:
|
||||||
_style_account_chevron(account_chevron_button)
|
_style_account_chevron(account_chevron_button)
|
||||||
|
if credits_badge != null:
|
||||||
|
credits_badge.refresh_theme_controls()
|
||||||
if sidebar != null:
|
if sidebar != null:
|
||||||
sidebar.add_theme_stylebox_override("panel", _sidebar_box())
|
sidebar.add_theme_stylebox_override("panel", _sidebar_box())
|
||||||
if header_panel != null:
|
if header_panel != null:
|
||||||
@@ -896,7 +904,7 @@ func _build_app_menu() -> void:
|
|||||||
menu_exit_button.text = "Exit Run"
|
menu_exit_button.text = "Exit Run"
|
||||||
menu_exit_button.custom_minimum_size = Vector2(0, 48)
|
menu_exit_button.custom_minimum_size = Vector2(0, 48)
|
||||||
UISoundService.set_button_sound(menu_exit_button, "back")
|
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)
|
content.add_child(menu_exit_button)
|
||||||
|
|
||||||
var logout := Button.new()
|
var logout := Button.new()
|
||||||
@@ -943,7 +951,7 @@ func _navigate_from_pause(target_screen: String) -> void:
|
|||||||
BlockSessionService.pause_current_question_timer()
|
BlockSessionService.pause_current_question_timer()
|
||||||
|
|
||||||
|
|
||||||
func _exit_run() -> void:
|
func exit_current_run() -> void:
|
||||||
_close_app_menu()
|
_close_app_menu()
|
||||||
if current_screen_name == "ccs_player":
|
if current_screen_name == "ccs_player":
|
||||||
var ccs_player = screens.get("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 Brand := preload("res://scripts/ui/medmind_style.gd")
|
||||||
const FlashcardPrefillDialog := preload("res://scripts/ui/flashcard_prefill_dialog.gd")
|
const FlashcardPrefillDialog := preload("res://scripts/ui/flashcard_prefill_dialog.gd")
|
||||||
const CalculatorPanel := preload("res://scripts/ui/calculator_panel.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_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 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")
|
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 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_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 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 FEEDBACK_FADE_SECONDS := 0.45
|
||||||
const CORRECT_ADVANCE_DELAY_SECONDS := 1.25
|
const CORRECT_ADVANCE_DELAY_SECONDS := 1.25
|
||||||
const TEXT_SCALE_MIN := 0.85
|
const TEXT_SCALE_MIN := 0.85
|
||||||
@@ -90,6 +92,7 @@ var text_smaller_button: Button
|
|||||||
var text_larger_button: Button
|
var text_larger_button: Button
|
||||||
var labs_button: Button
|
var labs_button: Button
|
||||||
var calculator_button: Button
|
var calculator_button: Button
|
||||||
|
var exit_button: Button
|
||||||
var clear_highlight_button: Button
|
var clear_highlight_button: Button
|
||||||
var flag_button: Button
|
var flag_button: Button
|
||||||
var report_button: Button
|
var report_button: Button
|
||||||
@@ -99,6 +102,7 @@ var flashcard_prefill_dialog: Control
|
|||||||
var bookmark_button: Button
|
var bookmark_button: Button
|
||||||
var app_feedback_button: Button
|
var app_feedback_button: Button
|
||||||
var theme_button: Button
|
var theme_button: Button
|
||||||
|
var credits_badge: PanelContainer
|
||||||
var question_toolbar: HFlowContainer
|
var question_toolbar: HFlowContainer
|
||||||
var header_stack: VBoxContainer
|
var header_stack: VBoxContainer
|
||||||
var header_row: HBoxContainer
|
var header_row: HBoxContainer
|
||||||
@@ -317,6 +321,18 @@ func _ready() -> void:
|
|||||||
UISoundService.set_button_sound(text_larger_button, "toggle")
|
UISoundService.set_button_sound(text_larger_button, "toggle")
|
||||||
text_larger_button.pressed.connect(func(): _adjust_question_text_scale(TEXT_SCALE_STEP))
|
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 = Button.new()
|
||||||
calculator_button.name = "CalculatorButton"
|
calculator_button.name = "CalculatorButton"
|
||||||
calculator_button.tooltip_text = "Open calculator"
|
calculator_button.tooltip_text = "Open calculator"
|
||||||
@@ -404,10 +420,13 @@ func _ready() -> void:
|
|||||||
Style.style_chip(app_feedback_button, Style.CYAN)
|
Style.style_chip(app_feedback_button, Style.CYAN)
|
||||||
app_feedback_button.pressed.connect(func(): main.open_feedback_dialog())
|
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
|
# Every action is directly visible - no "More" overflow or popups hiding
|
||||||
# controls behind an extra click. No text labels either (their varying widths
|
# controls behind an extra click. No text labels either (their varying widths
|
||||||
# broke even spacing when a button hid itself) - each button's existing
|
# broke even spacing when a button hid itself) - each button's existing
|
||||||
# tooltip_text carries the label as hover text instead.
|
# 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_smaller_button)
|
||||||
question_toolbar.add_child(text_larger_button)
|
question_toolbar.add_child(text_larger_button)
|
||||||
question_toolbar.add_child(calculator_button)
|
question_toolbar.add_child(calculator_button)
|
||||||
@@ -420,6 +439,7 @@ func _ready() -> void:
|
|||||||
question_toolbar.add_child(notes_button)
|
question_toolbar.add_child(notes_button)
|
||||||
question_toolbar.add_child(theme_button)
|
question_toolbar.add_child(theme_button)
|
||||||
question_toolbar.add_child(app_feedback_button)
|
question_toolbar.add_child(app_feedback_button)
|
||||||
|
question_toolbar.add_child(credits_badge)
|
||||||
|
|
||||||
header_toolbar_host = HBoxContainer.new()
|
header_toolbar_host = HBoxContainer.new()
|
||||||
header_toolbar_host.alignment = BoxContainer.ALIGNMENT_END
|
header_toolbar_host.alignment = BoxContainer.ALIGNMENT_END
|
||||||
@@ -467,6 +487,7 @@ func _ready() -> void:
|
|||||||
content_margin.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
content_margin.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||||
content_margin.add_theme_constant_override("margin_left", 4)
|
content_margin.add_theme_constant_override("margin_left", 4)
|
||||||
content_margin.add_theme_constant_override("margin_right", 12)
|
content_margin.add_theme_constant_override("margin_right", 12)
|
||||||
|
content_margin.add_theme_constant_override("margin_top", 20)
|
||||||
question_scroll.add_child(content_margin)
|
question_scroll.add_child(content_margin)
|
||||||
|
|
||||||
question_content = VBoxContainer.new()
|
question_content = VBoxContainer.new()
|
||||||
@@ -787,9 +808,10 @@ func _refresh_responsive_layout() -> void:
|
|||||||
header_toolbar_host.reparent(target_parent)
|
header_toolbar_host.reparent(target_parent)
|
||||||
header_toolbar_host.size_flags_horizontal = Control.SIZE_EXPAND_FILL if compact else Control.SIZE_SHRINK_END
|
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
|
question_toolbar.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
# 11 bare 44px icon buttons + the wider 92px Feedback button, with 8px gaps
|
# 12 bare 44px icon buttons + the wider 92px Feedback button + the ~194px
|
||||||
# between all 12 - anything less than this wraps onto a second row.
|
# rank/ELO + points badge, with 8px gaps between all 14 - anything less
|
||||||
header_toolbar_host.custom_minimum_size.x = 0.0 if compact else 680.0
|
# 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:
|
if size.x < 560.0:
|
||||||
nav_palette.columns = 5
|
nav_palette.columns = 5
|
||||||
else:
|
else:
|
||||||
@@ -1045,7 +1067,7 @@ func _apply_question_theme() -> void:
|
|||||||
var navigator_heading := navigator_popup.find_child("NavigatorHeading", true, false) as Label
|
var navigator_heading := navigator_popup.find_child("NavigatorHeading", true, false) as Label
|
||||||
if navigator_heading != null:
|
if navigator_heading != null:
|
||||||
navigator_heading.add_theme_color_override("font_color", Brand.text_color(dark))
|
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:
|
if control == null:
|
||||||
continue
|
continue
|
||||||
var active: bool = control == bookmark_button and BookmarkService.is_bookmarked(str(BlockSessionService.get_current_question().get("id", "")))
|
var active: bool = control == bookmark_button and BookmarkService.is_bookmarked(str(BlockSessionService.get_current_question().get("id", "")))
|
||||||
@@ -1061,6 +1083,8 @@ func _apply_question_theme() -> void:
|
|||||||
_style_theme_toggle_button(dark)
|
_style_theme_toggle_button(dark)
|
||||||
if calculator_panel != null:
|
if calculator_panel != null:
|
||||||
calculator_panel.refresh_theme_controls()
|
calculator_panel.refresh_theme_controls()
|
||||||
|
if credits_badge != null:
|
||||||
|
credits_badge.refresh_theme_controls()
|
||||||
if skip_button != null:
|
if skip_button != null:
|
||||||
var skip_link_color := Brand.SKY_BLUE if dark else Brand.ROYAL_BLUE
|
var skip_link_color := Brand.SKY_BLUE if dark else Brand.ROYAL_BLUE
|
||||||
skip_button.add_theme_stylebox_override("normal", StyleBoxEmpty.new())
|
skip_button.add_theme_stylebox_override("normal", StyleBoxEmpty.new())
|
||||||
@@ -1305,7 +1329,7 @@ func _load_question() -> void:
|
|||||||
if category_text.is_empty():
|
if category_text.is_empty():
|
||||||
category_text = str(question.get("system", "")).strip_edges()
|
category_text = str(question.get("system", "")).strip_edges()
|
||||||
category_label.text = category_text
|
category_label.text = category_text
|
||||||
category_label.visible = not category_text.is_empty()
|
category_label.visible = false
|
||||||
_refresh_labs_button()
|
_refresh_labs_button()
|
||||||
|
|
||||||
presented_choices = question.get("presented_choices", question.get("choices", [])).duplicate(true)
|
presented_choices = question.get("presented_choices", question.get("choices", [])).duplicate(true)
|
||||||
@@ -1542,12 +1566,15 @@ func _submit_or_next() -> void:
|
|||||||
return
|
return
|
||||||
if BlockSessionService.session_kind in ["core", "bookmark", "review"]:
|
if BlockSessionService.session_kind in ["core", "bookmark", "review"]:
|
||||||
var attempted_choice := selected_choice
|
var attempted_choice := selected_choice
|
||||||
|
var met_time_limit := true
|
||||||
if BlockSessionService.session_kind == "core":
|
if BlockSessionService.session_kind == "core":
|
||||||
|
met_time_limit = BlockSessionService.get_current_elapsed_seconds() <= BlockSessionService.core_current_target_seconds
|
||||||
QuestionBankService.record_first_attempt(
|
QuestionBankService.record_first_attempt(
|
||||||
question_id_for_attempt,
|
question_id_for_attempt,
|
||||||
attempted_choice,
|
attempted_choice,
|
||||||
_presented_choice_ids(),
|
_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
|
var result: Dictionary
|
||||||
if BlockSessionService.session_kind == "core":
|
if BlockSessionService.session_kind == "core":
|
||||||
@@ -1570,6 +1597,15 @@ func _submit_or_next() -> void:
|
|||||||
_update_question_elo_label(true)
|
_update_question_elo_label(true)
|
||||||
_show_answer_review()
|
_show_answer_review()
|
||||||
_trigger_correct_feedback()
|
_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
|
return
|
||||||
_lock_wrong_choice(attempted_choice)
|
_lock_wrong_choice(attempted_choice)
|
||||||
_show_wrong_answer_followup(attempted_choice)
|
_show_wrong_answer_followup(attempted_choice)
|
||||||
@@ -1716,6 +1752,7 @@ func _show_explanation() -> void:
|
|||||||
explanation_plain_text = str(question.get("explanation", ""))
|
explanation_plain_text = str(question.get("explanation", ""))
|
||||||
_render_explanation()
|
_render_explanation()
|
||||||
explanation_label.visible = true
|
explanation_label.visible = true
|
||||||
|
category_label.visible = not category_label.text.strip_edges().is_empty()
|
||||||
_update_question_elo_label(true)
|
_update_question_elo_label(true)
|
||||||
_refresh_nav_palette()
|
_refresh_nav_palette()
|
||||||
_update_content_vertical_alignment()
|
_update_content_vertical_alignment()
|
||||||
@@ -1740,6 +1777,8 @@ func _clear_answer_review() -> void:
|
|||||||
explanation_label.visible = false
|
explanation_label.visible = false
|
||||||
if correct_review_panel != null:
|
if correct_review_panel != null:
|
||||||
correct_review_panel.visible = false
|
correct_review_panel.visible = false
|
||||||
|
if category_label != null:
|
||||||
|
category_label.visible = false
|
||||||
_clear_container_children(teaching_cards)
|
_clear_container_children(teaching_cards)
|
||||||
_clear_container_children(review_followups_content)
|
_clear_container_children(review_followups_content)
|
||||||
review_followup_states.clear()
|
review_followup_states.clear()
|
||||||
@@ -1751,13 +1790,7 @@ func _clear_answer_review() -> void:
|
|||||||
func _update_content_vertical_alignment() -> void:
|
func _update_content_vertical_alignment() -> void:
|
||||||
if question_content == null:
|
if question_content == null:
|
||||||
return
|
return
|
||||||
# Before an answer is submitted the stem+choices block is usually shorter than the
|
question_content.alignment = BoxContainer.ALIGNMENT_BEGIN
|
||||||
# available panel height; center it so it doesn't sit pinned to the top with a slab
|
|
||||||
# of empty space below. Once the answer review content appears, go back to top
|
|
||||||
# alignment so the growing explanation/follow-up sections read top-down and scroll
|
|
||||||
# naturally instead of being centered mid-panel.
|
|
||||||
var answered := correct_review_panel != null and correct_review_panel.visible
|
|
||||||
question_content.alignment = BoxContainer.ALIGNMENT_BEGIN if answered else BoxContainer.ALIGNMENT_CENTER
|
|
||||||
|
|
||||||
|
|
||||||
func _clear_container_children(container: Control) -> void:
|
func _clear_container_children(container: Control) -> void:
|
||||||
@@ -2165,6 +2198,39 @@ func _trigger_correct_feedback() -> void:
|
|||||||
feedback_fog.color = Color(0.25, 1.0, 0.25, _feedback_overlay_alpha(true))
|
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:
|
func _update_answer_feedback(delta: float) -> void:
|
||||||
if shake_time > 0.0:
|
if shake_time > 0.0:
|
||||||
shake_time = max(0.0, shake_time - delta)
|
shake_time = max(0.0, shake_time - delta)
|
||||||
@@ -2279,7 +2345,7 @@ func _refresh_toolbar_state() -> void:
|
|||||||
if notes_button != null:
|
if notes_button != null:
|
||||||
notes_button.visible = BlockSessionService.session_kind not in ["simulation", "dynamic"]
|
notes_button.visible = BlockSessionService.session_kind not in ["simulation", "dynamic"]
|
||||||
if flashcard_button != null:
|
if flashcard_button != null:
|
||||||
flashcard_button.visible = BlockSessionService.session_kind not in ["simulation", "dynamic"]
|
flashcard_button.visible = Brand.is_uat_environment() and BlockSessionService.session_kind not in ["simulation", "dynamic"]
|
||||||
flashcard_button.disabled = current_answer.is_empty()
|
flashcard_button.disabled = current_answer.is_empty()
|
||||||
if bookmark_button != null:
|
if bookmark_button != null:
|
||||||
bookmark_button.visible = BlockSessionService.session_kind not in ["simulation", "dynamic"]
|
bookmark_button.visible = BlockSessionService.session_kind not in ["simulation", "dynamic"]
|
||||||
|
|||||||
@@ -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