diff --git a/.gitea/scripts/deploy-environment.sh b/.gitea/scripts/deploy-environment.sh index 658bd56..86ddd43 100644 --- a/.gitea/scripts/deploy-environment.sh +++ b/.gitea/scripts/deploy-environment.sh @@ -34,7 +34,7 @@ docker system df df -h docker build -f api/QuestionsApi.Tests/Dockerfile -t "${TEST_IMAGE}" . -docker build --build-arg BUILD_REVISION="${BUILD_TAG}" -t "${API_IMAGE}" api/QuestionsApi +docker build --build-arg BUILD_REVISION="${BUILD_TAG}" -f api/QuestionsApi/Dockerfile -t "${API_IMAGE}" . docker build \ --build-arg BUILD_REVISION="${BUILD_TAG}" \ --build-arg DEPLOYMENT_ENVIRONMENT="${DEPLOYMENT_ENVIRONMENT}" \ @@ -70,7 +70,48 @@ kubectl kustomize "k8s/overlays/${DEPLOYMENT_ENVIRONMENT}" \ kubectl rollout restart deployment/step3-api -n "${KUBE_NAMESPACE}" kubectl rollout status deployment/step3-api -n "${KUBE_NAMESPACE}" --timeout=240s kubectl get --raw "/api/v1/namespaces/${KUBE_NAMESPACE}/services/step3-api:http/proxy/readyz" >/dev/null + +if [ "${DEPLOYMENT_ENVIRONMENT}" = "uat" ]; then + # A completed old replica can briefly remain visible immediately after a + # rollout. Select the newest pod that is both Running and container-ready + # instead of relying on the first item returned by Kubernetes. + PROMOTION_SUCCEEDED=0 + for attempt in 1 2 3; do + API_POD="$(kubectl get pods -n "${KUBE_NAMESPACE}" -l app=step3-api \ + --field-selector=status.phase=Running \ + --sort-by=.metadata.creationTimestamp \ + -o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.status.containerStatuses[0].ready}{"\n"}{end}' \ + | awk '$2 == "true" { name = $1 } END { print name }')" + if [ -n "${API_POD}" ] \ + && kubectl wait -n "${KUBE_NAMESPACE}" --for=condition=Ready "pod/${API_POD}" --timeout=60s \ + && kubectl exec -n "${KUBE_NAMESPACE}" "${API_POD}" -- \ + dotnet QuestionsApi.dll promote-dynamic-questions \ + --source /app/content/dynamic_questions \ + --apply; then + PROMOTION_SUCCEEDED=1 + break + fi + echo "Dynamic question promotion attempt ${attempt} did not find a stable ready API pod; retrying." >&2 + sleep 2 + done + if [ "${PROMOTION_SUCCEEDED}" -ne 1 ]; then + echo "Dynamic question promotion failed after 3 attempts." >&2 + exit 1 + fi +fi + kubectl rollout status deployment/step3-web -n "${KUBE_NAMESPACE}" --timeout=240s kubectl get --raw "/api/v1/namespaces/${KUBE_NAMESPACE}/services/step3-web:http/proxy/healthz" >/dev/null +if [ "${DEPLOYMENT_ENVIRONMENT}" = "uat" ]; then + # The images are already imported on every Kubernetes node. Reclaim runner + # space before pulling the Playwright browser image. + docker image rm "${API_IMAGE}" "${WEB_IMAGE}" "${TEST_IMAGE}" 2>/dev/null || true + docker builder prune -af >/dev/null || true + UAT_API_ORIGIN="${PUBLIC_API_ORIGIN}" \ + UAT_WEB_ORIGIN="${PUBLIC_WEB_ORIGIN:-https://ustep3.ranaze.com}" \ + EXPECTED_REVISION="${BUILD_TAG}" \ + bash .gitea/scripts/run-uat-smoke.sh +fi + echo "Deployed ${DEPLOYMENT_ENVIRONMENT} revision ${BUILD_TAG}." diff --git a/.gitea/scripts/run-uat-smoke.sh b/.gitea/scripts/run-uat-smoke.sh new file mode 100644 index 0000000..a95a047 --- /dev/null +++ b/.gitea/scripts/run-uat-smoke.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${UAT_API_ORIGIN:=https://us3q.ranaze.com}" +: "${UAT_WEB_ORIGIN:=https://ustep3.ranaze.com}" + +IMAGE_TAG="step3-uat-smoke:${GITHUB_RUN_ID:-manual}-${GITHUB_RUN_NUMBER:-0}" +ARTIFACT_DIR="${SMOKE_ARTIFACT_DIR:-artifacts/uat-smoke}" +ARTIFACT_ABS="$(pwd)/${ARTIFACT_DIR}" + +cleanup() { + docker image rm "${IMAGE_TAG}" >/dev/null 2>&1 || true +} +trap cleanup EXIT + +rm -rf "${ARTIFACT_DIR}" +mkdir -p "${ARTIFACT_DIR}" + +docker build -f tests/smoke/Dockerfile -t "${IMAGE_TAG}" tests/smoke +docker run --rm --ipc=host \ + -e UAT_API_ORIGIN="${UAT_API_ORIGIN}" \ + -e UAT_WEB_ORIGIN="${UAT_WEB_ORIGIN}" \ + -e EXPECTED_REVISION="${EXPECTED_REVISION:-}" \ + -v "${ARTIFACT_ABS}:/work/test-results" \ + "${IMAGE_TAG}" diff --git a/.gitea/workflows/deploy-prd.yml b/.gitea/workflows/deploy-prd.yml index c45c9e3..951ea2e 100644 --- a/.gitea/workflows/deploy-prd.yml +++ b/.gitea/workflows/deploy-prd.yml @@ -4,6 +4,10 @@ on: push: branches: - production + paths-ignore: + - '**.md' + - 'tools/**' + - 'tests/**' workflow_dispatch: {} concurrency: diff --git a/.gitea/workflows/deploy-uat.yml b/.gitea/workflows/deploy-uat.yml index aea475c..f7f0e7d 100644 --- a/.gitea/workflows/deploy-uat.yml +++ b/.gitea/workflows/deploy-uat.yml @@ -4,6 +4,10 @@ on: push: branches: - main + paths-ignore: + - '**.md' + - 'tools/**' + - 'tests/**' workflow_dispatch: {} concurrency: @@ -32,6 +36,7 @@ jobs: DEPLOYMENT_ENVIRONMENT: uat KUBE_NAMESPACE: step3-uat PUBLIC_API_ORIGIN: https://us3q.ranaze.com + PUBLIC_WEB_ORIGIN: https://ustep3.ranaze.com MONGODB_CONNECTION_STRING: ${{ secrets.UAT_MONGODB_CONNECTION_STRING }} AUTH_JWT_KEY: ${{ secrets.UAT_AUTH_JWT_KEY }} INTERNAL_PURGE_SECRET: ${{ secrets.UAT_INTERNAL_PURGE_SECRET }} @@ -40,3 +45,12 @@ jobs: GOOGLE_CLIENT_ID: ${{ secrets.UAT_GOOGLE_CLIENT_ID }} GOOGLE_CLIENT_SECRET: ${{ secrets.UAT_GOOGLE_CLIENT_SECRET }} run: bash .gitea/scripts/deploy-environment.sh + + - name: Upload UAT smoke-test artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: uat-smoke-${{ github.run_number }}-${{ github.run_attempt }} + path: artifacts/uat-smoke + if-no-files-found: warn + retention-days: 14 diff --git a/.gitea/workflows/smoke-uat.yml b/.gitea/workflows/smoke-uat.yml new file mode 100644 index 0000000..b40273f --- /dev/null +++ b/.gitea/workflows/smoke-uat.yml @@ -0,0 +1,30 @@ +name: Smoke Test STEP3 UAT + +on: + workflow_dispatch: {} + +concurrency: + group: step3-uat-smoke + cancel-in-progress: false + +jobs: + smoke: + runs-on: self-hosted + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Run UAT smoke tests + env: + UAT_API_ORIGIN: https://us3q.ranaze.com + UAT_WEB_ORIGIN: https://ustep3.ranaze.com + run: bash .gitea/scripts/run-uat-smoke.sh + + - name: Upload smoke-test artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: uat-smoke-${{ github.run_number }}-${{ github.run_attempt }} + path: artifacts/uat-smoke + if-no-files-found: warn + retention-days: 14 diff --git a/.gitignore b/.gitignore index 7fe0a3a..69ecdaa 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,8 @@ # Local editor/user files *.user *.suo + +# UAT smoke-test output +/artifacts/uat-smoke/ +**/node_modules/ +/tests/smoke/local-results/ diff --git a/api/QuestionsApi.Tests/CcsCaseTests.cs b/api/QuestionsApi.Tests/CcsCaseTests.cs index 81b6e00..766aadf 100644 --- a/api/QuestionsApi.Tests/CcsCaseTests.cs +++ b/api/QuestionsApi.Tests/CcsCaseTests.cs @@ -8,6 +8,41 @@ namespace QuestionsApi.Tests; public sealed class CcsCaseTests { + [Fact] + public void NewCcsAttemptsAreTimedOnly() + { + Assert.Null(CcsCaseValidator.ValidateCreate(new CreateCcsAttemptRequest())); + Assert.Equal("new CCS attempts must be timed", + CcsCaseValidator.ValidateCreate(new CreateCcsAttemptRequest { Mode = CcsAttemptModes.Practice })); + } + + [Fact] + public void CcsBatchCommandsAreBoundedAndHomogeneous() + { + var valid = new CcsBatchCommandRequest + { + CommandId = "batch-1", + Commands = + [ + new() { Type = "exam", Code = "general" }, + new() { Type = "exam", Code = "cardiac" } + ] + }; + Assert.Null(CcsCaseValidator.ValidateBatch(valid)); + valid.Commands.Add(new() { Type = "order", Code = "bmp" }); + Assert.Contains("cannot be mixed", CcsCaseValidator.ValidateBatch(valid), StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void CcsTimingReservesTwoMinutesWithoutExtendingTheHardDeadline() + { + Assert.Equal(18, CcsStore.ActivePhaseMinutes(20)); + Assert.Equal(8, CcsStore.ActivePhaseMinutes(10)); + var now = new DateTime(2026, 7, 23, 12, 0, 0, DateTimeKind.Utc); + Assert.Equal(now.AddMinutes(2), CcsStore.FinalOrdersDeadline(now, now.AddMinutes(5))); + Assert.Equal(now.AddMinutes(1), CcsStore.FinalOrdersDeadline(now, now.AddMinutes(1))); + } + [Fact] public void CcsHistoryCursorRoundTripsTimestampAndId() { @@ -108,6 +143,25 @@ public sealed class CcsCaseTests Assert.True(CcsRatingStore.ExpectedScore(800, 1000) < 0.25); } + [Theory] + [InlineData("medication", "medication")] + [InlineData("medication", "medications")] + [InlineData("medication", "treatment")] + [InlineData("procedure", "procedures")] + [InlineData("laboratory", "tests")] + [InlineData("diagnostic", "study")] + public void CcsOrderSearchMatchesClinicalCategories(string category, string query) + { + var order = new CcsOrderDefinition + { + Code = "order", + CanonicalName = "Unrelated canonical name", + Category = category + }; + + Assert.True(CcsStore.Rank(order, query) < 1000); + } + [Fact] public void ScoringReviewIncludesExpectedAndObservedEvidence() { diff --git a/api/QuestionsApi.Tests/DynamicQuestionTests.cs b/api/QuestionsApi.Tests/DynamicQuestionTests.cs new file mode 100644 index 0000000..e844a4e --- /dev/null +++ b/api/QuestionsApi.Tests/DynamicQuestionTests.cs @@ -0,0 +1,271 @@ +using Microsoft.Extensions.Configuration; +using QuestionsApi.Models; +using QuestionsApi.Services; +using Xunit; + +namespace QuestionsApi.Tests; + +public sealed class DynamicQuestionTests +{ + [Fact] + public void Generator_IsDeterministic_AndPreservesCorrectAnswer() + { + var blueprint = Blueprint(); + var generator = new DynamicQuestionGenerator(); + + var first = generator.Generate(blueprint, "same-seed", "one").Question; + var second = generator.Generate(blueprint, "same-seed", "two").Question; + + Assert.Equal(first.Stem, second.Stem); + Assert.Equal(first.Choices.Select(value => value.Text), second.Choices.Select(value => value.Text)); + Assert.Equal("correct", first.CorrectChoiceId); + Assert.Null(DynamicQuestionGenerator.Validate(blueprint)); + } + + [Fact] + public void SignedTokens_AreUserBound_AndRejectTampering() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary { ["DynamicQuestions:SigningKey"] = "unit-test-key" }) + .Build(); + var service = new DynamicQuestionTokenService(configuration); + var claims = new DynamicGenerationClaims + { + UserId = "user-one", + BlueprintExternalId = "test", + BlueprintVersion = 1, + ObjectiveId = "objective", + Seed = "seed", + Nonce = "nonce", + GeneratorVersion = 1, + ExpiresUtc = DateTime.UtcNow.AddMinutes(5) + }; + + var token = service.SignGeneration(claims); + + Assert.Equal("seed", service.ReadGeneration(token, "user-one").Seed); + Assert.Throws(() => service.ReadGeneration(token, "user-two")); + Assert.ThrowsAny(() => service.ReadGeneration(token + "x", "user-one")); + } + + [Fact] + public void GoldGenerator_UsesCaseFamilies_AndProducesAuthoredTeachingShape() + { + var blueprint = GoldBlueprint(); + var generator = new DynamicQuestionGenerator(); + var generated = Enumerable.Range(0, 100) + .Select(seed => generator.Generate(blueprint, seed.ToString(), seed.ToString())) + .ToList(); + + Assert.Equal(3, generated.Select(value => value.CaseFamilyId).Distinct().Count()); + Assert.All(generated, value => + { + Assert.True(value.Question.Stem.Length >= 220); + Assert.Equal(5, value.Question.Choices.Count); + Assert.Equal(5, value.Question.ChoiceExplanations.Count); + Assert.Single(value.Question.ReviewFollowups); + Assert.Equal(4, value.Question.WrongAnswerFollowups.Count); + Assert.Contains("quality-standard:2", value.Question.Tags); + }); + Assert.Null(DynamicQuestionGenerator.Validate(blueprint)); + } + + [Fact] + public void LegacyGeneratorVersion_RemainsDeterministic() + { + var generator = new DynamicQuestionGenerator(); + var first = generator.Generate(Blueprint(), "legacy-seed", "one", 1).Question; + var regenerated = generator.Generate(Blueprint(), "legacy-seed", "two", 1).Question; + Assert.Equal(first.Stem, regenerated.Stem); + Assert.Equal(first.Choices.Select(value => value.Id), regenerated.Choices.Select(value => value.Id)); + } + + [Fact] + public void DifficultyPolicy_BlendsThenSpecializes_AndTargetsSixtyPercent() + { + Assert.Equal(1200, DynamicDifficultyPolicy.EffectiveAbility(1100, 1300, null)); + var early = new DynamicObjectiveMastery { Elo = 1500, Attempts = 1 }; + var established = new DynamicObjectiveMastery { Elo = 1500, Attempts = 10 }; + Assert.Equal(1224, DynamicDifficultyPolicy.EffectiveAbility(1100, 1300, early)); + Assert.Equal(1440, DynamicDifficultyPolicy.EffectiveAbility(1100, 1300, established)); + Assert.Equal(1370, DynamicDifficultyPolicy.TargetFamilyElo(1440)); + } + + [Fact] + public void DifficultyPolicy_SelectsClosestNonrecentFamily() + { + var families = new List + { + new() { Id = "foundation", DifficultyTier = "foundation", NominalElo = 850 }, + new() { Id = "standard", DifficultyTier = "standard", NominalElo = 1050 }, + new() { Id = "advanced-a", DifficultyTier = "advanced", NominalElo = 1250 }, + new() { Id = "advanced-b", DifficultyTier = "advanced", NominalElo = 1250 }, + new() { Id = "expert", DifficultyTier = "expert", NominalElo = 1450 } + }; + var selected = DynamicDifficultyPolicy.SelectFamily( + families, new Dictionary(), 1260, + new HashSet { "advanced-a" }, 0); + Assert.Equal("advanced-b", selected.Id); + } + + [Fact] + public void DifficultyPolicy_RatingsMoveInOppositeDirections() + { + Assert.True(DynamicDifficultyPolicy.LearnerDelta(1000, 1050, true, 0) > 0); + Assert.True(DynamicDifficultyPolicy.FamilyDelta(1000, 1050, true, 0) < 0); + Assert.True(DynamicDifficultyPolicy.LearnerDelta(1000, 1050, false, 0) < 0); + Assert.True(DynamicDifficultyPolicy.FamilyDelta(1000, 1050, false, 0) > 0); + Assert.Equal(600, DynamicDifficultyPolicy.Clamp(-100)); + Assert.Equal(1800, DynamicDifficultyPolicy.Clamp(5000)); + } + + [Fact] + public void ReviewPolicy_UsesFixedIntervals_AndResetsAfterAMiss() + { + var now = new DateTime(2026, 7, 23, 12, 0, 0, DateTimeKind.Utc); + + Assert.Equal(now.AddDays(1), DynamicReviewPolicy.FirstDue(now)); + Assert.Equal((1, now.AddDays(3), false), DynamicReviewPolicy.AfterReview(0, true, now)); + Assert.Equal((2, now.AddDays(7), false), DynamicReviewPolicy.AfterReview(1, true, now)); + Assert.Equal((3, now.AddDays(21), false), DynamicReviewPolicy.AfterReview(2, true, now)); + Assert.Equal((4, now.AddDays(45), false), DynamicReviewPolicy.AfterReview(3, true, now)); + Assert.Equal((5, null, true), DynamicReviewPolicy.AfterReview(4, true, now)); + Assert.Equal((0, now.AddDays(1), false), DynamicReviewPolicy.AfterReview(3, false, now)); + } + + [Fact] + public void QualityStandardFour_RequiresCoverageReviewAndMisconceptionMetadata() + { + var options = new System.Text.Json.JsonSerializerOptions(System.Text.Json.JsonSerializerDefaults.Web) + { PropertyNameCaseInsensitive = true }; + var root = Path.GetFullPath(Path.Combine( + AppContext.BaseDirectory, "..", "..", "..", "..", "..", "content", "dynamic_questions")); + var source = Directory.EnumerateFiles(root, "*.json") + .SelectMany(file => System.Text.Json.JsonSerializer.Deserialize>( + File.ReadAllText(file), options) ?? []) + .ToList(); + var blueprint = DynamicBlueprintResolver.ResolveAll(source) + .First(value => value.QualityStandardVersion == 3); + blueprint.QualityStandardVersion = 4; + blueprint.BlueprintWeight = 6; + + Assert.Contains("encounterFrame", DynamicQuestionGenerator.Validate(blueprint)); + + blueprint.System = "Endocrine"; + blueprint.PhysicianTask = "Pharmacotherapy"; + blueprint.EncounterFrame = "Urgent Intervention"; + blueprint.SiteOfCare = "Emergency Department"; + blueprint.ConceptIds = ["test.concept"]; + blueprint.MedicallyReviewedUtc = DateTime.UtcNow.AddDays(-1); + blueprint.MedicalReviewExpiresUtc = DateTime.UtcNow.AddYears(1); + blueprint.MedicalReviewer = "Medical editor"; + foreach (var distractor in blueprint.Distractors) + distractor.MisconceptionId = $"misconception.{distractor.Id}"; + + Assert.Null(DynamicQuestionGenerator.Validate(blueprint)); + } + + [Fact] + public void CoveragePolicy_UsesOfficialDefaults_AndAllowsVersionedOverrides() + { + Assert.Equal(12, DynamicBlueprintCoveragePolicy.Weight( + new DynamicQuestionBlueprint { System = "Biostatistics" })); + Assert.Equal(10, DynamicBlueprintCoveragePolicy.Weight( + new DynamicQuestionBlueprint { System = "Cardiovascular" })); + Assert.Equal(2.5, DynamicBlueprintCoveragePolicy.Weight( + new DynamicQuestionBlueprint { System = "Cardiovascular", BlueprintWeight = 2.5 })); + } + + [Fact] + public void AdaptiveContent_ResolvesToFourCompleteTiers() + { + var options = new System.Text.Json.JsonSerializerOptions(System.Text.Json.JsonSerializerDefaults.Web) + { PropertyNameCaseInsensitive = true }; + var root = Path.GetFullPath(Path.Combine( + AppContext.BaseDirectory, "..", "..", "..", "..", "..", "content", "dynamic_questions")); + var source = Directory.EnumerateFiles(root, "*.json") + .SelectMany(file => System.Text.Json.JsonSerializer.Deserialize>( + File.ReadAllText(file), options) ?? []) + .ToList(); + var adaptive = DynamicBlueprintResolver.ResolveAll(source) + .Where(value => value.QualityStandardVersion >= 3).ToList(); + + Assert.Equal(5, adaptive.Count); + Assert.All(adaptive, blueprint => + { + Assert.Equal(8, blueprint.CaseFamilies.Count); + Assert.All(DynamicDifficultyPolicy.TierElos.Keys, tier => + Assert.Equal(2, blueprint.CaseFamilies.Count(value => value.DifficultyTier == tier))); + Assert.All(blueprint.CaseFamilies, family => + Assert.False(family.StemTemplate.EndsWith(family.LeadIn, StringComparison.Ordinal))); + Assert.Null(DynamicQuestionGenerator.Validate(blueprint)); + }); + } + + private static DynamicQuestionBlueprint Blueprint() => new() + { + ExternalId = "test", + ObjectiveId = "objective", + StemTemplate = "Patient age {{age}}.", + LeadIn = "What is correct?", + Variables = new() + { + ["age"] = new DynamicVariableDefinition { Minimum = 20, Maximum = 80, Step = 1 } + }, + CorrectAnswer = new DynamicAnswerTemplate { Id = "correct", TextTemplate = "Correct" }, + Distractors = + [ + new() { Id = "a", TextTemplate = "A" }, + new() { Id = "b", TextTemplate = "B" }, + new() { Id = "c", TextTemplate = "C" }, + new() { Id = "d", TextTemplate = "D" } + ], + References = [new QuestionReference { Title = "Reference", Url = "https://example.test" }] + }; + + private static DynamicQuestionBlueprint GoldBlueprint() + { + var stem = string.Join(" ", Enumerable.Repeat( + "A patient presents with a carefully correlated history, examination, vital signs, and diagnostic findings that establish the clinical context without naming the diagnosis.", 3)); + return new DynamicQuestionBlueprint + { + ExternalId = "gold-test", + Version = 2, + ObjectiveId = "gold.objective", + QualityStandardVersion = 2, + CorrectConceptId = "gold.correct", + CorrectAnswer = new DynamicAnswerTemplate + { Id = "correct", TextTemplate = "Correct action", ExplanationTemplate = "This addresses the decisive finding." }, + Distractors = Enumerable.Range(1, 6).Select(index => new DynamicAnswerTemplate + { + Id = $"d{index}", + TextTemplate = $"Distractor {index}", + ExplanationTemplate = $"Distractor {index} does not address the decisive finding." + }).ToList(), + ExplanationTemplate = "A complete explanation connects the presentation to the action.", + KeyTakeaway = "Use the decisive finding.", + UsmlePearl = "Sequence urgent care correctly.", + ReviewFollowups = + [ + new DynamicReviewFollowupTemplate + { + PromptTemplate = "What is decisive?", + Choices = + [ + new() { Id = "yes", TextTemplate = "The unstable finding" }, + new() { Id = "no", TextTemplate = "An incidental finding" } + ], + CorrectChoiceId = "yes", + TeachingPointTemplate = "The unstable finding determines management." + } + ], + CaseFamilies = + [ + new() { Id = "family-a", StemTemplate = stem, LeadIn = "What is next?" }, + new() { Id = "family-b", StemTemplate = stem + " Family B.", LeadIn = "What is next?" }, + new() { Id = "family-c", StemTemplate = stem + " Family C.", LeadIn = "What is next?" } + ], + References = [new QuestionReference { Title = "Reference", Url = "https://example.test" }] + }; + } +} diff --git a/api/QuestionsApi.Tests/FlashcardClozeParserTests.cs b/api/QuestionsApi.Tests/FlashcardClozeParserTests.cs new file mode 100644 index 0000000..fed70a2 --- /dev/null +++ b/api/QuestionsApi.Tests/FlashcardClozeParserTests.cs @@ -0,0 +1,94 @@ +using QuestionsApi.Services.Flashcards; +using Xunit; + +namespace QuestionsApi.Tests; + +public sealed class FlashcardClozeParserTests +{ + [Fact] + public void Parse_ExtractsAnswerAndHint() + { + var deletions = FlashcardClozeParser.Parse("The capital of France is {{c1::Paris::country capital}}."); + + var deletion = Assert.Single(deletions); + Assert.Equal(1, deletion.Number); + Assert.Equal("Paris", deletion.Answer); + Assert.Equal("country capital", deletion.Hint); + } + + [Fact] + public void Parse_WithoutHint_LeavesHintNull() + { + var deletions = FlashcardClozeParser.Parse("{{c1::Paris}}"); + + Assert.Null(Assert.Single(deletions).Hint); + } + + [Fact] + public void DistinctClozeNumbers_DeduplicatesRepeatedNumbersAndSorts() + { + var text = "{{c2::b}} some text {{c1::a}} more {{c1::a-again}} {{c3::c}}"; + + var numbers = FlashcardClozeParser.DistinctClozeNumbers(text); + + Assert.Equal([1, 2, 3], numbers); + } + + [Theory] + [InlineData("no cloze markers here")] + [InlineData("{{c0::invalid, number must be >= 1}}")] + [InlineData("{{cX::not a number}}")] + [InlineData("{{c1::}}")] + public void HasValidCloze_IsFalse_ForMalformedOrMissingMarkers(string text) + { + Assert.False(FlashcardClozeParser.HasValidCloze(text)); + } + + [Fact] + public void HasValidCloze_IsTrue_WhenAtLeastOneValidMarkerExists() + { + Assert.True(FlashcardClozeParser.HasValidCloze("prefix {{c1::answer}} suffix")); + } + + [Fact] + public void RenderFront_HidesTargetNumber_RevealsOthers() + { + var text = "{{c1::Paris}} is the capital of {{c2::France}}"; + + var front1 = FlashcardClozeParser.RenderFront(text, 1); + var front2 = FlashcardClozeParser.RenderFront(text, 2); + + Assert.Equal("[...] is the capital of France", front1); + Assert.Equal("Paris is the capital of [...]", front2); + } + + [Fact] + public void RenderFront_UsesHintWhenPresent() + { + var text = "{{c1::Paris::capital city}} is in France"; + + var front = FlashcardClozeParser.RenderFront(text, 1); + + Assert.Equal("[capital city] is in France", front); + } + + [Fact] + public void RenderBack_RevealsAllAnswers() + { + var text = "{{c1::Paris}} is the capital of {{c2::France::hint}}"; + + var back = FlashcardClozeParser.RenderBack(text, 1); + + Assert.Equal("Paris is the capital of France", back); + } + + [Fact] + public void Parse_SupportsMultilineClozeContent() + { + var text = "Line one\n{{c1::multi\nline\nanswer}}\nLine two"; + + var deletion = Assert.Single(FlashcardClozeParser.Parse(text)); + + Assert.Equal("multi\nline\nanswer", deletion.Answer); + } +} diff --git a/api/QuestionsApi.Tests/FlashcardCsvTests.cs b/api/QuestionsApi.Tests/FlashcardCsvTests.cs new file mode 100644 index 0000000..92e3724 --- /dev/null +++ b/api/QuestionsApi.Tests/FlashcardCsvTests.cs @@ -0,0 +1,75 @@ +using QuestionsApi.Services.Flashcards; +using Xunit; + +namespace QuestionsApi.Tests; + +public sealed class FlashcardCsvTests +{ + [Fact] + public void ParseRows_HandlesQuotedMultilineFieldsAndEmbeddedCommas() + { + var csv = "stable_id,deck,type,prompt,answer,extra,tags,source_question_external_id\n" + + "abc-1,Cardio,basic,\"What is the first line,\nsecond line?\",\"Answer, with comma\",,tag1;tag2,q-1\n"; + + var rows = FlashcardCsv.ParseRows(csv); + + Assert.Equal(2, rows.Count); + var data = rows[1]; + Assert.Equal("abc-1", data[0]); + Assert.Equal("What is the first line,\nsecond line?", data[3]); + Assert.Equal("Answer, with comma", data[4]); + Assert.Equal("tag1;tag2", data[6]); + } + + [Fact] + public void ParseRows_HandlesEscapedDoubleQuotes() + { + var csv = "a,b\n\"she said \"\"hi\"\"\",plain\n"; + + var rows = FlashcardCsv.ParseRows(csv); + + Assert.Equal("she said \"hi\"", rows[1][0]); + Assert.Equal("plain", rows[1][1]); + } + + [Fact] + public void ParseRows_SupportsUnicodeContent() + { + var csv = "a,b\néè中文,\U0001F600\n"; + + var rows = FlashcardCsv.ParseRows(csv); + + Assert.Equal("éè中文", rows[1][0]); + Assert.Equal("😀", rows[1][1]); + } + + [Fact] + public void ParseRows_HandlesFileWithoutTrailingNewline() + { + var csv = "a,b\n1,2"; + + var rows = FlashcardCsv.ParseRows(csv); + + Assert.Equal(2, rows.Count); + Assert.Equal(["1", "2"], rows[1]); + } + + [Fact] + public void WriteRow_QuotesFieldsContainingSpecialCharacters() + { + var row = FlashcardCsv.WriteRow(["plain", "has,comma", "has\"quote", "has\nnewline"]); + + Assert.Equal("plain,\"has,comma\",\"has\"\"quote\",\"has\nnewline\"", row); + } + + [Fact] + public void WriteThenParse_RoundTrips() + { + var original = new[] { "id-1", "Deck, Name", "basic", "prompt with \"quotes\" and\nnewline", "answer", "", "a;b", "q-1" }; + var line = FlashcardCsv.WriteRow(original); + + var rows = FlashcardCsv.ParseRows("header\n" + line + "\n"); + + Assert.Equal(original, rows[1]); + } +} diff --git a/api/QuestionsApi.Tests/FlashcardImportExportModelTests.cs b/api/QuestionsApi.Tests/FlashcardImportExportModelTests.cs new file mode 100644 index 0000000..648406f --- /dev/null +++ b/api/QuestionsApi.Tests/FlashcardImportExportModelTests.cs @@ -0,0 +1,92 @@ +using System.Text.Json; +using QuestionsApi.Models; +using QuestionsApi.Services.Flashcards; +using Xunit; + +namespace QuestionsApi.Tests; + +public sealed class FlashcardImportExportModelTests +{ + [Fact] + public void JsonExport_RoundTrips_NotesCardsAndReviewHistory() + { + var export = new FlashcardJsonExport + { + SchemaVersion = 1, + Settings = new FlashcardSettings { UserId = "user-1", NewCardsPerDay = 20, ReviewsPerDay = 200 }, + Decks = [new FlashcardDeck { Id = "deck-1", UserId = "user-1", Name = "Cardio", IsDefault = true }], + Notes = + [ + new FlashcardJsonExportNote + { + Note = new FlashcardNote + { + Id = "note-1", UserId = "user-1", DeckId = "deck-1", StableId = "stable-1", + Type = FlashcardTypes.Cloze, Prompt = "{{c1::Paris}} is the capital of France", + Tags = ["geography"] + }, + Cards = + [ + new FlashcardSchedule + { + Id = "card-1", UserId = "user-1", NoteId = "note-1", Variant = 1, + State = FlashcardScheduleStates.Review, Stability = 12.3, Difficulty = 4.5, + DueUtc = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc) + } + ], + ReviewLogs = + [ + new FlashcardReviewLog + { + Id = "log-1", UserId = "user-1", CardId = "card-1", Rating = FlashcardRatings.Good, + ReviewedUtc = new DateTime(2025, 12, 31, 0, 0, 0, DateTimeKind.Utc) + } + ], + Images = [new FlashcardJsonExportImage { AssetId = "asset-1", FileName = "x.png", ContentType = "image/png", Base64Bytes = Convert.ToBase64String([1, 2, 3]) }] + } + ] + }; + + var json = JsonSerializer.Serialize(export); + var roundTripped = JsonSerializer.Deserialize(json); + + Assert.NotNull(roundTripped); + var note = Assert.Single(roundTripped!.Notes); + Assert.Equal("stable-1", note.Note.StableId); + Assert.Equal(FlashcardTypes.Cloze, note.Note.Type); + var card = Assert.Single(note.Cards); + Assert.Equal(1, card.Variant); + Assert.Equal(12.3, card.Stability); + var log = Assert.Single(note.ReviewLogs); + Assert.Equal(FlashcardRatings.Good, log.Rating); + var image = Assert.Single(note.Images); + Assert.Equal([1, 2, 3], Convert.FromBase64String(image.Base64Bytes)); + } + + [Fact] + public void Fingerprint_IsStable_ForSameTypePromptAndAnswer_IgnoringSurroundingWhitespace() + { + var a = FlashcardNoteStore.Fingerprint(FlashcardTypes.Basic, " What is 2+2? ", "4"); + var b = FlashcardNoteStore.Fingerprint(FlashcardTypes.Basic, "What is 2+2?", "4"); + + Assert.Equal(a, b); + } + + [Fact] + public void Fingerprint_DiffersWhenAnswerDiffers() + { + var a = FlashcardNoteStore.Fingerprint(FlashcardTypes.Basic, "What is 2+2?", "4"); + var b = FlashcardNoteStore.Fingerprint(FlashcardTypes.Basic, "What is 2+2?", "five"); + + Assert.NotEqual(a, b); + } + + [Fact] + public void Fingerprint_DiffersByType_ForSamePromptAndAnswer() + { + var basic = FlashcardNoteStore.Fingerprint(FlashcardTypes.Basic, "text", "text"); + var cloze = FlashcardNoteStore.Fingerprint(FlashcardTypes.Cloze, "text", "text"); + + Assert.NotEqual(basic, cloze); + } +} diff --git a/api/QuestionsApi.Tests/FlashcardOwnershipTests.cs b/api/QuestionsApi.Tests/FlashcardOwnershipTests.cs new file mode 100644 index 0000000..0d67cac --- /dev/null +++ b/api/QuestionsApi.Tests/FlashcardOwnershipTests.cs @@ -0,0 +1,32 @@ +using QuestionsApi.Models; +using QuestionsApi.Services.Flashcards; +using Xunit; + +namespace QuestionsApi.Tests; + +public sealed class FlashcardOwnershipTests +{ + [Theory] + [InlineData(false, false, true)] + [InlineData(false, true, false)] + [InlineData(true, false, true)] + [InlineData(true, true, true)] + public void CanMutate_MatchesTruthTable(bool isSuper, bool targetIsSystem, bool expected) + { + Assert.Equal(expected, FlashcardOwnershipGuard.CanMutate(isSuper, targetIsSystem)); + } + + [Fact] + public void FlashcardDeck_IsSystem_TrueOnlyForTheReservedOwner() + { + Assert.True(new FlashcardDeck { UserId = FlashcardOwners.System }.IsSystem); + Assert.False(new FlashcardDeck { UserId = "a-real-user-id" }.IsSystem); + } + + [Fact] + public void FlashcardNote_IsSystem_TrueOnlyForTheReservedOwner() + { + Assert.True(new FlashcardNote { UserId = FlashcardOwners.System }.IsSystem); + Assert.False(new FlashcardNote { UserId = "a-real-user-id" }.IsSystem); + } +} diff --git a/api/QuestionsApi.Tests/FlashcardSchedulerTests.cs b/api/QuestionsApi.Tests/FlashcardSchedulerTests.cs new file mode 100644 index 0000000..aa2130d --- /dev/null +++ b/api/QuestionsApi.Tests/FlashcardSchedulerTests.cs @@ -0,0 +1,152 @@ +using FSRS.Core.Constants; +using QuestionsApi.Models; +using QuestionsApi.Services.Flashcards; +using Xunit; + +namespace QuestionsApi.Tests; + +// Validates FsrsFlashcardScheduler against the official FSRS formulas (not just against the +// library's own outputs), using the exact weights FSRS.Core 1.0.7 ships via FsrsConstants so +// this keeps validating the formula even if the shipped defaults are ever bumped upstream. +// +// S0(rating) = w[(int)rating - 1] (initial stability) +// D0(rating) = clamp(w4 - (e^(w5*((int)rating-1)) - 1), 1, 10) (initial difficulty) +// I(r, S) = round(S) when desired_retention == the FSRS reference retention (0.9), because +// the interval formula collapses to the stability itself at that specific retention. +public sealed class FlashcardSchedulerTests +{ + private static readonly double[] W = FsrsConstants.DefaultParameters; + private static readonly DateTime Start = new(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc); + + private static FlashcardSchedule NewSchedule() => new() + { + UserId = "user-1", + NoteId = "note-1", + State = FlashcardScheduleStates.New, + DueUtc = Start, + LastReviewUtc = null + }; + + [Theory] + [InlineData(FlashcardRatings.Again, 0)] + [InlineData(FlashcardRatings.Hard, 1)] + [InlineData(FlashcardRatings.Good, 2)] + [InlineData(FlashcardRatings.Easy, 3)] + public void InitialStability_MatchesFsrsFormula(string rating, int weightIndex) + { + var scheduler = new FsrsFlashcardScheduler(); + var result = scheduler.Review(NewSchedule(), rating, Start, 0); + + Assert.Equal(W[weightIndex], result.Stability, precision: 6); + } + + [Theory] + [InlineData(FlashcardRatings.Again, 1)] + [InlineData(FlashcardRatings.Hard, 2)] + [InlineData(FlashcardRatings.Good, 3)] + [InlineData(FlashcardRatings.Easy, 4)] + public void InitialDifficulty_MatchesFsrsFormula(string rating, int fsrsRatingValue) + { + var scheduler = new FsrsFlashcardScheduler(); + var result = scheduler.Review(NewSchedule(), rating, Start, 0); + + var expected = Math.Clamp(W[4] - (Math.Exp(W[5] * (fsrsRatingValue - 1)) - 1), 1, 10); + Assert.Equal(expected, result.Difficulty, precision: 6); + } + + [Fact] + public void Interval_EqualsRoundedNewStability_AtPinnedDesiredRetention() + { + // At desired_retention == 0.9 (the reference retention baked into the FSRS interval + // formula), I(0.9, S) reduces to S regardless of the decay parameter - this holds for + // any state, so it's a strong end-to-end check that both the stability update and the + // interval formula are wired correctly, without hand-deriving decay/factor constants. + var scheduler = new FsrsFlashcardScheduler(); + var schedule = NewSchedule(); + var t = Start; + foreach (var rating in new[] { FlashcardRatings.Good, FlashcardRatings.Good, FlashcardRatings.Good }) + { + var result = scheduler.Review(schedule, rating, t, 0); + schedule = ToSchedule(schedule, result); + t = result.DueUtc; + } + + var final = scheduler.Review(schedule, FlashcardRatings.Good, t, 0); + var intervalDays = (final.DueUtc - t).TotalDays; + Assert.Equal(Math.Round(final.Stability, MidpointRounding.AwayFromZero), Math.Round(intervalDays), precision: 6); + } + + [Fact] + public void MaximumInterval_NeverExceeds36500Days() + { + var scheduler = new FsrsFlashcardScheduler(); + var schedule = NewSchedule(); + schedule.State = FlashcardScheduleStates.Review; + schedule.Stability = 1_000_000; + schedule.Difficulty = 5; + schedule.DueUtc = Start; + schedule.LastReviewUtc = Start.AddDays(-1); + + var result = scheduler.Review(schedule, FlashcardRatings.Good, Start, 0); + + Assert.True((result.DueUtc - Start).TotalDays <= FsrsFlashcardScheduler.MaximumIntervalDays); + } + + [Fact] + public void Again_MovesToRelearning_AndReducesStabilityBelowPriorReview() + { + var scheduler = new FsrsFlashcardScheduler(); + var schedule = NewSchedule(); + var t = Start; + FlashcardScheduleResult result = null!; + foreach (var rating in new[] { FlashcardRatings.Good, FlashcardRatings.Good }) + { + result = scheduler.Review(schedule, rating, t, 0); + schedule = ToSchedule(schedule, result); + t = result.DueUtc; + } + var stabilityBeforeLapse = schedule.Stability; + + var lapsed = scheduler.Review(schedule, FlashcardRatings.Again, t, 0); + + Assert.Equal(FlashcardScheduleStates.Relearning, lapsed.State); + Assert.True(lapsed.Stability < stabilityBeforeLapse); + } + + [Fact] + public void PreviewRatingOptions_ReturnsAllFourRatings_WithNonDecreasingDueDates() + { + var scheduler = new FsrsFlashcardScheduler(); + var schedule = NewSchedule(); + schedule.State = FlashcardScheduleStates.Review; + schedule.Stability = 10; + schedule.Difficulty = 5; + schedule.LastReviewUtc = Start.AddDays(-10); + schedule.DueUtc = Start; + + var options = scheduler.PreviewRatingOptions(schedule, Start); + + Assert.Equal(FlashcardRatings.All.Length, options.Count); + Assert.Equal(FlashcardRatings.All.ToHashSet(), options.Select(o => o.Rating).ToHashSet()); + var again = options.Single(o => o.Rating == FlashcardRatings.Again).DueUtc; + var hard = options.Single(o => o.Rating == FlashcardRatings.Hard).DueUtc; + var good = options.Single(o => o.Rating == FlashcardRatings.Good).DueUtc; + var easy = options.Single(o => o.Rating == FlashcardRatings.Easy).DueUtc; + Assert.True(again <= hard); + Assert.True(hard <= good); + Assert.True(good <= easy); + } + + private static FlashcardSchedule ToSchedule(FlashcardSchedule prior, FlashcardScheduleResult result) => new() + { + UserId = prior.UserId, + NoteId = prior.NoteId, + Variant = prior.Variant, + State = result.State, + Step = result.Step, + Stability = result.Stability, + Difficulty = result.Difficulty, + DueUtc = result.DueUtc, + LastReviewUtc = result.LastReviewUtc + }; +} diff --git a/api/QuestionsApi.Tests/FlashcardSchedulingTests.cs b/api/QuestionsApi.Tests/FlashcardSchedulingTests.cs new file mode 100644 index 0000000..eed51c1 --- /dev/null +++ b/api/QuestionsApi.Tests/FlashcardSchedulingTests.cs @@ -0,0 +1,177 @@ +using QuestionsApi.Services.Flashcards; +using Xunit; + +namespace QuestionsApi.Tests; + +public sealed class FlashcardSchedulingTests +{ + [Fact] + public void NextStudyDayStart_IsMidnightOfTheFollowingDay() + { + var studyDayStart = new DateTime(2026, 3, 5, 0, 0, 0, DateTimeKind.Utc); + + var next = FlashcardScheduling.NextStudyDayStart(studyDayStart); + + Assert.Equal(new DateTime(2026, 3, 6, 0, 0, 0, DateTimeKind.Utc), next); + } + + [Fact] + public void NextStudyDayStart_TruncatesTimeOfDayBeforeAdvancing() + { + // A session that started mid-day still buries siblings until the *next calendar day*, + // not exactly 24h later. + var studyDayStart = new DateTime(2026, 3, 5, 14, 30, 0, DateTimeKind.Utc); + + var next = FlashcardScheduling.NextStudyDayStart(studyDayStart); + + Assert.Equal(new DateTime(2026, 3, 6, 0, 0, 0, DateTimeKind.Utc), next); + } + + [Theory] + [InlineData(20, null, 0, 20)] + [InlineData(20, null, 15, 5)] + [InlineData(20, null, 25, 0)] + [InlineData(20, 5, 0, 5)] + [InlineData(20, 0, 0, 0)] + public void ResolveDailyBudget_DeckOverrideWinsOverGlobal_ClampedAtZero( + int globalLimit, int? deckOverride, int consumedToday, int expected) + { + Assert.Equal(expected, FlashcardScheduling.ResolveDailyBudget(globalLimit, deckOverride, consumedToday)); + } + + private static readonly DateTime Today = new(2026, 3, 10, 0, 0, 0, DateTimeKind.Utc); + + [Fact] + public void ComputeStreakDays_NoReviews_IsZero() + { + Assert.Equal(0, FlashcardScheduling.ComputeStreakDays([], Today)); + } + + [Fact] + public void ComputeStreakDays_ReviewedTodayOnly_IsOne() + { + var timestamps = new List { Today.AddHours(9) }; + + Assert.Equal(1, FlashcardScheduling.ComputeStreakDays(timestamps, Today)); + } + + [Fact] + public void ComputeStreakDays_ThreeConsecutiveDaysEndingToday_IsThree() + { + var timestamps = new List + { + Today.AddDays(-2).AddHours(8), Today.AddDays(-1).AddHours(20), Today.AddHours(7) + }; + + Assert.Equal(3, FlashcardScheduling.ComputeStreakDays(timestamps, Today)); + } + + [Fact] + public void ComputeStreakDays_NotYetReviewedToday_StillCountsThroughYesterday() + { + // The streak shouldn't visibly reset to 0 the instant a new calendar day begins, before + // the user has had a chance to study today. + var timestamps = new List { Today.AddDays(-2).AddHours(8), Today.AddDays(-1).AddHours(20) }; + + Assert.Equal(2, FlashcardScheduling.ComputeStreakDays(timestamps, Today)); + } + + [Fact] + public void ComputeStreakDays_GapBeforeYesterday_BreaksTheStreak() + { + var timestamps = new List + { + Today.AddDays(-10).AddHours(8), Today.AddDays(-1).AddHours(20), Today.AddHours(7) + }; + + Assert.Equal(2, FlashcardScheduling.ComputeStreakDays(timestamps, Today)); + } + + [Fact] + public void ComputeStreakDays_MissedYesterdayAndToday_IsZero() + { + var timestamps = new List { Today.AddDays(-3).AddHours(8) }; + + Assert.Equal(0, FlashcardScheduling.ComputeStreakDays(timestamps, Today)); + } + + [Fact] + public void ComputeStreakDays_MultipleReviewsSameDay_CountOnceTowardTheStreak() + { + var timestamps = new List { Today.AddHours(7), Today.AddHours(8), Today.AddHours(9) }; + + Assert.Equal(1, FlashcardScheduling.ComputeStreakDays(timestamps, Today)); + } + + [Fact] + public void PickIndex_NonShuffle_ReturnsFirstNonExcluded() + { + var pool = new List { "a", "b", "c" }; + + var picked = FlashcardScheduling.PickIndex(pool, new HashSet { "a" }, shuffle: false, n => throw new InvalidOperationException("should not randomize")); + + Assert.Equal(1, picked); + } + + [Fact] + public void PickIndex_NonShuffle_EverythingExcluded_ReturnsNull() + { + var pool = new List { "a", "b" }; + + var picked = FlashcardScheduling.PickIndex(pool, new HashSet { "a", "b" }, shuffle: false, n => 0); + + Assert.Null(picked); + } + + [Fact] + public void PickIndex_EmptyPool_ReturnsNullRegardlessOfShuffle() + { + Assert.Null(FlashcardScheduling.PickIndex([], new HashSet(), shuffle: false, n => 0)); + Assert.Null(FlashcardScheduling.PickIndex([], new HashSet(), shuffle: true, n => 0)); + } + + [Fact] + public void PickIndex_Shuffle_StubReturningFirstCandidate_PicksFirstNonExcluded() + { + var pool = new List { "a", "b", "c" }; + + var picked = FlashcardScheduling.PickIndex(pool, new HashSet { "a" }, shuffle: true, n => 0); + + // Candidates (excluding "a") are ["b", "c"]; stub index 0 -> "b" -> original pool index 1. + Assert.Equal(1, picked); + } + + [Fact] + public void PickIndex_Shuffle_CanReturnSomethingOtherThanTheOldestCandidate() + { + var pool = new List { "a", "b", "c" }; + + // Stub picks the last candidate instead of always the first (oldest), proving this isn't + // hardcoded to FirstOrDefault-style behavior. + var picked = FlashcardScheduling.PickIndex(pool, new HashSet(), shuffle: true, n => n - 1); + + Assert.Equal(2, picked); + } + + [Fact] + public void PickIndex_Shuffle_ExcludedItemsDoNotShiftTheOriginalIndexMapping() + { + var pool = new List { "a", "b", "c", "d" }; + + // Candidates (excluding "b") are ["a", "c", "d"] at original indices [0, 2, 3]; stub + // index 2 should map back to original index 3 ("d"), not 2 ("c"). + var picked = FlashcardScheduling.PickIndex(pool, new HashSet { "b" }, shuffle: true, n => 2); + + Assert.Equal(3, picked); + } + + [Fact] + public void PickIndex_Shuffle_EverythingExcluded_ReturnsNull() + { + var pool = new List { "a", "b" }; + + var picked = FlashcardScheduling.PickIndex(pool, new HashSet { "a", "b" }, shuffle: true, n => 0); + + Assert.Null(picked); + } +} diff --git a/api/QuestionsApi.Tests/QuestionStoreTests.cs b/api/QuestionsApi.Tests/QuestionStoreTests.cs new file mode 100644 index 0000000..88146f8 --- /dev/null +++ b/api/QuestionsApi.Tests/QuestionStoreTests.cs @@ -0,0 +1,49 @@ +using QuestionsApi.Models; +using QuestionsApi.Services; +using Xunit; + +namespace QuestionsApi.Tests; + +public sealed class QuestionStoreTests +{ + [Fact] + public void ApplyMediaReviewGate_FlagsUnflaggedQuestionWithMedia() + { + var question = new Question { Media = ["asset-1"] }; + + QuestionStore.ApplyMediaReviewGate(question); + + Assert.NotNull(question.FlaggedForReviewUtc); + Assert.Equal("system", question.FlaggedByUserId); + Assert.False(string.IsNullOrWhiteSpace(question.FlagReason)); + } + + [Fact] + public void ApplyMediaReviewGate_LeavesMediaFreeQuestionsAlone() + { + var question = new Question { Media = [] }; + + QuestionStore.ApplyMediaReviewGate(question); + + Assert.Null(question.FlaggedForReviewUtc); + } + + [Fact] + public void ApplyMediaReviewGate_DoesNotOverwriteAnExistingFlag() + { + var flaggedAt = DateTime.UtcNow.AddDays(-1); + var question = new Question + { + Media = ["asset-1"], + FlaggedForReviewUtc = flaggedAt, + FlagReason = "Reported by student", + FlaggedByUserId = "user-42" + }; + + QuestionStore.ApplyMediaReviewGate(question); + + Assert.Equal(flaggedAt, question.FlaggedForReviewUtc); + Assert.Equal("Reported by student", question.FlagReason); + Assert.Equal("user-42", question.FlaggedByUserId); + } +} diff --git a/api/QuestionsApi.Tests/QuestionVisibilityTests.cs b/api/QuestionsApi.Tests/QuestionVisibilityTests.cs new file mode 100644 index 0000000..b39965f --- /dev/null +++ b/api/QuestionsApi.Tests/QuestionVisibilityTests.cs @@ -0,0 +1,17 @@ +using QuestionsApi.Services; +using Xunit; + +namespace QuestionsApi.Tests; + +public sealed class QuestionVisibilityTests +{ + [Theory] + [InlineData(true, false, true)] + [InlineData(true, true, false)] + [InlineData(false, false, false)] + [InlineData(false, true, false)] + public void IsVisibleToStudent_MatchesTruthTable(bool isPublished, bool isFlagged, bool expected) + { + Assert.Equal(expected, QuestionVisibility.IsVisibleToStudent(isPublished, isFlagged)); + } +} diff --git a/api/QuestionsApi.Tests/UserProfileModelsTests.cs b/api/QuestionsApi.Tests/UserProfileModelsTests.cs new file mode 100644 index 0000000..4fa0073 --- /dev/null +++ b/api/QuestionsApi.Tests/UserProfileModelsTests.cs @@ -0,0 +1,22 @@ +using QuestionsApi.Models; +using Xunit; + +namespace QuestionsApi.Tests; + +public sealed class UserProfileModelsTests +{ + [Theory] + [InlineData(null, null, true)] + [InlineData(1.0, 1.0, true)] + [InlineData(0.85, 1.35, true)] + [InlineData(0.84, null, false)] + [InlineData(null, 1.36, false)] + public void UpdateTextScaleRequestValidator_EnforcesRange(double? questionScale, double? ccsScale, bool expectedValid) + { + var request = new UpdateTextScaleRequest { QuestionTextScale = questionScale, CcsTextScale = ccsScale }; + + var error = UpdateTextScaleRequestValidator.Validate(request); + + Assert.Equal(expectedValid, error is null); + } +} diff --git a/api/QuestionsApi/Controllers/CcsController.cs b/api/QuestionsApi/Controllers/CcsController.cs index 2e6c76f..56e4ada 100644 --- a/api/QuestionsApi/Controllers/CcsController.cs +++ b/api/QuestionsApi/Controllers/CcsController.cs @@ -51,6 +51,16 @@ public sealed class CcsController(CcsStore store, CcsRatingStore ratings) : Cont catch (InvalidOperationException exception) { return Conflict(exception.Message); } } + [HttpPost("attempts/{id}/commands/batch")] + public async Task BatchCommand(string id, CcsBatchCommandRequest request) + { + var attempt = await store.GetOwnedAsync(id, UserId()); + if (attempt is null) return NotFound(); + try { return Ok(await store.ToResponseAsync(await store.ApplyBatchCommandAsync(attempt, request))); } + catch (ArgumentException exception) { return BadRequest(exception.Message); } + catch (InvalidOperationException exception) { return Conflict(exception.Message); } + } + [HttpPost("attempts/{id}/abandon")] public async Task Abandon(string id) { diff --git a/api/QuestionsApi/Controllers/DynamicQuestionsController.cs b/api/QuestionsApi/Controllers/DynamicQuestionsController.cs new file mode 100644 index 0000000..fd577b9 --- /dev/null +++ b/api/QuestionsApi/Controllers/DynamicQuestionsController.cs @@ -0,0 +1,68 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using QuestionsApi.Models; +using QuestionsApi.Services; +using System.Security.Claims; + +namespace QuestionsApi.Controllers; + +[ApiController, Route("api/dynamic-questions"), Authorize(Roles = "USER,SUPER")] +public sealed class DynamicQuestionsController : ControllerBase +{ + private readonly DynamicQuestionService _questions; + private readonly QuestionReportStore _reports; + + public DynamicQuestionsController(DynamicQuestionService questions, QuestionReportStore reports) + { + _questions = questions; + _reports = reports; + } + + [HttpGet("status")] + public async Task> Status() => + Ok(await _questions.StatusAsync(UserId())); + + [HttpPost("next")] + public async Task> Next([FromBody] DynamicNextRequest request) + { + try { return Ok(await _questions.NextAsync(UserId(), request.RunToken)); } + catch (ArgumentException exception) { return BadRequest(exception.Message); } + catch (InvalidOperationException exception) { return Conflict(exception.Message); } + } + + [HttpPost("attempt")] + public async Task> Attempt([FromBody] DynamicAttemptRequest request) + { + if (string.IsNullOrWhiteSpace(request.GenerationToken)) + return BadRequest("generationToken required"); + try { return Ok(await _questions.AttemptAsync(UserId(), request)); } + catch (ArgumentException exception) { return BadRequest(exception.Message); } + catch (KeyNotFoundException) { return NotFound(); } + catch (InvalidOperationException exception) { return Conflict(exception.Message); } + } + + [HttpPost("report")] + public async Task Report([FromBody] DynamicReportRequest request) + { + var category = request.Category.Trim().ToLowerInvariant(); + var details = request.Details.Trim(); + if (!QuestionReportCategory.All.Contains(category)) + return BadRequest("category is invalid"); + if (details.Length is < 10 or > 2000) + return BadRequest("details must be between 10 and 2000 characters"); + try + { + var (claims, _) = await _questions.RegenerateAsync(UserId(), request.GenerationToken); + var report = await _reports.CreateDynamicAsync(claims, UserId(), category, details); + return StatusCode(StatusCodes.Status201Created, new { id = report.Id, questionExternalId = report.QuestionExternalId }); + } + catch (ArgumentException exception) { return BadRequest(exception.Message); } + catch (KeyNotFoundException) { return NotFound(); } + } + + private string UserId() => + User.FindFirstValue(ClaimTypes.NameIdentifier) + ?? User.FindFirstValue(ClaimTypes.Name) + ?? User.Identity?.Name + ?? ""; +} diff --git a/api/QuestionsApi/Controllers/FlashcardsController.cs b/api/QuestionsApi/Controllers/FlashcardsController.cs new file mode 100644 index 0000000..4670864 --- /dev/null +++ b/api/QuestionsApi/Controllers/FlashcardsController.cs @@ -0,0 +1,415 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using QuestionsApi.Models; +using QuestionsApi.Services.Flashcards; +using System.Security.Claims; +using System.Text; + +namespace QuestionsApi.Controllers; + +[ApiController] +[Route("api/flashcards")] +[Authorize(Roles = "USER,SUPER")] +public sealed class FlashcardsController( + FlashcardDeckStore decks, + FlashcardNoteStore notes, + FlashcardScheduleStore schedules, + FlashcardSettingsStore settings, + FlashcardMediaStore media, + FlashcardStudyService study, + FlashcardImportExportService importExport) : ControllerBase +{ + [HttpGet("decks")] + public async Task ListDecks() + { + var userId = UserId(); + if (string.IsNullOrWhiteSpace(userId)) return Unauthorized(); + await decks.GetOrCreateDefaultDeckAsync(userId); + var now = DateTime.UtcNow; + var summaries = await study.GetDeckSummariesAsync(userId, now, now.Date); + return Ok(summaries); + } + + [HttpPost("decks")] + public async Task CreateDeck([FromBody] UpsertFlashcardDeckRequest request) + { + var userId = UserId(); + if (string.IsNullOrWhiteSpace(userId)) return Unauthorized(); + if (string.IsNullOrWhiteSpace(request.Name)) return BadRequest("name required"); + var result = await decks.UpsertAsync(userId, request, null); + if (!result.Succeeded) return Conflict(result.Current); + return Ok(result.Deck); + } + + [HttpPut("decks/{deckId}")] + public async Task UpdateDeck(string deckId, [FromBody] UpsertFlashcardDeckRequest request) + { + var userId = UserId(); + if (string.IsNullOrWhiteSpace(userId)) return Unauthorized(); + if (string.IsNullOrWhiteSpace(request.Name)) return BadRequest("name required"); + if (!FlashcardOwnershipGuard.CanMutate(User.IsInRole("SUPER"), (await decks.GetAnyAsync(deckId))?.IsSystem == true)) + return NotFound(); + var result = await decks.UpsertAsync(userId, request, deckId); + if (!result.Succeeded) return Conflict(result.Current); + return Ok(result.Deck); + } + + [HttpPost("decks/{deckId}/archive")] + public async Task ArchiveDeck(string deckId) + { + var userId = UserId(); + if (string.IsNullOrWhiteSpace(userId)) return Unauthorized(); + if (!FlashcardOwnershipGuard.CanMutate(User.IsInRole("SUPER"), (await decks.GetAnyAsync(deckId))?.IsSystem == true)) + return NotFound(); + var result = await decks.ArchiveAsync(userId, deckId, true); + if (!result.Succeeded) return Conflict("cannot archive the default deck, or deck not found"); + return Ok(result.Deck); + } + + [HttpPost("decks/{deckId}/restore")] + public async Task RestoreDeck(string deckId) + { + var userId = UserId(); + if (string.IsNullOrWhiteSpace(userId)) return Unauthorized(); + if (!FlashcardOwnershipGuard.CanMutate(User.IsInRole("SUPER"), (await decks.GetAnyAsync(deckId))?.IsSystem == true)) + return NotFound(); + var result = await decks.ArchiveAsync(userId, deckId, false); + if (!result.Succeeded) return NotFound(); + return Ok(result.Deck); + } + + [HttpPost("notes/search")] + public async Task SearchNotes([FromBody] FlashcardLibraryQuery query) + { + var userId = UserId(); + if (string.IsNullOrWhiteSpace(userId)) return Unauthorized(); + var (items, total) = await notes.SearchVisibleAsync(userId, query); + return Ok(new FlashcardLibraryResponse { Items = items, Total = total }); + } + + [HttpGet("notes/{noteId}")] + public async Task GetNote(string noteId) + { + var userId = UserId(); + if (string.IsNullOrWhiteSpace(userId)) return Unauthorized(); + var note = await notes.GetVisibleAsync(userId, noteId); + if (note is null) return NotFound(); + return Ok(note); + } + + [HttpPost("notes")] + public async Task CreateNote([FromBody] UpsertFlashcardNoteRequest request) + { + var userId = UserId(); + if (string.IsNullOrWhiteSpace(userId)) return Unauthorized(); + if (string.IsNullOrWhiteSpace(request.Prompt)) return BadRequest("prompt required"); + if (string.IsNullOrWhiteSpace(request.DeckId)) + request.DeckId = (await decks.GetOrCreateDefaultDeckAsync(userId)).Id!; + else if (!FlashcardOwnershipGuard.CanMutate(User.IsInRole("SUPER"), (await decks.GetAnyAsync(request.DeckId))?.IsSystem == true)) + return BadRequest("cannot add cards to a system deck"); + + try + { + var result = await notes.UpsertAsync(userId, request, null); + if (!result.Succeeded) return Conflict(result.Current); + return Ok(result.Note); + } + catch (FlashcardValidationException exception) + { + return BadRequest(exception.Message); + } + } + + [HttpPut("notes/{noteId}")] + public async Task UpdateNote(string noteId, [FromBody] UpsertFlashcardNoteRequest request) + { + var userId = UserId(); + if (string.IsNullOrWhiteSpace(userId)) return Unauthorized(); + if (string.IsNullOrWhiteSpace(request.Prompt)) return BadRequest("prompt required"); + var isSuper = User.IsInRole("SUPER"); + if (!FlashcardOwnershipGuard.CanMutate(isSuper, (await notes.GetAnyAsync(noteId))?.IsSystem == true)) + return NotFound(); + if (!string.IsNullOrWhiteSpace(request.DeckId) + && !FlashcardOwnershipGuard.CanMutate(isSuper, (await decks.GetAnyAsync(request.DeckId))?.IsSystem == true)) + return BadRequest("cannot move cards into a system deck"); + + try + { + var result = await notes.UpsertAsync(userId, request, noteId); + if (!result.Succeeded) return Conflict(result.Current); + return Ok(result.Note); + } + catch (FlashcardValidationException exception) + { + return BadRequest(exception.Message); + } + } + + [HttpPost("notes/move")] + public async Task MoveNotes([FromBody] FlashcardMoveRequest request) + { + var userId = UserId(); + if (string.IsNullOrWhiteSpace(userId)) return Unauthorized(); + if (string.IsNullOrWhiteSpace(request.TargetDeckId)) return BadRequest("targetDeckId required"); + if (!FlashcardOwnershipGuard.CanMutate(User.IsInRole("SUPER"), (await decks.GetAnyAsync(request.TargetDeckId))?.IsSystem == true)) + return BadRequest("cannot move cards into a system deck"); + var moved = await notes.MoveAsync(userId, request.NoteIds, request.TargetDeckId); + return Ok(new { moved }); + } + + [HttpPost("notes/archive")] + public async Task ArchiveNotes([FromBody] FlashcardBulkNoteIdsRequest request) + { + var userId = UserId(); + if (string.IsNullOrWhiteSpace(userId)) return Unauthorized(); + var isSuper = User.IsInRole("SUPER"); + var results = new List(); + foreach (var noteId in request.NoteIds) + { + if (!FlashcardOwnershipGuard.CanMutate(isSuper, (await notes.GetAnyAsync(noteId))?.IsSystem == true)) continue; + var result = await notes.ArchiveAsync(userId, noteId, true); + if (result.Note is not null) results.Add(result.Note); + } + return Ok(results); + } + + [HttpPost("notes/restore")] + public async Task RestoreNotes([FromBody] FlashcardBulkNoteIdsRequest request) + { + var userId = UserId(); + if (string.IsNullOrWhiteSpace(userId)) return Unauthorized(); + var isSuper = User.IsInRole("SUPER"); + var results = new List(); + foreach (var noteId in request.NoteIds) + { + if (!FlashcardOwnershipGuard.CanMutate(isSuper, (await notes.GetAnyAsync(noteId))?.IsSystem == true)) continue; + var result = await notes.ArchiveAsync(userId, noteId, false); + if (result.Note is not null) results.Add(result.Note); + } + return Ok(results); + } + + [HttpPost("cards/suspend")] + public async Task SuspendCards([FromBody] FlashcardSuspendRequest request) + { + var userId = UserId(); + if (string.IsNullOrWhiteSpace(userId)) return Unauthorized(); + var changed = await schedules.SetSuspendedAsync(userId, request.CardIds, request.Suspended); + return Ok(new { changed }); + } + + [HttpPost("cards/bury")] + public async Task BuryCards([FromBody] FlashcardBuryRequest request) + { + var userId = UserId(); + if (string.IsNullOrWhiteSpace(userId)) return Unauthorized(); + var until = request.UntilUtc == default ? DateTime.UtcNow.Date.AddDays(1) : request.UntilUtc; + var changed = await schedules.BuryAsync(userId, request.CardIds, until); + return Ok(new { changed }); + } + + [HttpPost("study/next")] + public async Task NextCard([FromBody] FlashcardStudyRequest request) + { + var userId = UserId(); + if (string.IsNullOrWhiteSpace(userId)) return Unauthorized(); + var response = await study.GetNextCardAsync(userId, request); + return Ok(response); + } + + [HttpGet("counts")] + public async Task GetCounts() + { + var userId = UserId(); + if (string.IsNullOrWhiteSpace(userId)) return Unauthorized(); + var now = DateTime.UtcNow; + var counts = await study.GetDashboardCountsAsync(userId, now, now.Date.AddDays(-6)); + return Ok(counts); + } + + [HttpGet("settings")] + public async Task GetSettings() + { + var userId = UserId(); + if (string.IsNullOrWhiteSpace(userId)) return Unauthorized(); + return Ok(await settings.GetAsync(userId)); + } + + [HttpPut("settings")] + public async Task UpdateSettings([FromBody] UpdateFlashcardSettingsRequest request) + { + var userId = UserId(); + if (string.IsNullOrWhiteSpace(userId)) return Unauthorized(); + return Ok(await settings.UpdateAsync(userId, request)); + } + + [HttpPost("reviews")] + public async Task SubmitReviews([FromBody] FlashcardBatchReviewRequest request) + { + var userId = UserId(); + if (string.IsNullOrWhiteSpace(userId)) return Unauthorized(); + + var results = new List(); + foreach (var review in request.Reviews) + { + if (!Guid.TryParse(review.ClientReviewId, out _)) return BadRequest("clientReviewId must be a UUID"); + if (!FlashcardRatings.All.Contains(review.Rating)) return BadRequest("rating must be again/hard/good/easy"); + + var outcome = await schedules.SubmitReviewAsync(userId, review); + switch (outcome.Kind) + { + case FlashcardReviewOutcomeKind.NotFound: + return NotFound($"card {review.CardId} not found"); + case FlashcardReviewOutcomeKind.Conflict: + return Conflict(new { cardId = review.CardId, current = outcome.Schedule }); + case FlashcardReviewOutcomeKind.Duplicate: + results.Add(new FlashcardReviewResponse { Card = outcome.Schedule!, Duplicate = true }); + break; + default: + results.Add(new FlashcardReviewResponse + { + Card = outcome.Schedule!, + RatingOptions = schedules.PreviewRatingOptions(outcome.Schedule!, DateTime.UtcNow) + }); + break; + } + } + return Ok(new FlashcardBatchReviewResponse { Results = results }); + } + + [HttpPost("reviews/undo")] + public async Task UndoReview([FromBody] FlashcardUndoRequest request) + { + var userId = UserId(); + if (string.IsNullOrWhiteSpace(userId)) return Unauthorized(); + var outcome = await schedules.UndoAsync(userId, request.ReviewLogId); + return outcome.Kind switch + { + FlashcardReviewOutcomeKind.NotFound => NotFound(), + FlashcardReviewOutcomeKind.Conflict => Conflict(outcome.Schedule), + _ => Ok(outcome.Schedule) + }; + } + + [HttpPost("media")] + [RequestSizeLimit(5_000_000)] + public async Task UploadMedia(IFormFile file) + { + var userId = UserId(); + if (string.IsNullOrWhiteSpace(userId)) return Unauthorized(); + if (file is null || file.Length == 0) return BadRequest("file required"); + if (file.Length > 5_000_000) return BadRequest("file must be 5 MB or smaller"); + + var contentType = file.ContentType.Trim().ToLowerInvariant(); + var allowedTypes = new HashSet { "image/png", "image/jpeg", "image/webp" }; + if (!allowedTypes.Contains(contentType)) return BadRequest("file must be a png, jpeg, or webp image"); + + await using var stream = file.OpenReadStream(); + using var memory = new MemoryStream(); + await stream.CopyToAsync(memory); + + var asset = await media.CreateAsync(userId, file.FileName, contentType, memory.ToArray()); + return Ok(new { id = asset.Id ?? "", url = $"/api/flashcards/media/{asset.Id}" }); + } + + [HttpGet("media/{id}")] + public async Task GetMedia(string id) + { + var userId = UserId(); + if (string.IsNullOrWhiteSpace(userId)) return Unauthorized(); + var asset = await media.GetAsync(userId, id.Trim()); + if (asset is null) return NotFound(); + return File(asset.Bytes, asset.ContentType); + } + + [HttpGet("export/csv")] + public async Task ExportCsv() + { + var userId = UserId(); + if (string.IsNullOrWhiteSpace(userId)) return Unauthorized(); + var (csv, omissions) = await importExport.ExportCsvAsync(userId); + Response.Headers.Append("X-Flashcard-Export-Omissions", string.Join(';', omissions.Select(v => v.Kind))); + return File(Encoding.UTF8.GetBytes(csv), "text/csv", "flashcards.csv"); + } + + [HttpGet("export/json")] + public async Task ExportJson() + { + var userId = UserId(); + if (string.IsNullOrWhiteSpace(userId)) return Unauthorized(); + var export = await importExport.ExportJsonAsync(userId); + return Ok(export); + } + + [HttpPost("import/csv/preview")] + [RequestSizeLimit(25 * 1024 * 1024)] + public async Task PreviewCsvImport(IFormFile file) + { + var userId = UserId(); + if (string.IsNullOrWhiteSpace(userId)) return Unauthorized(); + if (file is null || file.Length == 0) return BadRequest("file required"); + try + { + FlashcardImportExportService.ValidateSize(file.Length); + using var reader = new StreamReader(file.OpenReadStream(), Encoding.UTF8); + var text = await reader.ReadToEndAsync(); + return Ok(await importExport.PreviewCsvAsync(userId, text)); + } + catch (FlashcardValidationException exception) + { + return BadRequest(exception.Message); + } + } + + [HttpPost("import/json/preview")] + [RequestSizeLimit(25 * 1024 * 1024)] + public async Task PreviewJsonImport(IFormFile file) + { + var userId = UserId(); + if (string.IsNullOrWhiteSpace(userId)) return Unauthorized(); + if (file is null || file.Length == 0) return BadRequest("file required"); + try + { + FlashcardImportExportService.ValidateSize(file.Length); + using var reader = new StreamReader(file.OpenReadStream(), Encoding.UTF8); + var text = await reader.ReadToEndAsync(); + return Ok(await importExport.PreviewJsonAsync(userId, text)); + } + catch (FlashcardValidationException exception) + { + return BadRequest(exception.Message); + } + } + + [HttpPost("import/commit")] + public async Task CommitImport([FromBody] FlashcardImportCommitRequest request) + { + var userId = UserId(); + if (string.IsNullOrWhiteSpace(userId)) return Unauthorized(); + try + { + return Ok(await importExport.CommitAsync(userId, request.ImportToken)); + } + catch (FlashcardValidationException exception) + { + return BadRequest(exception.Message); + } + } + + private string UserId() => User.FindFirstValue(ClaimTypes.NameIdentifier) + ?? User.FindFirstValue(ClaimTypes.Name) + ?? User.Identity?.Name + ?? ""; +} + +public sealed class FlashcardSuspendRequest +{ + public List CardIds { get; set; } = []; + public bool Suspended { get; set; } = true; +} + +public sealed class FlashcardBuryRequest +{ + public List CardIds { get; set; } = []; + public DateTime UntilUtc { get; set; } +} diff --git a/api/QuestionsApi/Controllers/ProfileController.cs b/api/QuestionsApi/Controllers/ProfileController.cs index 0adef3c..5da4a6b 100644 --- a/api/QuestionsApi/Controllers/ProfileController.cs +++ b/api/QuestionsApi/Controllers/ProfileController.cs @@ -27,6 +27,14 @@ public sealed class ProfileController(UserProfileStore profiles, IConfiguration return Ok(await profiles.UpsertAsync(UserId(), request)); } + [HttpPut("me/text-scale")] + [Authorize(Roles = "USER,SUPER")] + public async Task UpdateTextScale(UpdateTextScaleRequest request) + { + if (UpdateTextScaleRequestValidator.Validate(request) is { } error) return BadRequest(error); + return Ok(await profiles.UpdateTextScaleAsync(UserId(), request)); + } + [HttpGet("me/export")] [Authorize(Roles = "USER,SUPER")] public async Task Export() => Ok(new diff --git a/api/QuestionsApi/Controllers/QuestionReportsController.cs b/api/QuestionsApi/Controllers/QuestionReportsController.cs index f202fef..09c2373 100644 --- a/api/QuestionsApi/Controllers/QuestionReportsController.cs +++ b/api/QuestionsApi/Controllers/QuestionReportsController.cs @@ -39,7 +39,7 @@ public class QuestionReportsController : ControllerBase return BadRequest($"details must be between {MinDetailsLength} and {MaxDetailsLength} characters"); var question = await _questions.GetByExternalIdAsync(questionExternalId); - if (question is null || (!question.IsPublished && !User.IsInRole("SUPER"))) + if (question is null || (!QuestionVisibility.IsVisibleToStudent(question.IsPublished, question.FlaggedForReviewUtc != null) && !User.IsInRole("SUPER"))) return NotFound(); var userId = CurrentUserId(); diff --git a/api/QuestionsApi/Controllers/QuestionsController.cs b/api/QuestionsApi/Controllers/QuestionsController.cs index 0116d2b..defbb92 100644 --- a/api/QuestionsApi/Controllers/QuestionsController.cs +++ b/api/QuestionsApi/Controllers/QuestionsController.cs @@ -94,7 +94,7 @@ public class QuestionsController : ControllerBase public async Task Get(string id) { var question = await _questions.GetByIdAsync(id); - if (question is null || (!question.IsPublished && !User.IsInRole("SUPER"))) + if (question is null || (!QuestionVisibility.IsVisibleToStudent(question.IsPublished, question.FlaggedForReviewUtc != null) && !User.IsInRole("SUPER"))) return NotFound(); var percentages = await _statistics.GetPercentagesForQuestionAsync(question.ExternalId); @@ -119,7 +119,7 @@ public class QuestionsController : ControllerBase return BadRequest("choiceId required"); var question = await _questions.GetByExternalIdAsync(req.QuestionExternalId.Trim()); - if (question is null || (!question.IsPublished && !User.IsInRole("SUPER"))) + if (question is null || (!QuestionVisibility.IsVisibleToStudent(question.IsPublished, question.FlaggedForReviewUtc != null) && !User.IsInRole("SUPER"))) return NotFound(); if (!question.Choices.Any(c => c.Id == req.ChoiceId.Trim())) return BadRequest("choiceId must match a choice id"); @@ -258,7 +258,7 @@ public class QuestionsController : ControllerBase return BadRequest("externalId required"); var question = await _questions.GetByExternalIdAsync(externalId.Trim()); - if (question is null || (!question.IsPublished && !User.IsInRole("SUPER"))) + if (question is null || (!QuestionVisibility.IsVisibleToStudent(question.IsPublished, question.FlaggedForReviewUtc != null) && !User.IsInRole("SUPER"))) return NotFound(); var userId = CurrentUserId(); @@ -404,6 +404,37 @@ public class QuestionsController : ControllerBase return Ok("Deleted"); } + [HttpPost("{id}/flag")] + [Authorize(Roles = "SUPER")] + public async Task Flag(string id, [FromBody] FlagQuestionRequest req) + { + if (string.IsNullOrWhiteSpace(req.Reason)) + return BadRequest("reason required"); + + var flagged = await _questions.FlagAsync(id, req.Reason.Trim(), CurrentUserId()); + if (flagged is null) + return NotFound(); + return Ok(QuestionFlagResponse.FromModel(flagged)); + } + + [HttpPost("{id}/unflag")] + [Authorize(Roles = "SUPER")] + public async Task Unflag(string id) + { + var unflagged = await _questions.UnflagAsync(id); + if (unflagged is null) + return NotFound(); + return Ok(QuestionFlagResponse.FromModel(unflagged)); + } + + [HttpGet("admin/flagged")] + [Authorize(Roles = "SUPER")] + public async Task Flagged() + { + var flagged = await _questions.GetFlaggedAsync(); + return Ok(flagged.Select(QuestionFlagResponse.FromModel)); + } + private string CurrentUserId() => User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue(ClaimTypes.Name) diff --git a/api/QuestionsApi/Dockerfile b/api/QuestionsApi/Dockerfile index 91831aa..b35494d 100644 --- a/api/QuestionsApi/Dockerfile +++ b/api/QuestionsApi/Dockerfile @@ -4,16 +4,19 @@ FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build ARG BUILD_REVISION WORKDIR /src -COPY ["QuestionsApi.csproj", "./"] -RUN dotnet restore "QuestionsApi.csproj" +COPY ["api/QuestionsApi/QuestionsApi.csproj", "api/QuestionsApi/"] +RUN dotnet restore "api/QuestionsApi/QuestionsApi.csproj" -COPY . . +COPY api/QuestionsApi/ api/QuestionsApi/ +COPY content/dynamic_questions/ content/dynamic_questions/ +WORKDIR /src/api/QuestionsApi RUN dotnet publish "QuestionsApi.csproj" -c Release -o /app/publish /p:UseAppHost=false /p:SourceRevisionId=${BUILD_REVISION} FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS final ARG BUILD_REVISION WORKDIR /app COPY --from=build /app/publish . +COPY --from=build /src/content/dynamic_questions ./content/dynamic_questions ENV ASPNETCORE_URLS=http://+:5008 \ ASPNETCORE_ENVIRONMENT=Production \ diff --git a/api/QuestionsApi/Models/CcsModels.cs b/api/QuestionsApi/Models/CcsModels.cs index f920171..bc2f72d 100644 --- a/api/QuestionsApi/Models/CcsModels.cs +++ b/api/QuestionsApi/Models/CcsModels.cs @@ -17,6 +17,26 @@ public static class CcsAttemptModes public const string Timed = "timed"; } +public static class CcsChartCategories +{ + public const string Orders = "orders"; + public const string Progress = "progress"; + public const string Vitals = "vitals"; + public const string Lab = "lab"; + public const string Imaging = "imaging"; + public const string OtherTest = "other_test"; + public const string Treatment = "treatment"; + + public static readonly IReadOnlySet All = new HashSet(StringComparer.OrdinalIgnoreCase) + { + Orders, Progress, Vitals, Lab, Imaging, OtherTest, Treatment + }; + public static readonly IReadOnlySet ResultSections = new HashSet(StringComparer.OrdinalIgnoreCase) + { + Lab, Imaging, OtherTest, Treatment + }; +} + public class CcsOrderDefinition { [BsonId] public string Code { get; set; } = ""; @@ -25,6 +45,9 @@ public class CcsOrderDefinition public List Aliases { get; set; } = []; public int DefaultTurnaroundMinutes { get; set; } public List AvailableLocations { get; set; } = []; + public string DisplayRoute { get; set; } = ""; + public string DisplayFrequency { get; set; } = ""; + public string ResultSection { get; set; } = ""; public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow; } @@ -57,11 +80,19 @@ public class CcsInitialChart public string ReasonForEncounter { get; set; } = ""; public string Appearance { get; set; } = ""; public string History { get; set; } = ""; + public List HistorySections { get; set; } = []; public Dictionary Vitals { get; set; } = []; public DateTime SimulatedStartUtc { get; set; } = DateTime.UtcNow; public List InitialOrderCodes { get; set; } = []; } +public class CcsNarrativeSection +{ + public string Code { get; set; } = ""; + public string Title { get; set; } = ""; + public string Text { get; set; } = ""; +} + public class CcsExamDefinition { public string Code { get; set; } = ""; @@ -75,6 +106,7 @@ public class CcsCaseOrderDefinition public string OrderCode { get; set; } = ""; public int? TurnaroundMinutes { get; set; } public string Result { get; set; } = ""; + public string ResultSection { get; set; } = ""; public Dictionary VitalPatches { get; set; } = []; } @@ -111,19 +143,22 @@ public class CcsAttempt public string CaseExternalId { get; set; } = ""; [BsonRepresentation(BsonType.ObjectId)] public string CaseRevisionId { get; set; } = ""; public int CaseVersion { get; set; } - public string Mode { get; set; } = CcsAttemptModes.Practice; + public string Mode { get; set; } = CcsAttemptModes.Timed; public string State { get; set; } = CcsAttemptStates.Active; public int Revision { get; set; } public int SimulatedMinute { get; set; } + [BsonIgnoreIfNull] public bool? OpeningCompleted { get; set; } public string Location { get; set; } = ""; public Dictionary CurrentVitals { get; set; } = []; public List Actions { get; set; } = []; public List PendingResults { get; set; } = []; public List Chart { get; set; } = []; + public List VitalHistory { get; set; } = []; public List ActiveOrderCodes { get; set; } = []; public List ProcessedEventIds { get; set; } = []; public List ProcessedCommandIds { get; set; } = []; public DateTime? DeadlineUtc { get; set; } + public DateTime? HardDeadlineUtc { get; set; } public DateTime? FinalOrdersDeadlineUtc { get; set; } public DateTime CreatedUtc { get; set; } = DateTime.UtcNow; public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow; @@ -144,6 +179,8 @@ public class CcsAttemptAction public class CcsPendingResult { public string OrderCode { get; set; } = ""; + public string Category { get; set; } = ""; + public string Title { get; set; } = ""; public int DueMinute { get; set; } public string Result { get; set; } = ""; public Dictionary VitalPatches { get; set; } = []; @@ -153,9 +190,30 @@ public class CcsChartEntry { public string Category { get; set; } = "progress"; public int SimulatedMinute { get; set; } + public string Code { get; set; } = ""; + public string Title { get; set; } = ""; public string Text { get; set; } = ""; } +public class CcsVitalSnapshot +{ + public int SimulatedMinute { get; set; } + public Dictionary Values { get; set; } = []; +} + +public class CcsActiveOrder +{ + public string Code { get; set; } = ""; + public string Name { get; set; } = ""; + public string Category { get; set; } = ""; + public string Route { get; set; } = ""; + public string Frequency { get; set; } = ""; + public int OrderedMinute { get; set; } + public int? ReportMinute { get; set; } + public bool IsPending { get; set; } + public bool HasResult { get; set; } +} + public class CcsCaseScore { public double Percent { get; set; } @@ -194,7 +252,7 @@ public class CcsImportResponse public class CreateCcsAttemptRequest { public string CaseExternalId { get; set; } = ""; - public string Mode { get; set; } = CcsAttemptModes.Practice; + public string Mode { get; set; } = CcsAttemptModes.Timed; } public class CcsCommandRequest @@ -206,6 +264,20 @@ public class CcsCommandRequest public int Minutes { get; set; } } +public class CcsBatchCommandRequest +{ + public string CommandId { get; set; } = ""; + public int ExpectedRevision { get; set; } + public List Commands { get; set; } = []; +} + +public class CcsBatchCommandItem +{ + public string Type { get; set; } = ""; + public string Code { get; set; } = ""; + public int Minutes { get; set; } +} + public class CcsCaseSummary { public string ExternalId { get; set; } = ""; @@ -226,8 +298,11 @@ public class CcsAttemptResponse public string State { get; set; } = ""; public int Revision { get; set; } public int SimulatedMinute { get; set; } + public DateTime SimulatedStartUtc { get; set; } + public bool OpeningCompleted { get; set; } public string Location { get; set; } = ""; public DateTime? DeadlineUtc { get; set; } + public DateTime? HardDeadlineUtc { get; set; } public DateTime? FinalOrdersDeadlineUtc { get; set; } public DateTime CreatedUtc { get; set; } public int RealTimeMinutes { get; set; } @@ -236,9 +311,12 @@ public class CcsAttemptResponse public string ReasonForEncounter { get; set; } = ""; public string Appearance { get; set; } = ""; public string History { get; set; } = ""; + public List HistorySections { get; set; } = []; public Dictionary Vitals { get; set; } = []; public List Chart { get; set; } = []; public List ActiveOrderCodes { get; set; } = []; + public List ActiveOrders { get; set; } = []; + public List VitalHistory { get; set; } = []; public List PendingResults { get; set; } = []; public List AvailableExams { get; set; } = []; public List AllowedLocations { get; set; } = []; diff --git a/api/QuestionsApi/Models/DashboardModels.cs b/api/QuestionsApi/Models/DashboardModels.cs index a6bad14..43f8508 100644 --- a/api/QuestionsApi/Models/DashboardModels.cs +++ b/api/QuestionsApi/Models/DashboardModels.cs @@ -14,8 +14,12 @@ public sealed class DashboardResponse public int StudyTimeSecondsThisWeek { get; set; } public int CurrentStreak { get; set; } public int MissedQuestionCount { get; set; } + public int DynamicReviewsDue { get; set; } public int BookmarkCount { get; set; } public int NoteCount { get; set; } + public int FlashcardsDue { get; set; } + public int FlashcardsNewAvailable { get; set; } + public int FlashcardsReviewedThisWeek { get; set; } public List PerformanceTrend { get; set; } = []; public List Systems { get; set; } = []; public List WeeklyActivity { get; set; } = []; diff --git a/api/QuestionsApi/Models/DynamicQuestionModels.cs b/api/QuestionsApi/Models/DynamicQuestionModels.cs new file mode 100644 index 0000000..0b79ef5 --- /dev/null +++ b/api/QuestionsApi/Models/DynamicQuestionModels.cs @@ -0,0 +1,319 @@ +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; + +namespace QuestionsApi.Models; + +public sealed class DynamicQuestionBlueprint +{ + [BsonId] + [BsonRepresentation(BsonType.ObjectId)] + public string? Id { get; set; } + public string ExternalId { get; set; } = ""; + public int Version { get; set; } = 1; + public string ObjectiveId { get; set; } = ""; + public string Title { get; set; } = ""; + public string System { get; set; } = ""; + public string Discipline { get; set; } = ""; + public string PerformanceCategory { get; set; } = ""; + public string PhysicianTask { get; set; } = ""; + public string EncounterFrame { get; set; } = ""; + public string SiteOfCare { get; set; } = ""; + public double BlueprintWeight { get; set; } + public List ConceptIds { get; set; } = []; + public List PrerequisiteConceptIds { get; set; } = []; + public DateTime? MedicallyReviewedUtc { get; set; } + public DateTime? MedicalReviewExpiresUtc { get; set; } + public string MedicalReviewer { get; set; } = ""; + public int Difficulty { get; set; } = 3; + public int QualityStandardVersion { get; set; } = 1; + public string CorrectConceptId { get; set; } = ""; + public int BaseBlueprintVersion { get; set; } + public bool IsPublished { get; set; } = true; + public string StemTemplate { get; set; } = ""; + public string LeadIn { get; set; } = ""; + public Dictionary Variables { get; set; } = []; + public Dictionary Derived { get; set; } = []; + public List Constraints { get; set; } = []; + public List Labs { get; set; } = []; + public DynamicAnswerTemplate CorrectAnswer { get; set; } = new(); + public List Distractors { get; set; } = []; + public string ExplanationTemplate { get; set; } = ""; + public string KeyTakeaway { get; set; } = ""; + public string UsmlePearl { get; set; } = ""; + public List References { get; set; } = []; + public List Tags { get; set; } = []; + public List ReviewFollowups { get; set; } = []; + public Dictionary WrongAnswerFollowups { get; set; } = []; + public List CaseFamilies { get; set; } = []; +} + +public sealed class DynamicCaseFamily +{ + public string Id { get; set; } = ""; + public string BaseFamilyId { get; set; } = ""; + public string DifficultyTier { get; set; } = "standard"; + public int NominalElo { get; set; } = 1050; + public string StemTemplate { get; set; } = ""; + public string LeadIn { get; set; } = ""; + public Dictionary Variables { get; set; } = []; + public List> ValueBundles { get; set; } = []; + public Dictionary Derived { get; set; } = []; + public List Constraints { get; set; } = []; + public List Labs { get; set; } = []; + public DynamicAnswerTemplate CorrectAnswer { get; set; } = new(); + public List Distractors { get; set; } = []; + public string ExplanationTemplate { get; set; } = ""; + public string KeyTakeaway { get; set; } = ""; + public string UsmlePearl { get; set; } = ""; + public List ReviewFollowups { get; set; } = []; + public Dictionary WrongAnswerFollowups { get; set; } = []; +} + +public sealed class DynamicReviewFollowupTemplate +{ + public string PromptTemplate { get; set; } = ""; + public List Choices { get; set; } = []; + public string CorrectChoiceId { get; set; } = ""; + public string TeachingPointTemplate { get; set; } = ""; +} + +public sealed class DynamicWrongAnswerFollowupTemplate +{ + public string PromptTemplate { get; set; } = ""; + public List Choices { get; set; } = []; + public string CorrectChoiceId { get; set; } = ""; + public string TeachingPointTemplate { get; set; } = ""; +} + +public sealed class DynamicVariableDefinition +{ + public string Type { get; set; } = "integer"; + public double Minimum { get; set; } + public double Maximum { get; set; } + public double Step { get; set; } = 1; + public int Precision { get; set; } + public List Values { get; set; } = []; +} + +public sealed class DynamicDerivedDefinition +{ + public string Operation { get; set; } = ""; + public string Left { get; set; } = ""; + public string Right { get; set; } = ""; + public int Precision { get; set; } +} + +public sealed class DynamicConstraint +{ + public string Field { get; set; } = ""; + public string Operator { get; set; } = ""; + public double Value { get; set; } +} + +public sealed class DynamicLabTemplate +{ + public string Name { get; set; } = ""; + public string ValueTemplate { get; set; } = ""; + public string Unit { get; set; } = ""; + public string Reference { get; set; } = ""; +} + +public sealed class DynamicAnswerTemplate +{ + public string Id { get; set; } = ""; + public string TextTemplate { get; set; } = ""; + public string ExplanationTemplate { get; set; } = ""; + public string MisconceptionId { get; set; } = ""; + public List RemediationConceptIds { get; set; } = []; +} + +public sealed class DynamicGenerationClaims +{ + public string UserId { get; set; } = ""; + public string BlueprintExternalId { get; set; } = ""; + public int BlueprintVersion { get; set; } + public string ObjectiveId { get; set; } = ""; + public string Seed { get; set; } = ""; + public string Nonce { get; set; } = ""; + public int GeneratorVersion { get; set; } = 1; + public string CaseFamilyId { get; set; } = ""; + public string DifficultyTier { get; set; } = ""; + public int EffectiveAbilityElo { get; set; } + public int FamilyElo { get; set; } + public bool IsScheduledReview { get; set; } + public string ReviewItemId { get; set; } = ""; + public int ReviewStage { get; set; } + public DateTime ExpiresUtc { get; set; } +} + +public sealed class DynamicRunClaims +{ + public string UserId { get; set; } = ""; + public List SeenObjectiveIds { get; set; } = []; + public string PreviousObjectiveId { get; set; } = ""; + public int Cycle { get; set; } + public List RecentCaseFamilyIds { get; set; } = []; + public DateTime ExpiresUtc { get; set; } +} + +public sealed class DynamicGeneratedQuestion +{ + public Question Question { get; set; } = new(); + public DynamicQuestionBlueprint Blueprint { get; set; } = new(); + public string CaseFamilyId { get; set; } = ""; +} + +public sealed class DynamicQuestionEnvelope +{ + public QuestionResponse Question { get; set; } = new(); + public string Source { get; set; } = "dynamic"; + public string ObjectiveId { get; set; } = ""; + public string ObjectiveTitle { get; set; } = ""; + public string GenerationToken { get; set; } = ""; + public string RunToken { get; set; } = ""; + public int CycleSeen { get; set; } + public int TotalObjectives { get; set; } +} + +public sealed class DynamicNextRequest +{ + public string RunToken { get; set; } = ""; +} + +public sealed class DynamicAttemptRequest +{ + public string GenerationToken { get; set; } = ""; + public string ChoiceId { get; set; } = ""; + public bool IsSkip { get; set; } + public int DurationSeconds { get; set; } +} + +public sealed class DynamicAttemptResponse +{ + public bool Inserted { get; set; } + public bool IsCorrect { get; set; } + public string CorrectChoiceId { get; set; } = ""; + public string ObjectiveId { get; set; } = ""; + public int ObjectiveElo { get; set; } + public int RatingChange { get; set; } + public string SelectedMisconceptionId { get; set; } = ""; + public bool IsScheduledReview { get; set; } + public DateTime? NextReviewUtc { get; set; } +} + +public sealed class DynamicReportRequest +{ + public string GenerationToken { get; set; } = ""; + public string Category { get; set; } = ""; + public string Details { get; set; } = ""; +} + +public sealed class DynamicQuestionStatusResponse +{ + public bool Available { get; set; } + public int PublishedObjectives { get; set; } + public int AttemptedObjectives { get; set; } + public int TotalAttempts { get; set; } + public int AverageObjectiveElo { get; set; } = 1000; + public int ReviewsDue { get; set; } + public List SystemCoverage { get; set; } = []; + public List PhysicianTaskCoverage { get; set; } = []; +} + +public sealed class DynamicCoverageSummary +{ + public string Id { get; set; } = ""; + public int PublishedObjectives { get; set; } + public int AttemptedObjectives { get; set; } + public int Attempts { get; set; } + public double CoveragePercent { get; set; } + public int AverageElo { get; set; } = 1000; +} + +public sealed class DynamicQuestionAttempt +{ + [BsonId] + [BsonRepresentation(BsonType.ObjectId)] + public string? Id { get; set; } + public string UserId { get; set; } = ""; + public string Nonce { get; set; } = ""; + public string BlueprintExternalId { get; set; } = ""; + public int BlueprintVersion { get; set; } + public string ObjectiveId { get; set; } = ""; + public string CaseFamilyId { get; set; } = ""; + public string DifficultyTier { get; set; } = ""; + public int EffectiveAbilityElo { get; set; } + public int FamilyElo { get; set; } + public string ChoiceId { get; set; } = ""; + public bool IsCorrect { get; set; } + public bool IsSkip { get; set; } + public string SelectedMisconceptionId { get; set; } = ""; + public bool IsScheduledReview { get; set; } + public int DurationSeconds { get; set; } + public DateTime CreatedUtc { get; set; } = DateTime.UtcNow; +} + +public static class DynamicReviewStates +{ + public const string Pending = "pending"; + public const string Mastered = "mastered"; +} + +public sealed class DynamicReviewItem +{ + [BsonId] + [BsonRepresentation(BsonType.ObjectId)] + public string? Id { get; set; } + public string UserId { get; set; } = ""; + public string ObjectiveId { get; set; } = ""; + public string BlueprintExternalId { get; set; } = ""; + public int BlueprintVersion { get; set; } + public string MisconceptionId { get; set; } = ""; + public string State { get; set; } = DynamicReviewStates.Pending; + public int Stage { get; set; } + public int ReviewCount { get; set; } + public DateTime DueUtc { get; set; } + public DateTime CreatedUtc { get; set; } = DateTime.UtcNow; + public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow; +} + +public sealed class DynamicObjectiveMastery +{ + [BsonId] + [BsonRepresentation(BsonType.ObjectId)] + public string? Id { get; set; } + public string UserId { get; set; } = ""; + public string ObjectiveId { get; set; } = ""; + public int Elo { get; set; } = 1000; + public int Attempts { get; set; } + public int Correct { get; set; } + public DateTime LastSeenUtc { get; set; } +} + +public sealed class DynamicFamilyRating +{ + [BsonId] + [BsonRepresentation(BsonType.ObjectId)] + public string? Id { get; set; } + public string BlueprintExternalId { get; set; } = ""; + public int BlueprintVersion { get; set; } + public string CaseFamilyId { get; set; } = ""; + public string DifficultyTier { get; set; } = ""; + public int NominalElo { get; set; } + public int Elo { get; set; } + public int Attempts { get; set; } + public int Correct { get; set; } + public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow; +} + +public sealed class DynamicGenerationDescriptor +{ + public string BlueprintExternalId { get; set; } = ""; + public int BlueprintVersion { get; set; } + public int GeneratorVersion { get; set; } + public string Seed { get; set; } = ""; + public string CaseFamilyId { get; set; } = ""; + public string DifficultyTier { get; set; } = ""; + public int FamilyElo { get; set; } + public int EffectiveAbilityElo { get; set; } +} diff --git a/api/QuestionsApi/Models/FlashcardModels.cs b/api/QuestionsApi/Models/FlashcardModels.cs new file mode 100644 index 0000000..17ad08f --- /dev/null +++ b/api/QuestionsApi/Models/FlashcardModels.cs @@ -0,0 +1,396 @@ +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; + +namespace QuestionsApi.Models; + +public static class FlashcardTypes +{ + public const string Basic = "basic"; + public const string Cloze = "cloze"; + public static readonly HashSet All = [Basic, Cloze]; +} + +// A reserved, never-authenticatable owner id. Decks/notes owned by this id are shared, +// admin-curated content visible to every user (studied via their own independent schedule +// rows) rather than a real user's private content. +public static class FlashcardOwners +{ + public const string System = "system"; +} + +public static class FlashcardRatings +{ + public const string Again = "again"; + public const string Hard = "hard"; + public const string Good = "good"; + public const string Easy = "easy"; + public static readonly string[] All = [Again, Hard, Good, Easy]; +} + +public static class FlashcardScheduleStates +{ + public const string New = "new"; + public const string Learning = "learning"; + public const string Review = "review"; + public const string Relearning = "relearning"; +} + +public sealed class FlashcardDeck +{ + [BsonId, BsonRepresentation(BsonType.ObjectId)] public string? Id { get; set; } + public string UserId { get; set; } = ""; + public string StableId { get; set; } = Guid.NewGuid().ToString(); + public string Name { get; set; } = ""; + public string NormalizedName { get; set; } = ""; + public string Description { get; set; } = ""; + public bool IsDefault { get; set; } + public int? NewCardsPerDay { get; set; } + public int? ReviewsPerDay { get; set; } + public double? DesiredRetention { get; set; } + public long Revision { get; set; } = 1; + public DateTime CreatedUtc { get; set; } = DateTime.UtcNow; + public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow; + public DateTime? ArchivedUtc { get; set; } + [BsonIgnore] public bool IsSystem => UserId == FlashcardOwners.System; +} + +public sealed class FlashcardImageReference +{ + public string Kind { get; set; } = "owned"; + public string AssetId { get; set; } = ""; +} + +public sealed class FlashcardNote +{ + [BsonId, BsonRepresentation(BsonType.ObjectId)] public string? Id { get; set; } + public string UserId { get; set; } = ""; + public string StableId { get; set; } = Guid.NewGuid().ToString(); + public string DeckId { get; set; } = ""; + public string Type { get; set; } = FlashcardTypes.Basic; + public string Prompt { get; set; } = ""; + public string Answer { get; set; } = ""; + public string Extra { get; set; } = ""; + public List Tags { get; set; } = []; + public List Images { get; set; } = []; + public string SourceQuestionExternalId { get; set; } = ""; + public string ContentFingerprint { get; set; } = ""; + public long Revision { get; set; } = 1; + public DateTime CreatedUtc { get; set; } = DateTime.UtcNow; + public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow; + public DateTime? ArchivedUtc { get; set; } + [BsonIgnore] public bool IsSystem => UserId == FlashcardOwners.System; +} + +public sealed class FlashcardSchedule +{ + [BsonId, BsonRepresentation(BsonType.ObjectId)] public string? Id { get; set; } + public string UserId { get; set; } = ""; + public string NoteId { get; set; } = ""; + public int Variant { get; set; } + public string State { get; set; } = FlashcardScheduleStates.New; + public int? Step { get; set; } + public double? Stability { get; set; } + public double? Difficulty { get; set; } + public DateTime DueUtc { get; set; } = DateTime.UtcNow; + public DateTime? LastReviewUtc { get; set; } + public DateTime? BuriedUntilUtc { get; set; } + public bool IsSuspended { get; set; } + public long Revision { get; set; } = 1; + public string AlgorithmVersion { get; set; } = "fsrs-core-1.0.7"; + public DateTime CreatedUtc { get; set; } = DateTime.UtcNow; +} + +public sealed class FlashcardReviewLog +{ + [BsonId] public string Id { get; set; } = ""; + public string UserId { get; set; } = ""; + public string CardId { get; set; } = ""; + public string Rating { get; set; } = ""; + public DateTime ReviewedUtc { get; set; } + public int DurationSeconds { get; set; } + public FlashcardScheduleSnapshot Before { get; set; } = new(); + public FlashcardScheduleSnapshot After { get; set; } = new(); + public DateTime CreatedUtc { get; set; } = DateTime.UtcNow; + public DateTime? UndoneUtc { get; set; } +} + +public sealed class FlashcardScheduleSnapshot +{ + public string State { get; set; } = FlashcardScheduleStates.New; + public int? Step { get; set; } + public double? Stability { get; set; } + public double? Difficulty { get; set; } + public DateTime DueUtc { get; set; } + public DateTime? LastReviewUtc { get; set; } + public long Revision { get; set; } +} + +public sealed class FlashcardMediaAsset +{ + [BsonId, BsonRepresentation(BsonType.ObjectId)] public string? Id { get; set; } + public string UserId { get; set; } = ""; + public string FileName { get; set; } = ""; + public string ContentType { get; set; } = ""; + public byte[] Bytes { get; set; } = []; + public DateTime CreatedUtc { get; set; } = DateTime.UtcNow; +} + +public sealed class FlashcardSettings +{ + [BsonId, BsonRepresentation(BsonType.ObjectId)] public string? Id { get; set; } + public string UserId { get; set; } = ""; + public int NewCardsPerDay { get; set; } = 20; + public int ReviewsPerDay { get; set; } = 200; + public double DesiredRetention { get; set; } = 0.90; + public long Revision { get; set; } = 1; + public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow; +} + +public sealed class UpsertFlashcardDeckRequest +{ + public string Name { get; set; } = ""; + public string Description { get; set; } = ""; + public int? NewCardsPerDay { get; set; } + public int? ReviewsPerDay { get; set; } + public double? DesiredRetention { get; set; } + public long? ExpectedRevision { get; set; } +} + +public sealed class UpsertFlashcardNoteRequest +{ + public string DeckId { get; set; } = ""; + public string StableId { get; set; } = ""; + public string Type { get; set; } = FlashcardTypes.Basic; + public string Prompt { get; set; } = ""; + public string Answer { get; set; } = ""; + public string Extra { get; set; } = ""; + public List Tags { get; set; } = []; + public List Images { get; set; } = []; + public string SourceQuestionExternalId { get; set; } = ""; + public long? ExpectedRevision { get; set; } +} + +public sealed class UpdateFlashcardSettingsRequest +{ + public int NewCardsPerDay { get; set; } = 20; + public int ReviewsPerDay { get; set; } = 200; + public double DesiredRetention { get; set; } = 0.90; + public long? ExpectedRevision { get; set; } +} + +public sealed class FlashcardDeckSummary +{ + public FlashcardDeck Deck { get; set; } = new(); + public int Total { get; set; } + public int Due { get; set; } + public int New { get; set; } + public int Suspended { get; set; } + public int Learning { get; set; } + public int Review { get; set; } + public int Mastered { get; set; } +} + +public sealed class FlashcardLibraryResponse +{ + public List Items { get; set; } = []; + public int Total { get; set; } +} + +public sealed class FlashcardLibraryQuery +{ + public string? DeckId { get; set; } + public string? Tag { get; set; } + public string? Type { get; set; } + public string? State { get; set; } + public string? Search { get; set; } + public int Page { get; set; } = 1; + public int PageSize { get; set; } = 50; +} + +public sealed class FlashcardStudyRequest +{ + public string DeckId { get; set; } = ""; + public List ExcludeCardIds { get; set; } = []; + public DateTime StudyDayStartUtc { get; set; } = DateTime.UtcNow.Date; + public bool Shuffle { get; set; } +} + +public sealed class FlashcardBulkNoteIdsRequest +{ + public List NoteIds { get; set; } = []; +} + +public sealed class FlashcardMoveRequest +{ + public List NoteIds { get; set; } = []; + public string TargetDeckId { get; set; } = ""; +} + +public sealed class FlashcardBatchReviewRequest +{ + public List Reviews { get; set; } = []; +} + +public sealed class FlashcardBatchReviewResponse +{ + public List Results { get; set; } = []; +} + +public sealed class FlashcardUndoRequest +{ + public string ReviewLogId { get; set; } = ""; +} + +public sealed class FlashcardRatingOption +{ + public string Rating { get; set; } = ""; + public string IntervalLabel { get; set; } = ""; + public DateTime DueUtc { get; set; } +} + +public sealed class FlashcardStudyCardResponse +{ + public FlashcardNote? Note { get; set; } + public FlashcardSchedule? Card { get; set; } + public List RatingOptions { get; set; } = []; + public int DueRemaining { get; set; } + public int NewRemaining { get; set; } +} + +public sealed class FlashcardReviewRequest +{ + public string ClientReviewId { get; set; } = ""; + public string CardId { get; set; } = ""; + public string Rating { get; set; } = ""; + public long ExpectedRevision { get; set; } + public DateTime ReviewedUtc { get; set; } + public int DurationSeconds { get; set; } + public DateTime StudyDayStartUtc { get; set; } = DateTime.UtcNow.Date; +} + +public sealed class FlashcardReviewResponse +{ + public FlashcardSchedule Card { get; set; } = new(); + public List RatingOptions { get; set; } = []; + public bool Duplicate { get; set; } +} + +public sealed class FlashcardDashboardCounts +{ + public int Due { get; set; } + public int NewAvailable { get; set; } + public int ReviewedThisWeek { get; set; } + public int Learning { get; set; } + public int Review { get; set; } + public int CurrentStreakDays { get; set; } + public int ReviewsToday { get; set; } + public int DailyGoal { get; set; } +} + +public static class FlashcardImportFormats +{ + public const string Csv = "csv"; + public const string Json = "json"; +} + +public static class FlashcardImportRowActions +{ + public const string Create = "create"; + public const string Update = "update"; + public const string Skip = "skip"; + public const string Error = "error"; +} + +public sealed class FlashcardImportPendingRecord +{ + public int RowNumber { get; set; } + public string Action { get; set; } = FlashcardImportRowActions.Create; + public string? Error { get; set; } + public string StableId { get; set; } = ""; + public string DeckName { get; set; } = ""; + public string Type { get; set; } = FlashcardTypes.Basic; + public string Prompt { get; set; } = ""; + public string Answer { get; set; } = ""; + public string Extra { get; set; } = ""; + public List Tags { get; set; } = []; + public string SourceQuestionExternalId { get; set; } = ""; + public List Cards { get; set; } = []; + public List ReviewLogs { get; set; } = []; +} + +public sealed class FlashcardImportPreview +{ + [BsonId] public string Id { get; set; } = ""; + public string UserId { get; set; } = ""; + public string Format { get; set; } = FlashcardImportFormats.Csv; + public List Records { get; set; } = []; + public DateTime CreatedUtc { get; set; } = DateTime.UtcNow; + public DateTime ExpiresUtc { get; set; } = DateTime.UtcNow.AddHours(1); +} + +public sealed class FlashcardImportPreviewRow +{ + public int RowNumber { get; set; } + public string Action { get; set; } = FlashcardImportRowActions.Create; + public string? Error { get; set; } + public string StableId { get; set; } = ""; + public string Deck { get; set; } = ""; + public string Type { get; set; } = FlashcardTypes.Basic; + public string Prompt { get; set; } = ""; + public string Answer { get; set; } = ""; +} + +public sealed class FlashcardImportPreviewResponse +{ + public string ImportToken { get; set; } = ""; + public int CreateCount { get; set; } + public int UpdateCount { get; set; } + public int SkipCount { get; set; } + public int ErrorCount { get; set; } + public List Rows { get; set; } = []; +} + +public sealed class FlashcardImportCommitRequest +{ + public string ImportToken { get; set; } = ""; +} + +public sealed class FlashcardImportCommitResponse +{ + public int Created { get; set; } + public int Updated { get; set; } + public int Skipped { get; set; } + public int Errored { get; set; } + public List Errors { get; set; } = []; +} + +public sealed class FlashcardExportOmission +{ + public string Kind { get; set; } = ""; + public string Reason { get; set; } = ""; +} + +public sealed class FlashcardJsonExport +{ + public int SchemaVersion { get; set; } = 1; + public DateTime ExportedUtc { get; set; } = DateTime.UtcNow; + public FlashcardSettings Settings { get; set; } = new(); + public List Decks { get; set; } = []; + public List Notes { get; set; } = []; +} + +public sealed class FlashcardJsonExportNote +{ + public FlashcardNote Note { get; set; } = new(); + public List Cards { get; set; } = []; + public List ReviewLogs { get; set; } = []; + public List Images { get; set; } = []; +} + +public sealed class FlashcardJsonExportImage +{ + public string AssetId { get; set; } = ""; + public string FileName { get; set; } = ""; + public string ContentType { get; set; } = ""; + public string Base64Bytes { get; set; } = ""; +} diff --git a/api/QuestionsApi/Models/Question.cs b/api/QuestionsApi/Models/Question.cs index f621fc1..0bf6a20 100644 --- a/api/QuestionsApi/Models/Question.cs +++ b/api/QuestionsApi/Models/Question.cs @@ -30,6 +30,9 @@ public class Question public List Tags { get; set; } = []; public List References { get; set; } = []; public bool IsPublished { get; set; } = true; + public DateTime? FlaggedForReviewUtc { get; set; } + public string FlagReason { get; set; } = ""; + public string FlaggedByUserId { get; set; } = ""; public DateTime CreatedUtc { get; set; } = DateTime.UtcNow; public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow; } diff --git a/api/QuestionsApi/Models/QuestionReport.cs b/api/QuestionsApi/Models/QuestionReport.cs index d8c910d..223312c 100644 --- a/api/QuestionsApi/Models/QuestionReport.cs +++ b/api/QuestionsApi/Models/QuestionReport.cs @@ -45,6 +45,8 @@ public class QuestionReport public string Details { get; set; } = ""; public string Status { get; set; } = QuestionReportStatus.Open; public QuestionReportSnapshot QuestionSnapshot { get; set; } = new(); + public string SourceType { get; set; } = "authored"; + public DynamicGenerationDescriptor? DynamicGeneration { get; set; } public string ReviewedByUserId { get; set; } = ""; public string ReviewerNote { get; set; } = ""; public DateTime? ReviewedUtc { get; set; } diff --git a/api/QuestionsApi/Models/QuestionRequests.cs b/api/QuestionsApi/Models/QuestionRequests.cs index 9ddbb26..7004cef 100644 --- a/api/QuestionsApi/Models/QuestionRequests.cs +++ b/api/QuestionsApi/Models/QuestionRequests.cs @@ -38,6 +38,11 @@ public class UpsertQuestionRequest public bool IsPublished { get; set; } = true; } +public class FlagQuestionRequest +{ + public string Reason { get; set; } = ""; +} + public class FirstQuestionAttemptRequest { public string QuestionExternalId { get; set; } = ""; diff --git a/api/QuestionsApi/Models/QuestionResponse.cs b/api/QuestionsApi/Models/QuestionResponse.cs index f9240ec..0c636ba 100644 --- a/api/QuestionsApi/Models/QuestionResponse.cs +++ b/api/QuestionsApi/Models/QuestionResponse.cs @@ -111,6 +111,30 @@ public class QuestionResponse } } +// Admin-only shape for the flag/unflag/list-flagged endpoints - deliberately separate from +// QuestionResponse (which is student-facing and never exposes IsPublished or the flag fields). +public class QuestionFlagResponse +{ + public string Id { get; set; } = ""; + public string ExternalId { get; set; } = ""; + public string Stem { get; set; } = ""; + public bool IsPublished { get; set; } + public DateTime? FlaggedForReviewUtc { get; set; } + public string FlagReason { get; set; } = ""; + public string FlaggedByUserId { get; set; } = ""; + + public static QuestionFlagResponse FromModel(Question question) => new() + { + Id = question.Id ?? "", + ExternalId = question.ExternalId, + Stem = question.Stem, + IsPublished = question.IsPublished, + FlaggedForReviewUtc = question.FlaggedForReviewUtc, + FlagReason = question.FlagReason, + FlaggedByUserId = question.FlaggedByUserId + }; +} + public class FirstQuestionAttemptResponse { public string QuestionExternalId { get; set; } = ""; diff --git a/api/QuestionsApi/Models/UserProfileModels.cs b/api/QuestionsApi/Models/UserProfileModels.cs index aedfb6c..64d5a6a 100644 --- a/api/QuestionsApi/Models/UserProfileModels.cs +++ b/api/QuestionsApi/Models/UserProfileModels.cs @@ -13,6 +13,8 @@ public sealed class UserStudyProfile public string PreparationStage { get; set; } = "pre_dedicated"; public int WeeklyGoalHours { get; set; } = 10; public string PreferredStudyMode { get; set; } = "mixed"; + public double QuestionTextScale { get; set; } = 1.0; + public double CcsTextScale { get; set; } = 1.0; public int Revision { get; set; } public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow; } @@ -25,6 +27,12 @@ public sealed class UpdateUserStudyProfileRequest public string PreferredStudyMode { get; set; } = "mixed"; } +public sealed class UpdateTextScaleRequest +{ + public double? QuestionTextScale { get; set; } + public double? CcsTextScale { get; set; } +} + public sealed class DeletedUserTombstone { [BsonId] @@ -48,3 +56,18 @@ public static class UserStudyProfileValidator return null; } } + +public static class UpdateTextScaleRequestValidator +{ + private const double Min = 0.85; + private const double Max = 1.35; + + public static string? Validate(UpdateTextScaleRequest request) + { + if (request.QuestionTextScale is { } questionScale && (questionScale < Min || questionScale > Max)) + return "Question text scale must be between 0.85 and 1.35."; + if (request.CcsTextScale is { } ccsScale && (ccsScale < Min || ccsScale > Max)) + return "CCS text scale must be between 0.85 and 1.35."; + return null; + } +} diff --git a/api/QuestionsApi/Program.cs b/api/QuestionsApi/Program.cs index 3b472ea..9d7c1c5 100644 --- a/api/QuestionsApi/Program.cs +++ b/api/QuestionsApi/Program.cs @@ -4,6 +4,7 @@ using Microsoft.AspNetCore.HttpOverrides; using Microsoft.IdentityModel.Tokens; using Microsoft.OpenApi.Models; using QuestionsApi.Services; +using QuestionsApi.Services.Flashcards; using MongoDB.Bson; using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; @@ -16,6 +17,38 @@ if (args.FirstOrDefault() == "promote-content") return; } +if (args.FirstOrDefault() == "promote-dynamic-questions") +{ + Environment.ExitCode = await DynamicBlueprintCommand.RunAsync(args.Skip(1).ToArray(), Console.Out); + return; +} + +if (args.FirstOrDefault() == "export-dynamic-review") +{ + Environment.ExitCode = await DynamicReviewExportCommand.RunAsync(args.Skip(1).ToArray(), Console.Out); + return; +} + +if (args.FirstOrDefault() == "seed-flashcards") +{ + var seedConfiguration = new ConfigurationBuilder() + .AddJsonFile("appsettings.json", optional: true) + .AddEnvironmentVariables() + .Build(); + Environment.ExitCode = await SeedFlashcardsCommand.RunAsync(seedConfiguration, Console.Out); + return; +} + +if (args.FirstOrDefault() == "flag-media-questions") +{ + var flagConfiguration = new ConfigurationBuilder() + .AddJsonFile("appsettings.json", optional: true) + .AddEnvironmentVariables() + .Build(); + Environment.ExitCode = await FlagMediaQuestionsCommand.RunAsync(flagConfiguration, Console.Out); + return; +} + var builder = WebApplication.CreateBuilder(args); builder.Services.AddControllers(); builder.Services.Configure(options => @@ -33,6 +66,10 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); @@ -43,6 +80,14 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/api/QuestionsApi/QuestionsApi.csproj b/api/QuestionsApi/QuestionsApi.csproj index d627762..0a6aa82 100644 --- a/api/QuestionsApi/QuestionsApi.csproj +++ b/api/QuestionsApi/QuestionsApi.csproj @@ -8,6 +8,7 @@ + diff --git a/api/QuestionsApi/Services/CcsCaseValidator.cs b/api/QuestionsApi/Services/CcsCaseValidator.cs index caba486..1993015 100644 --- a/api/QuestionsApi/Services/CcsCaseValidator.cs +++ b/api/QuestionsApi/Services/CcsCaseValidator.cs @@ -4,8 +4,7 @@ namespace QuestionsApi.Services; public static class CcsCaseValidator { - private static readonly HashSet Modes = [CcsAttemptModes.Practice, CcsAttemptModes.Timed]; - private static readonly HashSet CommandTypes = ["exam", "order", "cancel_order", "location", "advance", "interval_history", "end_case", "exit_case"]; + private static readonly HashSet CommandTypes = ["exam", "order", "cancel_order", "location", "advance", "interval_history", "acknowledge_opening", "end_case", "exit_case"]; public static List ValidateImport(CcsImportRequest request, IReadOnlySet existingOrderCodes) { @@ -18,6 +17,8 @@ public static class CcsCaseValidator if (string.IsNullOrWhiteSpace(order.CanonicalName)) errors.Add(prefix + "canonicalName required"); if (string.IsNullOrWhiteSpace(order.Category)) errors.Add(prefix + "category required"); if (order.DefaultTurnaroundMinutes < 0) errors.Add(prefix + "defaultTurnaroundMinutes cannot be negative"); + if (!string.IsNullOrWhiteSpace(order.ResultSection) && !CcsChartCategories.ResultSections.Contains(order.ResultSection)) + errors.Add(prefix + "resultSection is invalid"); } if (request.Orders.Select(order => order.Code).Distinct(StringComparer.OrdinalIgnoreCase).Count() != request.Orders.Count) errors.Add("order codes must be unique within an import"); @@ -50,7 +51,11 @@ public static class CcsCaseValidator if (eventIds.Any(string.IsNullOrWhiteSpace) || eventIds.Distinct(StringComparer.OrdinalIgnoreCase).Count() != eventIds.Count) errors.Add(prefix + "event ids must be present and unique"); foreach (var response in item.OrderResponses) + { if (!orderCodes.Contains(response.OrderCode)) errors.Add(prefix + $"unknown order code {response.OrderCode}"); + if (!string.IsNullOrWhiteSpace(response.ResultSection) && !CcsChartCategories.ResultSections.Contains(response.ResultSection)) + errors.Add(prefix + $"order response {response.OrderCode} resultSection is invalid"); + } foreach (var code in item.InitialChart.InitialOrderCodes.Concat(item.Events.SelectMany(value => value.RequiresOrderCodes.Concat(value.RequiresAnyOrderCodes)))) if (!orderCodes.Contains(code)) errors.Add(prefix + $"unknown order code {code}"); var criteriaIds = item.ScoringCriteria.Select(value => value.Id).ToList(); @@ -75,7 +80,8 @@ public static class CcsCaseValidator public static string? ValidateCreate(CreateCcsAttemptRequest request) { - if (!Modes.Contains(request.Mode)) return "mode must be practice or timed"; + if (!request.Mode.Equals(CcsAttemptModes.Timed, StringComparison.OrdinalIgnoreCase)) + return "new CCS attempts must be timed"; return null; } @@ -88,4 +94,29 @@ public static class CcsCaseValidator if (request.Type == "advance" && request.Minutes is < 1 or > 525600) return "advance minutes must be between 1 and 525600"; return null; } + + public static string? ValidateBatch(CcsBatchCommandRequest request) + { + if (string.IsNullOrWhiteSpace(request.CommandId)) return "commandId required"; + if (request.ExpectedRevision < 0) return "expectedRevision cannot be negative"; + if (request.Commands.Count is < 1 or > 50) return "commands must contain between 1 and 50 items"; + foreach (var item in request.Commands) + { + var error = ValidateCommand(new CcsCommandRequest + { + CommandId = request.CommandId, + ExpectedRevision = request.ExpectedRevision, + Type = item.Type, + Code = item.Code, + Minutes = item.Minutes + }); + if (error is not null) return error; + if (item.Type is not ("exam" or "interval_history" or "order" or "cancel_order")) + return "batch commands support history/exams, orders, and order cancellations only"; + } + var hasClinicalAssessment = request.Commands.Any(value => value.Type is "exam" or "interval_history"); + if (hasClinicalAssessment && request.Commands.Any(value => value.Type is not ("exam" or "interval_history"))) + return "history and examinations cannot be mixed with order changes in one batch"; + return null; + } } diff --git a/api/QuestionsApi/Services/CcsStore.cs b/api/QuestionsApi/Services/CcsStore.cs index d938022..3095986 100644 --- a/api/QuestionsApi/Services/CcsStore.cs +++ b/api/QuestionsApi/Services/CcsStore.cs @@ -115,16 +115,22 @@ public sealed class CcsStore CaseExternalId = definition.ExternalId, CaseRevisionId = definition.Id ?? "", CaseVersion = definition.Version, - Mode = request.Mode, + Mode = CcsAttemptModes.Timed, + OpeningCompleted = false, Location = definition.PresentingLocation, CurrentVitals = new Dictionary(definition.InitialChart.Vitals), + VitalHistory = + [ + new CcsVitalSnapshot { SimulatedMinute = 0, Values = new Dictionary(definition.InitialChart.Vitals) } + ], ActiveOrderCodes = definition.InitialChart.InitialOrderCodes.Distinct(StringComparer.OrdinalIgnoreCase).ToList(), - DeadlineUtc = request.Mode == CcsAttemptModes.Timed ? now.AddMinutes(definition.RealTimeMinutes) : null, - RatingEligible = request.Mode == CcsAttemptModes.Timed, + DeadlineUtc = now.AddMinutes(ActivePhaseMinutes(definition.RealTimeMinutes)), + HardDeadlineUtc = now.AddMinutes(definition.RealTimeMinutes), + RatingEligible = true, Chart = [ - new CcsChartEntry { Category = "progress", Text = definition.InitialChart.ReasonForEncounter }, - new CcsChartEntry { Category = "progress", Text = definition.InitialChart.Appearance } + new CcsChartEntry { Category = CcsChartCategories.Progress, Code = "reason_for_encounter", Title = "Reason for encounter", Text = definition.InitialChart.ReasonForEncounter }, + new CcsChartEntry { Category = CcsChartCategories.Progress, Code = "initial_appearance", Title = "Initial appearance", Text = definition.InitialChart.Appearance } ] }; try { await _attempts.InsertOneAsync(attempt); } @@ -173,7 +179,12 @@ public sealed class CcsStore if (attempt.State == CcsAttemptStates.Active && attempt.DeadlineUtc <= DateTime.UtcNow) { attempt.State = CcsAttemptStates.FinalOrders; - attempt.FinalOrdersDeadlineUtc = DateTime.UtcNow.AddMinutes(2); + attempt.FinalOrdersDeadlineUtc = FinalOrdersDeadline(DateTime.UtcNow, attempt.HardDeadlineUtc); + attempt.Chart.Add(new CcsChartEntry + { + Category = CcsChartCategories.Progress, Code = "case_end", Title = "Case-end instructions", + SimulatedMinute = attempt.SimulatedMinute, Text = "Finalize patient care orders." + }); attempt.Revision++; await SaveAsync(attempt); } @@ -206,9 +217,16 @@ public sealed class CcsStore case "advance": Advance(attempt, definition, command.Minutes); break; case "interval_history": attempt.Actions.Add(Action("interval_history", "interval_history", attempt.SimulatedMinute, now)); - attempt.Chart.Add(new CcsChartEntry { Category = "progress", SimulatedMinute = attempt.SimulatedMinute, Text = "The patient was reassessed for interval change." }); + attempt.Chart.Add(new CcsChartEntry + { + Category = CcsChartCategories.Progress, Code = "interval_history", Title = "Interval history", + SimulatedMinute = attempt.SimulatedMinute, Text = "The patient was reassessed for interval change." + }); Advance(attempt, definition, 1); break; + case "acknowledge_opening": + attempt.OpeningCompleted = true; + break; case "end_case": EnterFinalOrders(attempt); break; case "exit_case": await CompleteAsync(attempt); break; } @@ -220,6 +238,63 @@ public sealed class CcsStore return attempt; } + public async Task ApplyBatchCommandAsync(CcsAttempt attempt, CcsBatchCommandRequest request) + { + var validation = CcsCaseValidator.ValidateBatch(request); + if (validation is not null) throw new ArgumentException(validation); + if (attempt.ProcessedCommandIds.Contains(request.CommandId, StringComparer.Ordinal)) return attempt; + if (attempt.Revision != request.ExpectedRevision) throw new InvalidOperationException("Attempt revision is stale."); + if (attempt.State is CcsAttemptStates.Completed or CcsAttemptStates.Abandoned) + throw new InvalidOperationException("The CCS attempt is closed."); + if (attempt.State == CcsAttemptStates.FinalOrders && request.Commands.Any(value => value.Type is not ("order" or "cancel_order"))) + throw new InvalidOperationException("Only final orders may be changed now."); + + var definition = await DefinitionAsync(attempt); + var now = DateTime.UtcNow; + if (request.Commands.Any(value => value.Type is "exam" or "interval_history")) + { + var selected = request.Commands.Where(item => item.Type == "exam").Select(item => + definition.Exams.FirstOrDefault(value => value.Code.Equals(item.Code, StringComparison.OrdinalIgnoreCase)) + ?? throw new ArgumentException($"Unknown examination component: {item.Code}.")).ToList(); + var includeHistory = request.Commands.Any(item => item.Type == "interval_history"); + var startingMinute = attempt.SimulatedMinute; + foreach (var exam in selected) + attempt.Actions.Add(Action("exam", exam.Code, startingMinute, now)); + if (includeHistory) + attempt.Actions.Add(Action("interval_history", "interval_history", startingMinute, now)); + Advance(attempt, definition, selected.Sum(value => Math.Max(1, value.DurationMinutes)) + (includeHistory ? 1 : 0)); + if (includeHistory) + attempt.Chart.Add(new CcsChartEntry + { + Category = CcsChartCategories.Progress, Code = "interval_history", Title = "Interval history", + SimulatedMinute = attempt.SimulatedMinute, Text = "The patient was reassessed for interval change." + }); + foreach (var exam in selected) + attempt.Chart.Add(new CcsChartEntry + { + Category = CcsChartCategories.Progress, Code = exam.Code, Title = exam.Name, + SimulatedMinute = attempt.SimulatedMinute, Text = exam.Finding + }); + } + else + { + foreach (var item in request.Commands) + { + if (item.Type == "order") await ApplyOrderAsync(attempt, definition, item.Code, now, false); + else CancelOrder(attempt, item.Code, now); + } + ProcessDue(attempt); + } + + attempt.ProcessedCommandIds.Add(request.CommandId); + if (attempt.ProcessedCommandIds.Count > 100) + attempt.ProcessedCommandIds.RemoveRange(0, attempt.ProcessedCommandIds.Count - 100); + attempt.Revision++; + attempt.UpdatedUtc = now; + await SaveAsync(attempt); + return attempt; + } + public async Task HistoryAsync(string userId, int limit, string status = "all", string sort = "newest", string? cursor = null) { limit = Math.Clamp(limit, 1, 50); @@ -314,19 +389,49 @@ public sealed class CcsStore { var definition = await DefinitionAsync(attempt); var completed = attempt.State == CcsAttemptStates.Completed; + List activeCatalog = attempt.ActiveOrderCodes.Count == 0 + ? [] + : await _orders.Find(value => attempt.ActiveOrderCodes.Contains(value.Code)).ToListAsync(); + var activeOrders = activeCatalog.Select(order => + { + var orderedMinute = attempt.Actions.LastOrDefault(value => + value.Type == "order" && value.Code.Equals(order.Code, StringComparison.OrdinalIgnoreCase))?.SimulatedMinute ?? 0; + var pending = attempt.PendingResults.FirstOrDefault(value => + value.OrderCode.Equals(order.Code, StringComparison.OrdinalIgnoreCase)); + var reported = attempt.Chart.LastOrDefault(value => + value.Code.Equals(order.Code, StringComparison.OrdinalIgnoreCase) + && value.Category is not CcsChartCategories.Orders); + return new CcsActiveOrder + { + Code = order.Code, Name = order.CanonicalName, Category = order.Category, + Route = order.DisplayRoute, Frequency = order.DisplayFrequency, OrderedMinute = orderedMinute, + ReportMinute = pending is not null ? null : reported?.SimulatedMinute, + IsPending = pending is not null, HasResult = reported is not null + }; + }).OrderBy(value => value.OrderedMinute).ThenBy(value => value.Name).ToList(); + List vitalHistory = attempt.VitalHistory.Count > 0 + ? attempt.VitalHistory + : [new CcsVitalSnapshot { SimulatedMinute = attempt.SimulatedMinute, Values = new Dictionary(attempt.CurrentVitals) }]; return new CcsAttemptResponse { Id = attempt.Id ?? "", CaseExternalId = attempt.CaseExternalId, CaseTitle = completed ? definition.Title : $"CCS Case {attempt.CaseExternalId}", Mode = attempt.Mode, State = attempt.State, Revision = attempt.Revision, - SimulatedMinute = attempt.SimulatedMinute, Location = attempt.Location, - DeadlineUtc = attempt.DeadlineUtc, FinalOrdersDeadlineUtc = attempt.FinalOrdersDeadlineUtc, + SimulatedMinute = attempt.SimulatedMinute, SimulatedStartUtc = definition.InitialChart.SimulatedStartUtc, + OpeningCompleted = attempt.OpeningCompleted ?? true, Location = attempt.Location, + DeadlineUtc = attempt.DeadlineUtc, HardDeadlineUtc = attempt.HardDeadlineUtc, + FinalOrdersDeadlineUtc = attempt.FinalOrdersDeadlineUtc, CreatedUtc = attempt.CreatedUtc, RealTimeMinutes = definition.RealTimeMinutes, ElapsedSeconds = Math.Max(0, (int)Math.Round(((attempt.CompletedUtc ?? DateTime.UtcNow) - attempt.CreatedUtc).TotalSeconds)), PatientSummary = definition.InitialChart.PatientSummary, ReasonForEncounter = definition.InitialChart.ReasonForEncounter, Appearance = definition.InitialChart.Appearance, History = definition.InitialChart.History, + HistorySections = definition.InitialChart.HistorySections, Vitals = attempt.CurrentVitals, Chart = attempt.Chart, ActiveOrderCodes = attempt.ActiveOrderCodes, - PendingResults = attempt.PendingResults.Select(value => new CcsPendingResult { OrderCode = value.OrderCode, DueMinute = value.DueMinute }).ToList(), + ActiveOrders = activeOrders, VitalHistory = vitalHistory, + PendingResults = attempt.PendingResults.Select(value => new CcsPendingResult + { + OrderCode = value.OrderCode, DueMinute = value.DueMinute, Category = value.Category, Title = value.Title + }).ToList(), AvailableExams = attempt.State == CcsAttemptStates.Active ? definition.Exams : [], AllowedLocations = attempt.State == CcsAttemptStates.Active ? definition.AllowedLocations : [], Score = completed ? attempt.Score : null, Rating = completed ? attempt.Rating : null, @@ -342,7 +447,7 @@ public sealed class CcsStore await SaveAsync(attempt); } - private async Task ApplyOrderAsync(CcsAttempt attempt, CcsCaseRevision definition, string code, DateTime now) + private async Task ApplyOrderAsync(CcsAttempt attempt, CcsCaseRevision definition, string code, DateTime now, bool processImmediately = true) { code = code.Trim().ToLowerInvariant(); var catalog = await _orders.Find(value => value.Code == code).FirstOrDefaultAsync() ?? throw new ArgumentException("Unknown order code."); @@ -352,17 +457,30 @@ public sealed class CcsStore if (!attempt.ActiveOrderCodes.Contains(code, StringComparer.OrdinalIgnoreCase)) attempt.ActiveOrderCodes.Add(code); var response = definition.OrderResponses.FirstOrDefault(value => value.OrderCode.Equals(code, StringComparison.OrdinalIgnoreCase)); var delay = response?.TurnaroundMinutes ?? catalog.DefaultTurnaroundMinutes; + var resultCategory = ResultCategory(response?.ResultSection, catalog.ResultSection, catalog.Category); if (response is not null && (!string.IsNullOrWhiteSpace(response.Result) || response.VitalPatches.Count > 0)) - attempt.PendingResults.Add(new CcsPendingResult { OrderCode = code, DueMinute = attempt.SimulatedMinute + delay, Result = response.Result, VitalPatches = response.VitalPatches }); - attempt.Chart.Add(new CcsChartEntry { Category = "orders", SimulatedMinute = attempt.SimulatedMinute, Text = $"Ordered: {catalog.CanonicalName}" }); - if (delay == 0) ProcessDue(attempt); + attempt.PendingResults.Add(new CcsPendingResult + { + OrderCode = code, Category = resultCategory, Title = catalog.CanonicalName, + DueMinute = attempt.SimulatedMinute + delay, Result = response.Result, VitalPatches = response.VitalPatches + }); + attempt.Chart.Add(new CcsChartEntry + { + Category = CcsChartCategories.Orders, Code = code, Title = catalog.CanonicalName, + SimulatedMinute = attempt.SimulatedMinute, Text = $"Ordered: {catalog.CanonicalName}" + }); + if (delay == 0 && processImmediately) ProcessDue(attempt); } private static void ApplyExam(CcsAttempt attempt, CcsCaseRevision definition, string code, DateTime now) { var exam = definition.Exams.FirstOrDefault(value => value.Code.Equals(code, StringComparison.OrdinalIgnoreCase)) ?? throw new ArgumentException("Unknown examination component."); attempt.Actions.Add(Action("exam", exam.Code, attempt.SimulatedMinute, now)); - attempt.Chart.Add(new CcsChartEntry { Category = "exam", SimulatedMinute = attempt.SimulatedMinute, Text = $"{exam.Name}: {exam.Finding}" }); + attempt.Chart.Add(new CcsChartEntry + { + Category = CcsChartCategories.Progress, Code = exam.Code, Title = exam.Name, + SimulatedMinute = attempt.SimulatedMinute, Text = exam.Finding + }); Advance(attempt, definition, Math.Max(1, exam.DurationMinutes)); } @@ -371,7 +489,11 @@ public sealed class CcsStore attempt.Actions.Add(Action("cancel_order", code, attempt.SimulatedMinute, now)); attempt.ActiveOrderCodes.RemoveAll(value => value.Equals(code, StringComparison.OrdinalIgnoreCase)); attempt.PendingResults.RemoveAll(value => value.OrderCode.Equals(code, StringComparison.OrdinalIgnoreCase)); - attempt.Chart.Add(new CcsChartEntry { Category = "orders", SimulatedMinute = attempt.SimulatedMinute, Text = $"Canceled: {code}" }); + attempt.Chart.Add(new CcsChartEntry + { + Category = CcsChartCategories.Orders, Code = code, Title = Humanize(code), + SimulatedMinute = attempt.SimulatedMinute, Text = $"Canceled: {Humanize(code)}" + }); } private static void ChangeLocation(CcsAttempt attempt, CcsCaseRevision definition, string location, DateTime now) @@ -379,7 +501,11 @@ public sealed class CcsStore if (!definition.AllowedLocations.Contains(location, StringComparer.OrdinalIgnoreCase)) throw new ArgumentException("Unsupported location."); attempt.Location = location; attempt.Actions.Add(Action("location", location, attempt.SimulatedMinute, now)); - attempt.Chart.Add(new CcsChartEntry { Category = "progress", SimulatedMinute = attempt.SimulatedMinute, Text = $"Patient moved to {location}." }); + attempt.Chart.Add(new CcsChartEntry + { + Category = CcsChartCategories.Progress, Code = "location", Title = "Location change", + SimulatedMinute = attempt.SimulatedMinute, Text = $"Patient moved to {Humanize(location)}." + }); } private static void Advance(CcsAttempt attempt, CcsCaseRevision definition, int minutes) @@ -403,13 +529,20 @@ public sealed class CcsStore private static void ProcessDue(CcsAttempt attempt) { var due = attempt.PendingResults.Where(value => value.DueMinute <= attempt.SimulatedMinute).ToList(); + var vitalsChanged = false; foreach (var result in due) { if (!string.IsNullOrWhiteSpace(result.Result)) - attempt.Chart.Add(new CcsChartEntry { Category = "results", SimulatedMinute = attempt.SimulatedMinute, Text = result.Result }); + attempt.Chart.Add(new CcsChartEntry + { + Category = string.IsNullOrWhiteSpace(result.Category) ? CcsChartCategories.OtherTest : result.Category, + Code = result.OrderCode, Title = result.Title, SimulatedMinute = attempt.SimulatedMinute, Text = result.Result + }); foreach (var patch in result.VitalPatches) attempt.CurrentVitals[patch.Key] = patch.Value; + vitalsChanged |= result.VitalPatches.Count > 0; attempt.PendingResults.Remove(result); } + if (vitalsChanged) SnapshotVitals(attempt); } private static bool ProcessEvents(CcsAttempt attempt, CcsCaseRevision definition) @@ -419,13 +552,20 @@ public sealed class CcsStore && value.TriggerMinute <= attempt.SimulatedMinute && value.RequiresOrderCodes.All(activeOrders.Contains) && (value.RequiresAnyOrderCodes.Count == 0 || value.RequiresAnyOrderCodes.Any(activeOrders.Contains))).ToList(); + var vitalsChanged = false; foreach (var item in events) { attempt.ProcessedEventIds.Add(item.Id); foreach (var patch in item.VitalPatches) attempt.CurrentVitals[patch.Key] = patch.Value; - if (!string.IsNullOrWhiteSpace(item.Message)) attempt.Chart.Add(new CcsChartEntry { Category = "progress", SimulatedMinute = attempt.SimulatedMinute, Text = item.Message }); + vitalsChanged |= item.VitalPatches.Count > 0; + if (!string.IsNullOrWhiteSpace(item.Message)) attempt.Chart.Add(new CcsChartEntry + { + Category = CcsChartCategories.Progress, Code = item.Id, Title = "Clinical update", + SimulatedMinute = attempt.SimulatedMinute, Text = item.Message + }); if (item.EndCase) EnterFinalOrders(attempt); } + if (vitalsChanged) SnapshotVitals(attempt); return events.Any(value => !string.IsNullOrWhiteSpace(value.Message) || value.EndCase); } @@ -433,9 +573,21 @@ public sealed class CcsStore { if (attempt.State != CcsAttemptStates.Active) return; attempt.State = CcsAttemptStates.FinalOrders; - var remaining = attempt.DeadlineUtc is null ? TimeSpan.FromMinutes(2) : attempt.DeadlineUtc.Value - DateTime.UtcNow; - attempt.FinalOrdersDeadlineUtc = DateTime.UtcNow.Add(remaining < TimeSpan.FromMinutes(2) ? TimeSpan.FromSeconds(Math.Max(0, remaining.TotalSeconds)) : TimeSpan.FromMinutes(2)); - attempt.Chart.Add(new CcsChartEntry { Category = "progress", SimulatedMinute = attempt.SimulatedMinute, Text = "Case-end instructions: finalize patient care orders." }); + if (attempt.HardDeadlineUtc is not null) + { + attempt.FinalOrdersDeadlineUtc = FinalOrdersDeadline(DateTime.UtcNow, attempt.HardDeadlineUtc); + } + else + { + var remaining = attempt.DeadlineUtc is null ? TimeSpan.FromMinutes(2) : attempt.DeadlineUtc.Value - DateTime.UtcNow; + attempt.FinalOrdersDeadlineUtc = DateTime.UtcNow.Add( + remaining < TimeSpan.FromMinutes(2) ? TimeSpan.FromSeconds(Math.Max(0, remaining.TotalSeconds)) : TimeSpan.FromMinutes(2)); + } + attempt.Chart.Add(new CcsChartEntry + { + Category = CcsChartCategories.Progress, Code = "case_end", Title = "Case-end instructions", + SimulatedMinute = attempt.SimulatedMinute, Text = "Finalize patient care orders." + }); } private async Task CompleteAsync(CcsAttempt attempt) @@ -489,12 +641,44 @@ public sealed class CcsStore }; } + public static int ActivePhaseMinutes(int totalMinutes) => Math.Max(1, totalMinutes - 2); + + public static DateTime FinalOrdersDeadline(DateTime now, DateTime? hardDeadline) + { + var proposed = now.AddMinutes(2); + return hardDeadline is null || proposed <= hardDeadline ? proposed : hardDeadline.Value; + } + public async Task DefinitionAsync(CcsAttempt attempt) => await _cases.Find(value => value.Id == attempt.CaseRevisionId).FirstOrDefaultAsync() ?? throw new InvalidOperationException("The case revision is unavailable."); private Task SaveAsync(CcsAttempt attempt) => _attempts.ReplaceOneAsync(value => value.Id == attempt.Id, attempt); private static CcsAttemptAction Action(string type, string code, int minute, DateTime now) => new() { Type = type, Code = code, SimulatedMinute = minute, RealUtc = now }; + private static void SnapshotVitals(CcsAttempt attempt) + { + if (attempt.VitalHistory.Count > 0 + && attempt.VitalHistory[^1].SimulatedMinute == attempt.SimulatedMinute + && attempt.VitalHistory[^1].Values.OrderBy(value => value.Key).SequenceEqual(attempt.CurrentVitals.OrderBy(value => value.Key))) + return; + attempt.VitalHistory.Add(new CcsVitalSnapshot + { + SimulatedMinute = attempt.SimulatedMinute, + Values = new Dictionary(attempt.CurrentVitals) + }); + } + private static string ResultCategory(string? responseSection, string catalogSection, string catalogCategory) + { + var explicitSection = string.IsNullOrWhiteSpace(responseSection) ? catalogSection : responseSection; + if (!string.IsNullOrWhiteSpace(explicitSection)) return explicitSection; + return catalogCategory.ToLowerInvariant() switch + { + "laboratory" => CcsChartCategories.Lab, + "imaging" => CcsChartCategories.Imaging, + "medication" or "procedure" or "monitoring" => CcsChartCategories.Treatment, + _ => CcsChartCategories.OtherTest + }; + } private static string Humanize(string value) => string.Join(' ', value.Split('_', StringSplitOptions.RemoveEmptyEntries).Select(word => char.ToUpperInvariant(word[0]) + word[1..])); private static string DescribeExpected(CcsScoringCriterion criterion, List targets) { @@ -511,12 +695,25 @@ public sealed class CcsStore PresentingLocation = value.PresentingLocation, RealTimeMinutes = value.RealTimeMinutes, Difficulty = value.Difficulty, Tags = value.Tags }; - private static int Rank(CcsOrderDefinition order, string query) + internal static int Rank(CcsOrderDefinition order, string query) { var values = new[] { order.CanonicalName }.Concat(order.Aliases).Select(value => value.ToLowerInvariant()); if (values.Any(value => value == query)) return 0; if (values.Any(value => value.StartsWith(query))) return 1; if (values.Any(value => value.Contains(query))) return 2; + if (CategorySearchTerms(order.Category).Contains(query, StringComparer.OrdinalIgnoreCase)) return 3; return 1000; } + + private static IReadOnlyList CategorySearchTerms(string category) => + category.Trim().ToLowerInvariant() switch + { + "laboratory" => ["laboratory", "laboratories", "lab", "labs", "test", "tests", "diagnostic", "diagnostics", "study", "studies"], + "diagnostic" => ["diagnostic", "diagnostics", "test", "tests", "study", "studies"], + "medication" => ["medication", "medications", "medicine", "medicines", "drug", "drugs", "treatment", "treatments", "therapy", "therapies"], + "procedure" => ["procedure", "procedures", "intervention", "interventions", "treatment", "treatments"], + "monitoring" => ["monitor", "monitoring", "treatment", "treatments"], + "disposition" => ["disposition", "admission", "admit", "transfer", "discharge"], + _ => [category.Trim().ToLowerInvariant()] + }; } diff --git a/api/QuestionsApi/Services/DashboardStore.cs b/api/QuestionsApi/Services/DashboardStore.cs index 2631977..44fe6da 100644 --- a/api/QuestionsApi/Services/DashboardStore.cs +++ b/api/QuestionsApi/Services/DashboardStore.cs @@ -1,5 +1,6 @@ using MongoDB.Driver; using QuestionsApi.Models; +using QuestionsApi.Services.Flashcards; namespace QuestionsApi.Services; @@ -15,8 +16,17 @@ public sealed class DashboardStore private readonly IMongoCollection _notes; private readonly QuestionStore _questions; private readonly ReviewQueueStore _reviewQueue; + private readonly DynamicQuestionStore _dynamicQuestions; + private readonly FlashcardStudyService _flashcards; + private readonly FlashcardScheduleStore _flashcardSchedules; - public DashboardStore(MongoDatabaseProvider mongo, QuestionStore questions, ReviewQueueStore reviewQueue) + public DashboardStore( + MongoDatabaseProvider mongo, + QuestionStore questions, + ReviewQueueStore reviewQueue, + DynamicQuestionStore dynamicQuestions, + FlashcardStudyService flashcards, + FlashcardScheduleStore flashcardSchedules) { _attempts = mongo.Database.GetCollection("QuestionAttempts"); _ratings = mongo.Database.GetCollection("UserRatings"); @@ -28,6 +38,9 @@ public sealed class DashboardStore _notes = mongo.Database.GetCollection("UserNotepads"); _questions = questions; _reviewQueue = reviewQueue; + _dynamicQuestions = dynamicQuestions; + _flashcards = flashcards; + _flashcardSchedules = flashcardSchedules; } public async Task GetAsync(string userId, DateTime nowUtc) @@ -45,8 +58,12 @@ public sealed class DashboardStore var bookmarkCountTask = _bookmarks.CountDocumentsAsync(value => value.UserId == userId); var notepadTask = _notes.Find(value => value.UserId == userId).FirstOrDefaultAsync(); var reviewAttemptsTask = _reviewQueue.GetReviewsSinceAsync(userId, weekStart); + var flashcardCountsTask = _flashcards.GetDashboardCountsAsync(userId, nowUtc, weekStart); + var flashcardReviewsTask = _flashcardSchedules.GetReviewsSinceAsync(userId, weekStart); + var dynamicReviewsTask = _dynamicQuestions.DueReviewsAsync(userId, nowUtc); await Task.WhenAll(attemptsTask, ratingTask, ccsRatingTask, ccsAttemptsTask, completedCcsCountTask, - simulationsTask, bookmarkCountTask, notepadTask, reviewAttemptsTask); + simulationsTask, bookmarkCountTask, notepadTask, reviewAttemptsTask, flashcardCountsTask, + flashcardReviewsTask, dynamicReviewsTask); var attempts = attemptsTask.Result; var answeredAttempts = attempts.Where(value => !value.IsSkip).ToList(); @@ -71,7 +88,11 @@ public sealed class DashboardStore QuestionsThisWeek = answeredAttempts.Count(value => value.CreatedUtc >= weekStart), BookmarkCount = checked((int)bookmarkCountTask.Result), NoteCount = CountNotes(notepadTask.Result?.Content), - MissedQuestionCount = checked((int)pendingReviewCount) + MissedQuestionCount = checked((int)pendingReviewCount), + DynamicReviewsDue = dynamicReviewsTask.Result.Count, + FlashcardsDue = flashcardCountsTask.Result.Due, + FlashcardsNewAvailable = flashcardCountsTask.Result.NewAvailable, + FlashcardsReviewedThisWeek = flashcardCountsTask.Result.ReviewedThisWeek }; response.PerformanceTrend = Enumerable.Range(0, 30).Select(offset => @@ -115,12 +136,14 @@ public sealed class DashboardStore + dailyCcs.Sum(CcsDurationSeconds) + dailySimulations.Sum(value => value.Responses.Sum(item => Math.Max(0, item.ElapsedSeconds))) + reviewAttemptsTask.Result.Where(value => value.CreatedUtc.Date == date).Sum(value => value.DurationSeconds) + + flashcardReviewsTask.Result.Where(value => value.ReviewedUtc.Date == date).Sum(value => value.DurationSeconds) }; }).ToList(); response.StudyTimeSecondsThisWeek = response.WeeklyActivity.Sum(value => value.StudyTimeSeconds); var activeDates = attempts.Select(value => value.CreatedUtc.Date) .Concat(completedCcs.Select(value => value.CompletedUtc!.Value.Date)) - .Concat(simulations.Where(value => value.CompletedUtc.HasValue).Select(value => value.CompletedUtc!.Value.Date)); + .Concat(simulations.Where(value => value.CompletedUtc.HasValue).Select(value => value.CompletedUtc!.Value.Date)) + .Concat(flashcardReviewsTask.Result.Select(value => value.ReviewedUtc.Date)); response.CurrentStreak = CalculateCurrentStreak(activeDates, today); response.RecentActivity = await BuildRecentActivityAsync(answeredAttempts, questionMap, completedCcs, simulations); return response; diff --git a/api/QuestionsApi/Services/DynamicBlueprintCommand.cs b/api/QuestionsApi/Services/DynamicBlueprintCommand.cs new file mode 100644 index 0000000..d68fdac --- /dev/null +++ b/api/QuestionsApi/Services/DynamicBlueprintCommand.cs @@ -0,0 +1,54 @@ +using Microsoft.Extensions.Configuration; +using QuestionsApi.Models; +using System.Text.Json; + +namespace QuestionsApi.Services; + +public static class DynamicBlueprintCommand +{ + public static async Task RunAsync(string[] args, TextWriter output) + { + var apply = args.Contains("--apply", StringComparer.OrdinalIgnoreCase); + var sourceIndex = Array.FindIndex(args, value => value.Equals("--source", StringComparison.OrdinalIgnoreCase)); + var source = sourceIndex >= 0 && sourceIndex + 1 < args.Length + ? args[sourceIndex + 1] + : Path.Combine(AppContext.BaseDirectory, "content", "dynamic_questions"); + if (!Directory.Exists(source)) + { + await output.WriteLineAsync($"Dynamic blueprint directory not found: {source}"); + return 2; + } + var json = new JsonSerializerOptions(JsonSerializerDefaults.Web) { PropertyNameCaseInsensitive = true }; + var blueprints = new List(); + foreach (var file in Directory.EnumerateFiles(source, "*.json", SearchOption.AllDirectories)) + blueprints.AddRange(JsonSerializer.Deserialize>(await File.ReadAllTextAsync(file), json) ?? []); + blueprints = DynamicBlueprintResolver.ResolveAll(blueprints); + + var generator = new DynamicQuestionGenerator(); + var generatedVariants = 0; + foreach (var blueprint in blueprints) + { + var error = DynamicQuestionGenerator.Validate(blueprint); + if (error is not null) + throw new InvalidDataException($"{blueprint.ExternalId} v{blueprint.Version}: {error}"); + var validationSeeds = blueprint.QualityStandardVersion >= 2 ? 10_000 : 1_000; + for (var seed = 0; seed < validationSeeds; seed++) + generator.Generate(blueprint, seed.ToString(), seed.ToString()); + generatedVariants += validationSeeds; + } + await output.WriteLineAsync($"Validated {blueprints.Count} blueprints across {generatedVariants:N0} generated variants."); + if (!apply) + { + await output.WriteLineAsync("Dry run only. Pass --apply to publish."); + return 0; + } + var configuration = new ConfigurationBuilder() + .AddJsonFile("appsettings.json", optional: true) + .AddEnvironmentVariables() + .Build(); + var store = new DynamicQuestionStore(new MongoDatabaseProvider(configuration)); + var result = await store.UpsertBlueprintsAsync(blueprints); + await output.WriteLineAsync($"Promotion complete: {result.Inserted} inserted, {result.Unchanged} unchanged."); + return 0; + } +} diff --git a/api/QuestionsApi/Services/DynamicBlueprintCoveragePolicy.cs b/api/QuestionsApi/Services/DynamicBlueprintCoveragePolicy.cs new file mode 100644 index 0000000..b1bc76b --- /dev/null +++ b/api/QuestionsApi/Services/DynamicBlueprintCoveragePolicy.cs @@ -0,0 +1,37 @@ +using QuestionsApi.Models; + +namespace QuestionsApi.Services; + +public static class DynamicBlueprintCoveragePolicy +{ + private static readonly Dictionary SystemWeights = + new(StringComparer.OrdinalIgnoreCase) + { + ["Nutrition"] = 17.5, + ["Biostatistics"] = 12, + ["Cardiovascular"] = 10, + ["Neurologic"] = 9, + ["Nervous System"] = 9, + ["Pulmonary"] = 9, + ["Respiratory"] = 9, + ["Pregnancy"] = 8, + ["Social Sciences"] = 8, + ["Gastrointestinal"] = 7, + ["Immune System"] = 7, + ["Hematology"] = 7, + ["Multisystem"] = 7, + ["Infectious Disease"] = 7, + ["Endocrine"] = 6, + ["Musculoskeletal"] = 6, + ["Behavioral Health"] = 5, + ["Renal"] = 5, + ["Skin"] = 5, + ["Human Development"] = 2 + }; + + public static double Weight(DynamicQuestionBlueprint blueprint) + { + if (blueprint.BlueprintWeight > 0) return blueprint.BlueprintWeight; + return SystemWeights.GetValueOrDefault(blueprint.System, 5); + } +} diff --git a/api/QuestionsApi/Services/DynamicBlueprintResolver.cs b/api/QuestionsApi/Services/DynamicBlueprintResolver.cs new file mode 100644 index 0000000..bb1e3db --- /dev/null +++ b/api/QuestionsApi/Services/DynamicBlueprintResolver.cs @@ -0,0 +1,72 @@ +using QuestionsApi.Models; +using System.Text.Json; + +namespace QuestionsApi.Services; + +public static class DynamicBlueprintResolver +{ + private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web); + + public static List ResolveAll(IEnumerable source) + { + var all = source.ToList(); + var resolved = new List(); + foreach (var blueprint in all.OrderBy(value => value.Version)) + { + if (blueprint.BaseBlueprintVersion <= 0) + { + resolved.Add(blueprint); + continue; + } + var baseBlueprint = resolved.FirstOrDefault(value => + value.ExternalId == blueprint.ExternalId + && value.Version == blueprint.BaseBlueprintVersion) + ?? throw new InvalidDataException( + $"{blueprint.ExternalId} v{blueprint.Version} base version {blueprint.BaseBlueprintVersion} not found"); + var materialized = Clone(baseBlueprint); + materialized.Id = null; + materialized.Version = blueprint.Version; + materialized.BaseBlueprintVersion = blueprint.BaseBlueprintVersion; + materialized.QualityStandardVersion = blueprint.QualityStandardVersion; + materialized.IsPublished = blueprint.IsPublished; + materialized.EncounterFrame = blueprint.EncounterFrame; + materialized.SiteOfCare = blueprint.SiteOfCare; + materialized.BlueprintWeight = blueprint.BlueprintWeight; + materialized.ConceptIds = blueprint.ConceptIds; + materialized.PrerequisiteConceptIds = blueprint.PrerequisiteConceptIds; + materialized.MedicallyReviewedUtc = blueprint.MedicallyReviewedUtc; + materialized.MedicalReviewExpiresUtc = blueprint.MedicalReviewExpiresUtc; + materialized.MedicalReviewer = blueprint.MedicalReviewer; + if (blueprint.Distractors.Count > 0) + materialized.Distractors = blueprint.Distractors; + if (blueprint.CaseFamilies.Count > 0) + materialized.CaseFamilies = blueprint.CaseFamilies.Select(overlay => + { + var baseFamily = baseBlueprint.CaseFamilies.FirstOrDefault(value => value.Id == overlay.BaseFamilyId) + ?? throw new InvalidDataException( + $"{blueprint.ExternalId} v{blueprint.Version} base family {overlay.BaseFamilyId} not found"); + var family = Clone(baseFamily); + family.Id = overlay.Id; + family.BaseFamilyId = overlay.BaseFamilyId; + family.DifficultyTier = overlay.DifficultyTier; + family.NominalElo = overlay.NominalElo; + if (!string.IsNullOrWhiteSpace(family.LeadIn) + && family.StemTemplate.EndsWith(family.LeadIn, StringComparison.Ordinal)) + family.StemTemplate = family.StemTemplate[..^family.LeadIn.Length].TrimEnd(); + if (!string.IsNullOrWhiteSpace(overlay.StemTemplate)) + family.StemTemplate = $"{family.StemTemplate} {overlay.StemTemplate}".Trim(); + if (!string.IsNullOrWhiteSpace(overlay.LeadIn)) + family.LeadIn = overlay.LeadIn; + if (overlay.Distractors.Count > 0) + family.Distractors = overlay.Distractors; + return family; + }).ToList(); + resolved.Add(materialized); + } + return resolved; + } + + private static T Clone(T value) => + JsonSerializer.Deserialize(JsonSerializer.Serialize(value, Json), Json) + ?? throw new InvalidOperationException("Blueprint clone failed."); +} diff --git a/api/QuestionsApi/Services/DynamicDifficultyPolicy.cs b/api/QuestionsApi/Services/DynamicDifficultyPolicy.cs new file mode 100644 index 0000000..0cd2b91 --- /dev/null +++ b/api/QuestionsApi/Services/DynamicDifficultyPolicy.cs @@ -0,0 +1,69 @@ +using QuestionsApi.Models; + +namespace QuestionsApi.Services; + +public static class DynamicDifficultyPolicy +{ + public const int MinimumElo = 600; + public const int MaximumElo = 1800; + public const int TargetOffset = 70; + public static readonly IReadOnlyDictionary TierElos = + new Dictionary(StringComparer.Ordinal) + { + ["foundation"] = 850, + ["standard"] = 1050, + ["advanced"] = 1250, + ["expert"] = 1450 + }; + + public static int EffectiveAbility( + int overallElo, + int categoryElo, + DynamicObjectiveMastery? objective) + { + var bankAbility = (int)Math.Round((overallElo + categoryElo) / 2.0); + if (objective is null || objective.Attempts <= 0) + return Clamp(bankAbility); + var objectiveWeight = Math.Min(0.8, objective.Attempts * 0.08); + return Clamp((int)Math.Round(bankAbility * (1.0 - objectiveWeight) + objective.Elo * objectiveWeight)); + } + + public static int TargetFamilyElo(int effectiveAbility) => Clamp(effectiveAbility - TargetOffset); + + public static double ExpectedScore(int playerElo, int familyElo) => + 1.0 / (1.0 + Math.Pow(10, (familyElo - playerElo) / 400.0)); + + public static int LearnerDelta(int playerElo, int familyElo, bool correct, int attempts) => + (int)Math.Round((attempts < 5 ? 40 : 24) + * ((correct ? 1.0 : 0.0) - ExpectedScore(playerElo, familyElo))); + + public static int FamilyDelta(int playerElo, int familyElo, bool correct, int attempts) => + (int)Math.Round((attempts < 20 ? 32 : 24) + * (ExpectedScore(playerElo, familyElo) - (correct ? 1.0 : 0.0))); + + public static DynamicCaseFamily SelectFamily( + IReadOnlyList families, + IReadOnlyDictionary ratings, + int targetElo, + ISet recentFamilyIds, + int tieBreaker) + { + if (families.Count == 0) throw new InvalidOperationException("No case families are available."); + var eligible = families.Where(value => !recentFamilyIds.Contains(value.Id)).ToList(); + if (eligible.Count == 0) eligible = families.ToList(); + var ranked = eligible + .Select(value => new + { + Family = value, + Elo = ratings.TryGetValue(value.Id, out var rating) ? rating.Elo : value.NominalElo + }) + .OrderBy(value => Math.Abs(value.Elo - targetElo)) + .ThenBy(value => value.Family.Id, StringComparer.Ordinal) + .ToList(); + var bestDistance = Math.Abs(ranked[0].Elo - targetElo); + var tied = ranked.Where(value => Math.Abs(value.Elo - targetElo) == bestDistance).ToList(); + return tied[Math.Abs(tieBreaker) % tied.Count].Family; + } + + public static int Clamp(int elo) => Math.Clamp(elo, MinimumElo, MaximumElo); +} diff --git a/api/QuestionsApi/Services/DynamicQuestionGenerator.cs b/api/QuestionsApi/Services/DynamicQuestionGenerator.cs new file mode 100644 index 0000000..d19ecce --- /dev/null +++ b/api/QuestionsApi/Services/DynamicQuestionGenerator.cs @@ -0,0 +1,448 @@ +using QuestionsApi.Models; +using System.Globalization; +using System.Security.Cryptography; +using System.Text; +using System.Text.RegularExpressions; + +namespace QuestionsApi.Services; + +public sealed class DynamicQuestionGenerator +{ + public const int CurrentVersion = 2; + private static readonly Regex Placeholder = new(@"\{\{([a-zA-Z0-9_]+)\}\}", RegexOptions.Compiled); + + public DynamicGeneratedQuestion Generate( + DynamicQuestionBlueprint blueprint, + string seed, + string nonce, + int generatorVersion = CurrentVersion, + string caseFamilyId = "") + => blueprint.QualityStandardVersion >= 2 + ? GenerateV2(blueprint, seed, nonce, caseFamilyId) + : GenerateLegacy(blueprint, seed, nonce, generatorVersion); + + private DynamicGeneratedQuestion GenerateLegacy(DynamicQuestionBlueprint blueprint, string seed, string nonce, int generatorVersion) + { + var draw = new DeterministicDraw($"{blueprint.ExternalId}|{blueprint.Version}|{generatorVersion}|{seed}"); + var values = new Dictionary(StringComparer.Ordinal); + var numbers = new Dictionary(StringComparer.Ordinal); + foreach (var item in blueprint.Variables.OrderBy(value => value.Key, StringComparer.Ordinal)) + { + var definition = item.Value; + if (definition.Type == "enum") + { + if (definition.Values.Count == 0) throw new InvalidOperationException($"{item.Key} requires values"); + values[item.Key] = definition.Values[draw.Next(definition.Values.Count)]; + continue; + } + var step = definition.Step <= 0 ? 1 : definition.Step; + var count = (int)Math.Floor((definition.Maximum - definition.Minimum) / step) + 1; + if (count < 1) throw new InvalidOperationException($"{item.Key} range is invalid"); + var number = definition.Minimum + draw.Next(count) * step; + number = Math.Round(number, Math.Clamp(definition.Precision, 0, 8)); + numbers[item.Key] = number; + values[item.Key] = Format(number, definition.Precision); + } + foreach (var item in blueprint.Derived) + { + var definition = item.Value; + var left = ResolveNumber(definition.Left, numbers); + var right = ResolveNumber(definition.Right, numbers); + var number = definition.Operation switch + { + "add" => left + right, + "subtract" => left - right, + "multiply" => left * right, + "divide" when Math.Abs(right) > double.Epsilon => left / right, + _ => throw new InvalidOperationException($"{item.Key} operation is invalid") + }; + number = Math.Round(number, Math.Clamp(definition.Precision, 0, 8)); + numbers[item.Key] = number; + values[item.Key] = Format(number, definition.Precision); + } + foreach (var constraint in blueprint.Constraints) + { + if (!numbers.TryGetValue(constraint.Field, out var actual)) + throw new InvalidOperationException($"constraint field {constraint.Field} is not numeric"); + var valid = constraint.Operator switch + { + "lt" => actual < constraint.Value, + "lte" => actual <= constraint.Value, + "gt" => actual > constraint.Value, + "gte" => actual >= constraint.Value, + "eq" => Math.Abs(actual - constraint.Value) < 0.0000001, + "neq" => Math.Abs(actual - constraint.Value) >= 0.0000001, + _ => false + }; + if (!valid) throw new InvalidOperationException($"generated value violates {constraint.Field} {constraint.Operator}"); + } + + var distractors = blueprint.Distractors.OrderBy(_ => draw.Next(int.MaxValue)).Take(4).ToList(); + if (distractors.Count != 4) throw new InvalidOperationException("at least four distractors are required"); + var answerTemplates = distractors.Append(blueprint.CorrectAnswer) + .OrderBy(_ => draw.Next(int.MaxValue)).ToList(); + var choices = answerTemplates.Select(answer => new QuestionChoice + { + Id = answer.Id, + Text = Render(answer.TextTemplate, values) + }).ToList(); + if (choices.Select(value => value.Id).Distinct(StringComparer.Ordinal).Count() != 5 + || choices.Select(value => value.Text).Distinct(StringComparer.OrdinalIgnoreCase).Count() != 5) + throw new InvalidOperationException("generated choices must be unique"); + + var question = new Question + { + Id = $"dynamic-{nonce}", + ExternalId = $"dynamic-{nonce}", + Stem = Render(blueprint.StemTemplate, values), + LeadIn = Render(blueprint.LeadIn, values), + Choices = choices, + CorrectChoiceId = blueprint.CorrectAnswer.Id, + Explanation = Render(blueprint.ExplanationTemplate, values), + KeyTakeaway = Render(blueprint.KeyTakeaway, values), + UsmlePearl = Render(blueprint.UsmlePearl, values), + ChoiceExplanations = answerTemplates.ToDictionary( + value => value.Id, + value => Render(value.ExplanationTemplate, values)), + Discipline = blueprint.Discipline, + System = blueprint.System, + PerformanceCategory = blueprint.PerformanceCategory, + Difficulty = blueprint.Difficulty, + Labs = blueprint.Labs.Select(lab => new QuestionLab + { + Name = lab.Name, + Value = Render(lab.ValueTemplate, values), + Unit = lab.Unit, + Reference = lab.Reference + }).ToList(), + Tags = [.. blueprint.Tags, "dynamic", $"objective:{blueprint.ObjectiveId}"], + References = blueprint.References, + IsPublished = true + }; + return new DynamicGeneratedQuestion { Question = question, Blueprint = blueprint }; + } + + private DynamicGeneratedQuestion GenerateV2(DynamicQuestionBlueprint blueprint, string seed, string nonce, string caseFamilyId) + { + if (blueprint.CaseFamilies.Count == 0) + throw new InvalidOperationException("quality-standard-2 blueprints require case families"); + var draw = new DeterministicDraw($"{blueprint.ExternalId}|{blueprint.Version}|{CurrentVersion}|{seed}"); + var orderedFamilies = blueprint.CaseFamilies.OrderBy(value => value.Id, StringComparer.Ordinal).ToList(); + var family = string.IsNullOrWhiteSpace(caseFamilyId) + ? orderedFamilies[draw.Next(orderedFamilies.Count)] + : orderedFamilies.FirstOrDefault(value => value.Id == caseFamilyId) + ?? throw new InvalidOperationException($"case family {caseFamilyId} is unavailable"); + var values = new Dictionary(StringComparer.Ordinal); + var numbers = new Dictionary(StringComparer.Ordinal); + if (family.ValueBundles.Count > 0) + foreach (var item in family.ValueBundles[draw.Next(family.ValueBundles.Count)]) + values[item.Key] = item.Value; + PopulateVariables(family.Variables, values, numbers, draw); + PopulateDerived(family.Derived, values, numbers); + ValidateConstraints(family.Constraints, numbers); + + var correctAnswer = string.IsNullOrWhiteSpace(family.CorrectAnswer.Id) ? blueprint.CorrectAnswer : family.CorrectAnswer; + var distractorPool = family.Distractors.Count == 0 ? blueprint.Distractors : family.Distractors; + var reviewTemplates = family.ReviewFollowups.Count == 0 ? blueprint.ReviewFollowups : family.ReviewFollowups; + var wrongTemplates = family.WrongAnswerFollowups.Count == 0 ? blueprint.WrongAnswerFollowups : family.WrongAnswerFollowups; + var explanationTemplate = string.IsNullOrWhiteSpace(family.ExplanationTemplate) ? blueprint.ExplanationTemplate : family.ExplanationTemplate; + var keyTakeaway = string.IsNullOrWhiteSpace(family.KeyTakeaway) ? blueprint.KeyTakeaway : family.KeyTakeaway; + var usmlePearl = string.IsNullOrWhiteSpace(family.UsmlePearl) ? blueprint.UsmlePearl : family.UsmlePearl; + var distractors = distractorPool.OrderBy(_ => draw.Next(int.MaxValue)).Take(4).ToList(); + if (distractors.Count != 4) throw new InvalidOperationException($"{family.Id} requires at least four distractors"); + var answerTemplates = distractors.Append(correctAnswer).OrderBy(_ => draw.Next(int.MaxValue)).ToList(); + var choices = answerTemplates.Select(answer => new QuestionChoice + { + Id = answer.Id, + Text = Render(answer.TextTemplate, values) + }).ToList(); + if (choices.Select(value => value.Id).Distinct(StringComparer.Ordinal).Count() != 5 + || choices.Select(value => value.Text).Distinct(StringComparer.OrdinalIgnoreCase).Count() != 5) + throw new InvalidOperationException($"{family.Id} generated choices must be unique"); + + var question = new Question + { + Id = $"dynamic-{nonce}", + ExternalId = $"dynamic-{nonce}", + Stem = Render(family.StemTemplate, values), + LeadIn = Render(family.LeadIn, values), + Choices = choices, + CorrectChoiceId = correctAnswer.Id, + Explanation = Render(explanationTemplate, values), + KeyTakeaway = Render(keyTakeaway, values), + UsmlePearl = Render(usmlePearl, values), + ChoiceExplanations = answerTemplates.ToDictionary( + value => value.Id, value => Render(value.ExplanationTemplate, values)), + ReviewFollowups = reviewTemplates.Select(followup => new QuestionReviewFollowup + { + Prompt = Render(followup.PromptTemplate, values), + Choices = followup.Choices.Select(choice => new QuestionChoice + { Id = choice.Id, Text = Render(choice.TextTemplate, values) }).ToList(), + CorrectChoiceId = followup.CorrectChoiceId, + TeachingPoint = Render(followup.TeachingPointTemplate, values) + }).ToList(), + WrongAnswerFollowups = BuildWrongAnswerFollowups( + distractors, correctAnswer, wrongTemplates, values), + Discipline = blueprint.Discipline, + System = blueprint.System, + PerformanceCategory = blueprint.PerformanceCategory, + Difficulty = family.DifficultyTier switch + { + "foundation" => 1, + "standard" => 2, + "advanced" => 4, + "expert" => 5, + _ => blueprint.Difficulty + }, + Labs = family.Labs.Select(lab => new QuestionLab + { + Name = lab.Name, + Value = Render(lab.ValueTemplate, values), + Unit = lab.Unit, + Reference = lab.Reference + }).ToList(), + Tags = [.. blueprint.Tags, "dynamic", "quality-standard:2", $"objective:{blueprint.ObjectiveId}", $"family:{family.Id}"], + References = blueprint.References, + IsPublished = true + }; + return new DynamicGeneratedQuestion { Question = question, Blueprint = blueprint, CaseFamilyId = family.Id }; + } + + private static void PopulateVariables( + IReadOnlyDictionary definitions, + IDictionary values, + IDictionary numbers, + DeterministicDraw draw) + { + foreach (var item in definitions.OrderBy(value => value.Key, StringComparer.Ordinal)) + { + var definition = item.Value; + if (definition.Type == "enum") + { + if (definition.Values.Count == 0) throw new InvalidOperationException($"{item.Key} requires values"); + values[item.Key] = definition.Values[draw.Next(definition.Values.Count)]; + continue; + } + var step = definition.Step <= 0 ? 1 : definition.Step; + var count = (int)Math.Floor((definition.Maximum - definition.Minimum) / step) + 1; + if (count < 1) throw new InvalidOperationException($"{item.Key} range is invalid"); + var number = Math.Round(definition.Minimum + draw.Next(count) * step, Math.Clamp(definition.Precision, 0, 8)); + numbers[item.Key] = number; + values[item.Key] = Format(number, definition.Precision); + } + } + + private static void PopulateDerived( + IReadOnlyDictionary definitions, + IDictionary values, + Dictionary numbers) + { + foreach (var item in definitions) + { + var definition = item.Value; + var left = ResolveNumber(definition.Left, numbers); + var right = ResolveNumber(definition.Right, numbers); + var number = definition.Operation switch + { + "add" => left + right, + "subtract" => left - right, + "multiply" => left * right, + "divide" when Math.Abs(right) > double.Epsilon => left / right, + _ => throw new InvalidOperationException($"{item.Key} operation is invalid") + }; + number = Math.Round(number, Math.Clamp(definition.Precision, 0, 8)); + numbers[item.Key] = number; + values[item.Key] = Format(number, definition.Precision); + } + } + + private static void ValidateConstraints(IEnumerable constraints, IReadOnlyDictionary numbers) + { + foreach (var constraint in constraints) + { + if (!numbers.TryGetValue(constraint.Field, out var actual)) + throw new InvalidOperationException($"constraint field {constraint.Field} is not numeric"); + var valid = constraint.Operator switch + { + "lt" => actual < constraint.Value, + "lte" => actual <= constraint.Value, + "gt" => actual > constraint.Value, + "gte" => actual >= constraint.Value, + "eq" => Math.Abs(actual - constraint.Value) < 0.0000001, + "neq" => Math.Abs(actual - constraint.Value) >= 0.0000001, + _ => false + }; + if (!valid) throw new InvalidOperationException($"generated value violates {constraint.Field} {constraint.Operator}"); + } + } + + private static Dictionary BuildWrongAnswerFollowups( + IEnumerable distractors, + DynamicAnswerTemplate correct, + IReadOnlyDictionary templates, + IReadOnlyDictionary values) + { + var result = new Dictionary(); + foreach (var distractor in distractors) + { + if (templates.TryGetValue(distractor.Id, out var template)) + { + result[distractor.Id] = new WrongAnswerFollowup + { + Prompt = Render(template.PromptTemplate, values), + Choices = template.Choices.Select(choice => new QuestionChoice + { Id = choice.Id, Text = Render(choice.TextTemplate, values) }).ToList(), + CorrectChoiceId = template.CorrectChoiceId, + TeachingPoint = Render(template.TeachingPointTemplate, values) + }; + continue; + } + result[distractor.Id] = new WrongAnswerFollowup + { + Prompt = "Which option best addresses the decisive finding in this case?", + Choices = + [ + new QuestionChoice { Id = correct.Id, Text = Render(correct.TextTemplate, values) }, + new QuestionChoice { Id = distractor.Id, Text = Render(distractor.TextTemplate, values) } + ], + CorrectChoiceId = correct.Id, + TeachingPoint = $"{Render(distractor.ExplanationTemplate, values)} {Render(correct.ExplanationTemplate, values)}" + }; + } + return result; + } + + public static string? Validate(DynamicQuestionBlueprint blueprint) + { + if (string.IsNullOrWhiteSpace(blueprint.ExternalId)) return "externalId required"; + if (string.IsNullOrWhiteSpace(blueprint.ObjectiveId)) return "objectiveId required"; + if (blueprint.Version < 1) return "version must be positive"; + if (blueprint.BlueprintWeight < 0) return "blueprintWeight cannot be negative"; + if (blueprint.IsPublished + && blueprint.MedicalReviewExpiresUtc is { } expiresUtc + && expiresUtc <= DateTime.UtcNow) + return "medical review has expired"; + if (blueprint.QualityStandardVersion >= 2) + return ValidateV2(blueprint); + if (string.IsNullOrWhiteSpace(blueprint.StemTemplate) || string.IsNullOrWhiteSpace(blueprint.LeadIn)) return "stemTemplate and leadIn required"; + if (string.IsNullOrWhiteSpace(blueprint.CorrectAnswer.Id) || string.IsNullOrWhiteSpace(blueprint.CorrectAnswer.TextTemplate)) return "correctAnswer required"; + if (blueprint.Distractors.Count < 4) return "at least four distractors required"; + var ids = blueprint.Distractors.Select(value => value.Id).Append(blueprint.CorrectAnswer.Id).ToList(); + if (ids.Any(string.IsNullOrWhiteSpace) || ids.Distinct(StringComparer.Ordinal).Count() != ids.Count) return "answer ids must be unique"; + if (blueprint.References.Count == 0) return "at least one reference required"; + try + { + var generator = new DynamicQuestionGenerator(); + for (var index = 0; index < 100; index++) + generator.Generate(blueprint, $"validation-{index}", $"validation-{index}"); + } + catch (Exception exception) { return exception.Message; } + return null; + } + + private static string? ValidateV2(DynamicQuestionBlueprint blueprint) + { + if (string.IsNullOrWhiteSpace(blueprint.CorrectConceptId)) return "correctConceptId required"; + if (blueprint.CaseFamilies.Count < 3) return "at least three case families required"; + if (blueprint.References.Count == 0) return "at least one reference required"; + if (blueprint.CaseFamilies.Select(value => value.Id).Distinct(StringComparer.Ordinal).Count() != blueprint.CaseFamilies.Count) + return "case family ids must be unique"; + if (blueprint.QualityStandardVersion >= 3) + { + foreach (var tier in DynamicDifficultyPolicy.TierElos) + { + var tierFamilies = blueprint.CaseFamilies + .Where(value => value.DifficultyTier == tier.Key).ToList(); + if (tierFamilies.Count < 2) return $"at least two {tier.Key} case families required"; + if (tierFamilies.Any(value => Math.Abs(value.NominalElo - tier.Value) > 100)) + return $"{tier.Key} nominal Elo must be within 100 points of {tier.Value}"; + } + if (blueprint.CaseFamilies.Any(value => !DynamicDifficultyPolicy.TierElos.ContainsKey(value.DifficultyTier))) + return "difficultyTier must be foundation, standard, advanced, or expert"; + } + if (blueprint.QualityStandardVersion >= 4) + { + if (blueprint.BlueprintWeight <= 0) return "quality standard 4 requires blueprintWeight"; + if (string.IsNullOrWhiteSpace(blueprint.System) + || string.IsNullOrWhiteSpace(blueprint.PhysicianTask) + || string.IsNullOrWhiteSpace(blueprint.EncounterFrame) + || string.IsNullOrWhiteSpace(blueprint.SiteOfCare)) + return "quality standard 4 requires system, physicianTask, encounterFrame, and siteOfCare"; + if (blueprint.ConceptIds.Count == 0) return "quality standard 4 requires conceptIds"; + if (blueprint.MedicallyReviewedUtc is null + || blueprint.MedicalReviewExpiresUtc is null + || string.IsNullOrWhiteSpace(blueprint.MedicalReviewer)) + return "quality standard 4 requires medical review metadata"; + if (blueprint.Distractors.Any(value => string.IsNullOrWhiteSpace(value.MisconceptionId))) + return "quality standard 4 requires a misconceptionId for every distractor"; + } + foreach (var family in blueprint.CaseFamilies) + { + if (string.IsNullOrWhiteSpace(family.Id)) return "case family id required"; + if (family.StemTemplate.Length < 220) return $"{family.Id} stem is too short for quality standard 2"; + if (string.IsNullOrWhiteSpace(family.LeadIn)) return $"{family.Id} leadIn required"; + if (!string.IsNullOrWhiteSpace(family.CorrectAnswer.Id)) + return $"{family.Id} must use the blueprint-level invariant correct answer"; + var correctAnswer = string.IsNullOrWhiteSpace(family.CorrectAnswer.Id) ? blueprint.CorrectAnswer : family.CorrectAnswer; + var distractors = family.Distractors.Count == 0 ? blueprint.Distractors : family.Distractors; + var reviews = family.ReviewFollowups.Count == 0 ? blueprint.ReviewFollowups : family.ReviewFollowups; + var wrong = family.WrongAnswerFollowups.Count == 0 ? blueprint.WrongAnswerFollowups : family.WrongAnswerFollowups; + if (distractors.Count < 6) return $"{family.Id} requires at least six distractors"; + if (blueprint.QualityStandardVersion >= 4 + && distractors.Any(value => string.IsNullOrWhiteSpace(value.MisconceptionId))) + return $"{family.Id} every distractor requires a misconceptionId"; + var answers = distractors.Append(correctAnswer).ToList(); + if (answers.Any(value => string.IsNullOrWhiteSpace(value.Id) + || string.IsNullOrWhiteSpace(value.TextTemplate) + || string.IsNullOrWhiteSpace(value.ExplanationTemplate))) + return $"{family.Id} every answer requires id, text, and explanation"; + if (answers.Select(value => value.Id).Distinct(StringComparer.Ordinal).Count() != answers.Count) + return $"{family.Id} answer ids must be unique"; + if (reviews.Count == 0) return $"{family.Id} review followup required"; + if (string.IsNullOrWhiteSpace(family.ExplanationTemplate) && string.IsNullOrWhiteSpace(blueprint.ExplanationTemplate) + || string.IsNullOrWhiteSpace(family.KeyTakeaway) && string.IsNullOrWhiteSpace(blueprint.KeyTakeaway) + || string.IsNullOrWhiteSpace(family.UsmlePearl) && string.IsNullOrWhiteSpace(blueprint.UsmlePearl)) + return $"{family.Id} complete teaching content required"; + } + try + { + var generator = new DynamicQuestionGenerator(); + for (var index = 0; index < 300; index++) + { + var generated = generator.Generate(blueprint, $"validation-{index}", $"validation-{index}"); + if (generated.Question.ChoiceExplanations.Count != 5) return "every displayed choice requires an explanation"; + } + } + catch (Exception exception) { return exception.Message; } + return null; + } + + private static string Render(string template, IReadOnlyDictionary values) + { + var rendered = Placeholder.Replace(template ?? "", match => + values.TryGetValue(match.Groups[1].Value, out var value) ? value : match.Value); + if (Placeholder.IsMatch(rendered)) throw new InvalidOperationException($"unresolved template in {rendered}"); + return rendered; + } + + private static double ResolveNumber(string operand, IReadOnlyDictionary numbers) => + numbers.TryGetValue(operand, out var value) + ? value + : double.TryParse(operand, NumberStyles.Float, CultureInfo.InvariantCulture, out value) + ? value + : throw new InvalidOperationException($"unknown numeric operand {operand}"); + + private static string Format(double value, int precision) => + value.ToString(precision > 0 ? $"F{precision}" : "0", CultureInfo.InvariantCulture); + + private sealed class DeterministicDraw(string source) + { + private int _counter; + public int Next(int maximum) + { + if (maximum <= 0) throw new ArgumentOutOfRangeException(nameof(maximum)); + var bytes = SHA256.HashData(Encoding.UTF8.GetBytes($"{source}|{_counter++}")); + return (int)(BitConverter.ToUInt32(bytes, 0) % (uint)maximum); + } + } +} diff --git a/api/QuestionsApi/Services/DynamicQuestionService.cs b/api/QuestionsApi/Services/DynamicQuestionService.cs new file mode 100644 index 0000000..074481a --- /dev/null +++ b/api/QuestionsApi/Services/DynamicQuestionService.cs @@ -0,0 +1,192 @@ +using QuestionsApi.Models; +using System.Security.Cryptography; + +namespace QuestionsApi.Services; + +public sealed class DynamicQuestionService +{ + private readonly DynamicQuestionStore _store; + private readonly DynamicQuestionGenerator _generator; + private readonly DynamicQuestionTokenService _tokens; + private readonly RatingStore _ratings; + private readonly ServiceMetrics _metrics; + + public DynamicQuestionService( + DynamicQuestionStore store, + DynamicQuestionGenerator generator, + DynamicQuestionTokenService tokens, + RatingStore ratings, + ServiceMetrics metrics) + { + _store = store; + _generator = generator; + _tokens = tokens; + _ratings = ratings; + _metrics = metrics; + } + + public Task StatusAsync(string userId) => _store.StatusAsync(userId); + + public async Task NextAsync(string userId, string runToken) + { + var blueprints = await _store.PublishedAsync(); + if (blueprints.Count == 0) + throw new InvalidOperationException("No dynamic question blueprints are published."); + + var run = string.IsNullOrWhiteSpace(runToken) + ? NewRun(userId) + : _tokens.ReadRun(runToken, userId); + var byObjective = blueprints.GroupBy(value => value.ObjectiveId).ToList(); + var objectiveIds = byObjective.Select(value => value.Key).ToHashSet(StringComparer.Ordinal); + run.SeenObjectiveIds = run.SeenObjectiveIds.Where(objectiveIds.Contains).Distinct(StringComparer.Ordinal).ToList(); + + var unseen = byObjective.Where(group => !run.SeenObjectiveIds.Contains(group.Key, StringComparer.Ordinal)).ToList(); + if (unseen.Count == 0) + { + run.Cycle++; + run.SeenObjectiveIds.Clear(); + unseen = byObjective; + } + + var mastery = await _store.MasteryAsync(userId); + var dueReviews = await _store.DueReviewsAsync(userId, DateTime.UtcNow); + var dueByObjective = dueReviews.ToDictionary(value => value.ObjectiveId, StringComparer.Ordinal); + var candidates = unseen + .Where(group => group.Key != run.PreviousObjectiveId || unseen.Count == 1) + .ToList(); + var roll = RandomNumberGenerator.GetInt32(100); + IGrouping selectedObjective; + var dueCandidates = candidates.Where(value => dueByObjective.ContainsKey(value.Key)).ToList(); + if (roll < 50 && dueCandidates.Count == 0 && dueByObjective.Count > 0) + dueCandidates = byObjective + .Where(value => dueByObjective.ContainsKey(value.Key) + && (value.Key != run.PreviousObjectiveId || dueByObjective.Count == 1)) + .ToList(); + if (roll < 50 && dueCandidates.Count > 0) + { + selectedObjective = dueCandidates + .OrderBy(value => dueByObjective[value.Key].DueUtc) + .ThenBy(_ => RandomNumberGenerator.GetInt32(int.MaxValue)) + .First(); + } + else if (roll < 80) + { + var systemAttempts = byObjective + .GroupBy(group => group.OrderByDescending(value => value.Version).First().System) + .ToDictionary( + group => group.Key, + group => group.Sum(objective => mastery.TryGetValue(objective.Key, out var value) ? value.Attempts : 0), + StringComparer.Ordinal); + selectedObjective = candidates + .OrderBy(group => + { + var latest = group.OrderByDescending(value => value.Version).First(); + var weight = DynamicBlueprintCoveragePolicy.Weight(latest); + return (systemAttempts.GetValueOrDefault(latest.System) + 1) / weight; + }) + .ThenBy(_ => RandomNumberGenerator.GetInt32(int.MaxValue)) + .First(); + } + else + { + var weakestBand = candidates + .OrderBy(group => mastery.TryGetValue(group.Key, out var value) ? value.Elo : 1000) + .Take(Math.Min(4, candidates.Count)) + .ToList(); + selectedObjective = weakestBand[RandomNumberGenerator.GetInt32(weakestBand.Count)]; + } + var blueprint = selectedObjective + .OrderByDescending(value => value.Version) + .First(); + var ratingProfile = await _ratings.GetProfileAsync(userId); + mastery.TryGetValue(blueprint.ObjectiveId, out var objectiveMastery); + var category = RatingCategory.Normalize( + blueprint.PerformanceCategory, blueprint.System, blueprint.Discipline); + var categoryElo = ratingProfile.CategoryElos.GetValueOrDefault(category, ratingProfile.OverallElo); + var effectiveAbility = DynamicDifficultyPolicy.EffectiveAbility( + ratingProfile.OverallElo, categoryElo, objectiveMastery); + var targetElo = DynamicDifficultyPolicy.TargetFamilyElo(effectiveAbility); + var familyRatings = await _store.FamilyRatingsAsync(blueprint); + var blueprintFamilyIds = blueprint.CaseFamilies.Select(value => value.Id).ToHashSet(StringComparer.Ordinal); + var recentForObjective = run.RecentCaseFamilyIds + .Where(blueprintFamilyIds.Contains) + .TakeLast(Math.Max(1, blueprint.CaseFamilies.Count / 4)) + .ToHashSet(StringComparer.Ordinal); + var selectedFamily = DynamicDifficultyPolicy.SelectFamily( + blueprint.CaseFamilies, + familyRatings, + targetElo, + recentForObjective, + RandomNumberGenerator.GetInt32(int.MaxValue)); + var selectedFamilyElo = familyRatings.TryGetValue(selectedFamily.Id, out var selectedRating) + ? selectedRating.Elo + : selectedFamily.NominalElo; + _metrics.DynamicSelected(selectedFamily.DifficultyTier, selectedFamilyElo - targetElo); + + var claims = new DynamicGenerationClaims + { + UserId = userId, + BlueprintExternalId = blueprint.ExternalId, + BlueprintVersion = blueprint.Version, + ObjectiveId = blueprint.ObjectiveId, + Seed = Convert.ToHexString(RandomNumberGenerator.GetBytes(16)).ToLowerInvariant(), + Nonce = Guid.NewGuid().ToString("N"), + GeneratorVersion = DynamicQuestionGenerator.CurrentVersion, + CaseFamilyId = selectedFamily.Id, + DifficultyTier = selectedFamily.DifficultyTier, + EffectiveAbilityElo = effectiveAbility, + FamilyElo = selectedFamilyElo, + IsScheduledReview = dueByObjective.TryGetValue(blueprint.ObjectiveId, out var reviewItem), + ReviewItemId = reviewItem?.Id ?? "", + ReviewStage = reviewItem?.Stage ?? 0, + ExpiresUtc = DateTime.UtcNow.AddDays(7) + }; + var generated = _generator.Generate( + blueprint, claims.Seed, claims.Nonce, claims.GeneratorVersion, claims.CaseFamilyId); + if (!run.SeenObjectiveIds.Contains(blueprint.ObjectiveId, StringComparer.Ordinal)) + run.SeenObjectiveIds.Add(blueprint.ObjectiveId); + run.PreviousObjectiveId = blueprint.ObjectiveId; + run.RecentCaseFamilyIds.Add(generated.CaseFamilyId); + run.RecentCaseFamilyIds = run.RecentCaseFamilyIds.TakeLast(30).ToList(); + run.ExpiresUtc = DateTime.UtcNow.AddDays(7); + return new DynamicQuestionEnvelope + { + Question = QuestionResponse.FromModel(generated.Question, questionElo: selectedFamilyElo), + ObjectiveId = blueprint.ObjectiveId, + ObjectiveTitle = blueprint.Title, + GenerationToken = _tokens.SignGeneration(claims), + RunToken = _tokens.SignRun(run), + CycleSeen = run.SeenObjectiveIds.Count, + TotalObjectives = byObjective.Count + }; + } + + public async Task AttemptAsync(string userId, DynamicAttemptRequest request) + { + var (claims, generated) = await RegenerateAsync(userId, request.GenerationToken); + return await _store.RecordAsync(claims, generated, request); + } + + public async Task<(DynamicGenerationClaims Claims, DynamicGeneratedQuestion Generated)> RegenerateAsync( + string userId, + string generationToken) + { + var claims = _tokens.ReadGeneration(generationToken, userId); + if (claims.GeneratorVersion is < 1 or > DynamicQuestionGenerator.CurrentVersion) + throw new InvalidOperationException("This generated question version is no longer supported."); + var blueprint = await _store.GetAsync(claims.BlueprintExternalId, claims.BlueprintVersion) + ?? throw new KeyNotFoundException("The question blueprint is no longer available."); + var generated = _generator.Generate( + blueprint, claims.Seed, claims.Nonce, claims.GeneratorVersion, claims.CaseFamilyId); + if (!string.IsNullOrWhiteSpace(claims.CaseFamilyId) + && claims.CaseFamilyId != generated.CaseFamilyId) + throw new InvalidOperationException("Generated case family does not match its signed descriptor."); + return (claims, generated); + } + + private static DynamicRunClaims NewRun(string userId) => new() + { + UserId = userId, + ExpiresUtc = DateTime.UtcNow.AddDays(7) + }; +} diff --git a/api/QuestionsApi/Services/DynamicQuestionStore.cs b/api/QuestionsApi/Services/DynamicQuestionStore.cs new file mode 100644 index 0000000..76aaca3 --- /dev/null +++ b/api/QuestionsApi/Services/DynamicQuestionStore.cs @@ -0,0 +1,364 @@ +using MongoDB.Driver; +using MongoDB.Bson; +using QuestionsApi.Models; + +namespace QuestionsApi.Services; + +public sealed class DynamicQuestionStore +{ + private readonly IMongoCollection _blueprints; + private readonly IMongoCollection _attempts; + private readonly IMongoCollection _mastery; + private readonly IMongoCollection _familyRatings; + private readonly IMongoCollection _reviews; + private readonly ServiceMetrics? _metrics; + + public DynamicQuestionStore(MongoDatabaseProvider mongo, ServiceMetrics? metrics = null) + { + _metrics = metrics; + _blueprints = mongo.Database.GetCollection("DynamicQuestionBlueprints"); + _attempts = mongo.Database.GetCollection("DynamicQuestionAttempts"); + _mastery = mongo.Database.GetCollection("DynamicObjectiveMastery"); + _familyRatings = mongo.Database.GetCollection("DynamicFamilyRatings"); + _reviews = mongo.Database.GetCollection("DynamicReviewItems"); + _blueprints.Indexes.CreateOne(new CreateIndexModel( + Builders.IndexKeys.Ascending(value => value.ExternalId).Ascending(value => value.Version), + new CreateIndexOptions { Unique = true })); + _attempts.Indexes.CreateOne(new CreateIndexModel( + Builders.IndexKeys.Ascending(value => value.UserId).Ascending(value => value.Nonce), + new CreateIndexOptions { Unique = true })); + _mastery.Indexes.CreateOne(new CreateIndexModel( + Builders.IndexKeys.Ascending(value => value.UserId).Ascending(value => value.ObjectiveId), + new CreateIndexOptions { Unique = true })); + _familyRatings.Indexes.CreateOne(new CreateIndexModel( + Builders.IndexKeys + .Ascending(value => value.BlueprintExternalId) + .Ascending(value => value.BlueprintVersion) + .Ascending(value => value.CaseFamilyId), + new CreateIndexOptions { Unique = true })); + _reviews.Indexes.CreateMany([ + new CreateIndexModel( + Builders.IndexKeys + .Ascending(value => value.UserId) + .Ascending(value => value.ObjectiveId), + new CreateIndexOptions { Unique = true, Name = "dynamic_review_user_objective" }), + new CreateIndexModel( + Builders.IndexKeys + .Ascending(value => value.UserId) + .Ascending(value => value.State) + .Ascending(value => value.DueUtc), + new CreateIndexOptions { Name = "dynamic_review_user_due" }) + ]); + } + + public async Task> PublishedAsync() => + await _blueprints.Find(value => value.IsPublished && value.QualityStandardVersion >= 2).ToListAsync(); + + public async Task GetAsync(string externalId, int version) => + await _blueprints.Find(value => value.ExternalId == externalId && value.Version == version).FirstOrDefaultAsync(); + + public async Task> MasteryAsync(string userId) => + (await _mastery.Find(value => value.UserId == userId).ToListAsync()) + .ToDictionary(value => value.ObjectiveId, StringComparer.Ordinal); + + public async Task> DueReviewsAsync(string userId, DateTime nowUtc) => + await _reviews.Find(value => value.UserId == userId + && value.State == DynamicReviewStates.Pending + && value.DueUtc <= nowUtc).ToListAsync(); + + public async Task> FamilyRatingsAsync(DynamicQuestionBlueprint blueprint) + { + var ratings = await _familyRatings.Find(value => + value.BlueprintExternalId == blueprint.ExternalId + && value.BlueprintVersion == blueprint.Version).ToListAsync(); + return ratings.ToDictionary(value => value.CaseFamilyId, StringComparer.Ordinal); + } + + public async Task RecordAsync( + DynamicGenerationClaims claims, + DynamicGeneratedQuestion generated, + DynamicAttemptRequest request) + { + var isCorrect = !request.IsSkip && request.ChoiceId == generated.Question.CorrectChoiceId; + var selectedAnswer = generated.Blueprint.Distractors + .Concat(generated.Blueprint.CaseFamilies + .Where(value => value.Id == claims.CaseFamilyId) + .SelectMany(value => value.Distractors)) + .FirstOrDefault(value => value.Id == request.ChoiceId); + var misconceptionId = isCorrect || request.IsSkip + ? "" + : selectedAnswer?.MisconceptionId ?? ""; + var attempt = new DynamicQuestionAttempt + { + UserId = claims.UserId, + Nonce = claims.Nonce, + BlueprintExternalId = claims.BlueprintExternalId, + BlueprintVersion = claims.BlueprintVersion, + ObjectiveId = claims.ObjectiveId, + CaseFamilyId = claims.CaseFamilyId, + DifficultyTier = claims.DifficultyTier, + EffectiveAbilityElo = claims.EffectiveAbilityElo, + FamilyElo = claims.FamilyElo, + ChoiceId = request.ChoiceId, + IsCorrect = isCorrect, + IsSkip = request.IsSkip, + SelectedMisconceptionId = misconceptionId, + IsScheduledReview = claims.IsScheduledReview, + DurationSeconds = Math.Clamp(request.DurationSeconds, 0, 86_400), + CreatedUtc = DateTime.UtcNow + }; + try { await _attempts.InsertOneAsync(attempt); } + catch (MongoWriteException exception) when (exception.WriteError.Category == ServerErrorCategory.DuplicateKey) + { + var existingMastery = await _mastery.Find(value => value.UserId == claims.UserId && value.ObjectiveId == claims.ObjectiveId).FirstOrDefaultAsync(); + return new DynamicAttemptResponse + { + Inserted = false, + IsCorrect = isCorrect, + CorrectChoiceId = generated.Question.CorrectChoiceId, + ObjectiveId = claims.ObjectiveId, + ObjectiveElo = existingMastery?.Elo ?? 1000 + }; + } + _metrics?.DynamicAttempt(claims.DifficultyTier, isCorrect); + + await EnsureMasteryAsync(claims.UserId, claims.ObjectiveId); + var current = await _mastery.Find(value => value.UserId == claims.UserId && value.ObjectiveId == claims.ObjectiveId).FirstAsync() + ?? throw new InvalidOperationException("Dynamic objective mastery could not be initialized."); + var oldElo = current.Elo; + var familyElo = claims.FamilyElo > 0 + ? claims.FamilyElo + : 800 + Math.Clamp(generated.Blueprint.Difficulty, 1, 5) * 100; + var delta = DynamicDifficultyPolicy.LearnerDelta(oldElo, familyElo, isCorrect, current.Attempts); + var now = DateTime.UtcNow; + var update = Builders.Update + .Inc(value => value.Elo, delta) + .Inc(value => value.Attempts, 1) + .Inc(value => value.Correct, isCorrect ? 1 : 0) + .Set(value => value.LastSeenUtc, now); + var updated = await _mastery.FindOneAndUpdateAsync( + value => value.UserId == claims.UserId && value.ObjectiveId == claims.ObjectiveId, + update, + new FindOneAndUpdateOptions { ReturnDocument = ReturnDocument.After }); + await _mastery.UpdateOneAsync( + value => value.UserId == claims.UserId && value.ObjectiveId == claims.ObjectiveId, + Builders.Update + .Max(value => value.Elo, DynamicDifficultyPolicy.MinimumElo)); + await _mastery.UpdateOneAsync( + value => value.UserId == claims.UserId && value.ObjectiveId == claims.ObjectiveId, + Builders.Update + .Min(value => value.Elo, DynamicDifficultyPolicy.MaximumElo)); + if (!string.IsNullOrWhiteSpace(claims.CaseFamilyId)) + await UpdateFamilyRatingAsync(claims, generated, isCorrect); + DateTime? nextReviewUtc = null; + if (!request.IsSkip) + nextReviewUtc = await UpdateReviewAsync(claims, misconceptionId, isCorrect); + return new DynamicAttemptResponse + { + Inserted = true, + IsCorrect = isCorrect, + CorrectChoiceId = generated.Question.CorrectChoiceId, + ObjectiveId = claims.ObjectiveId, + ObjectiveElo = DynamicDifficultyPolicy.Clamp(updated.Elo), + RatingChange = delta, + SelectedMisconceptionId = misconceptionId, + IsScheduledReview = claims.IsScheduledReview, + NextReviewUtc = nextReviewUtc + }; + } + + private async Task UpdateReviewAsync( + DynamicGenerationClaims claims, + string misconceptionId, + bool isCorrect) + { + var now = DateTime.UtcNow; + var existing = await _reviews.Find(value => + value.UserId == claims.UserId && value.ObjectiveId == claims.ObjectiveId).FirstOrDefaultAsync(); + + if (!claims.IsScheduledReview) + { + if (isCorrect) return existing?.DueUtc; + var due = DynamicReviewPolicy.FirstDue(now); + var update = Builders.Update + .SetOnInsert(value => value.UserId, claims.UserId) + .SetOnInsert(value => value.ObjectiveId, claims.ObjectiveId) + .SetOnInsert(value => value.CreatedUtc, now) + .Set(value => value.BlueprintExternalId, claims.BlueprintExternalId) + .Set(value => value.BlueprintVersion, claims.BlueprintVersion) + .Set(value => value.MisconceptionId, misconceptionId) + .Set(value => value.State, DynamicReviewStates.Pending) + .Set(value => value.Stage, 0) + .Set(value => value.DueUtc, due) + .Set(value => value.UpdatedUtc, now); + await _reviews.UpdateOneAsync( + value => value.UserId == claims.UserId && value.ObjectiveId == claims.ObjectiveId, + update, + new UpdateOptions { IsUpsert = true }); + return due; + } + + if (existing is null || existing.Id != claims.ReviewItemId || existing.Stage != claims.ReviewStage) + return null; + var next = DynamicReviewPolicy.AfterReview(existing.Stage, isCorrect, now); + var reviewUpdate = Builders.Update + .Set(value => value.Stage, next.Stage) + .Set(value => value.State, next.Mastered ? DynamicReviewStates.Mastered : DynamicReviewStates.Pending) + .Set(value => value.MisconceptionId, isCorrect ? existing.MisconceptionId : misconceptionId) + .Set(value => value.UpdatedUtc, now) + .Inc(value => value.ReviewCount, 1); + if (next.DueUtc.HasValue) + reviewUpdate = reviewUpdate.Set(value => value.DueUtc, next.DueUtc.Value); + await _reviews.UpdateOneAsync( + value => value.Id == existing.Id && value.Stage == claims.ReviewStage, + reviewUpdate); + return next.DueUtc; + } + + private async Task EnsureMasteryAsync(string userId, string objectiveId) + { + try + { + await _mastery.UpdateOneAsync( + value => value.UserId == userId && value.ObjectiveId == objectiveId, + Builders.Update + .SetOnInsert(value => value.UserId, userId) + .SetOnInsert(value => value.ObjectiveId, objectiveId) + .SetOnInsert(value => value.Elo, 1000) + .SetOnInsert(value => value.Attempts, 0) + .SetOnInsert(value => value.Correct, 0), + new UpdateOptions { IsUpsert = true }); + } + catch (MongoWriteException exception) when (exception.WriteError.Category == ServerErrorCategory.DuplicateKey) { } + } + + private async Task UpdateFamilyRatingAsync( + DynamicGenerationClaims claims, + DynamicGeneratedQuestion generated, + bool isCorrect) + { + var family = generated.Blueprint.CaseFamilies.First(value => value.Id == claims.CaseFamilyId); + try + { + await _familyRatings.UpdateOneAsync( + value => value.BlueprintExternalId == claims.BlueprintExternalId + && value.BlueprintVersion == claims.BlueprintVersion + && value.CaseFamilyId == claims.CaseFamilyId, + Builders.Update + .SetOnInsert(value => value.BlueprintExternalId, claims.BlueprintExternalId) + .SetOnInsert(value => value.BlueprintVersion, claims.BlueprintVersion) + .SetOnInsert(value => value.CaseFamilyId, claims.CaseFamilyId) + .SetOnInsert(value => value.DifficultyTier, family.DifficultyTier) + .SetOnInsert(value => value.NominalElo, family.NominalElo) + .SetOnInsert(value => value.Elo, family.NominalElo) + .SetOnInsert(value => value.Attempts, 0) + .SetOnInsert(value => value.Correct, 0), + new UpdateOptions { IsUpsert = true }); + } + catch (MongoWriteException exception) when (exception.WriteError.Category == ServerErrorCategory.DuplicateKey) { } + var current = await _familyRatings.Find(value => + value.BlueprintExternalId == claims.BlueprintExternalId + && value.BlueprintVersion == claims.BlueprintVersion + && value.CaseFamilyId == claims.CaseFamilyId).FirstAsync(); + var delta = DynamicDifficultyPolicy.FamilyDelta( + claims.EffectiveAbilityElo > 0 ? claims.EffectiveAbilityElo : 1000, + claims.FamilyElo > 0 ? claims.FamilyElo : current.Elo, + isCorrect, + current.Attempts); + await _familyRatings.UpdateOneAsync( + value => value.Id == current.Id, + Builders.Update + .Inc(value => value.Elo, delta) + .Inc(value => value.Attempts, 1) + .Inc(value => value.Correct, isCorrect ? 1 : 0) + .Set(value => value.UpdatedUtc, DateTime.UtcNow)); + await _familyRatings.UpdateOneAsync( + value => value.Id == current.Id, + Builders.Update + .Max(value => value.Elo, DynamicDifficultyPolicy.MinimumElo)); + await _familyRatings.UpdateOneAsync( + value => value.Id == current.Id, + Builders.Update + .Min(value => value.Elo, DynamicDifficultyPolicy.MaximumElo)); + } + + public async Task StatusAsync(string userId) + { + var blueprints = (await PublishedAsync()) + .GroupBy(value => value.ObjectiveId) + .Select(value => value.OrderByDescending(item => item.Version).First()) + .ToList(); + var objectiveCount = blueprints.Count; + var mastery = await _mastery.Find(value => value.UserId == userId).ToListAsync(); + var masteryByObjective = mastery.ToDictionary(value => value.ObjectiveId, StringComparer.Ordinal); + var due = await _reviews.CountDocumentsAsync(value => value.UserId == userId + && value.State == DynamicReviewStates.Pending + && value.DueUtc <= DateTime.UtcNow); + return new DynamicQuestionStatusResponse + { + Available = objectiveCount > 0, + PublishedObjectives = objectiveCount, + AttemptedObjectives = mastery.Count, + TotalAttempts = mastery.Sum(value => value.Attempts), + AverageObjectiveElo = mastery.Count == 0 ? 1000 : (int)Math.Round(mastery.Average(value => value.Elo)), + ReviewsDue = checked((int)due), + SystemCoverage = Coverage(blueprints, masteryByObjective, value => value.System), + PhysicianTaskCoverage = Coverage(blueprints, masteryByObjective, value => value.PhysicianTask) + }; + } + + private static List Coverage( + IReadOnlyCollection blueprints, + IReadOnlyDictionary mastery, + Func keySelector) => + blueprints + .GroupBy(value => string.IsNullOrWhiteSpace(keySelector(value)) ? "Unclassified" : keySelector(value)) + .Select(group => + { + var attempts = group + .Where(value => mastery.ContainsKey(value.ObjectiveId)) + .Select(value => mastery[value.ObjectiveId]) + .ToList(); + return new DynamicCoverageSummary + { + Id = group.Key, + PublishedObjectives = group.Count(), + AttemptedObjectives = attempts.Count, + Attempts = attempts.Sum(value => value.Attempts), + CoveragePercent = group.Count() == 0 ? 0 : Math.Round(attempts.Count * 100.0 / group.Count(), 1), + AverageElo = attempts.Count == 0 ? 1000 : (int)Math.Round(attempts.Average(value => value.Elo)) + }; + }) + .OrderBy(value => value.CoveragePercent) + .ThenBy(value => value.Id) + .ToList(); + + public async Task<(int Inserted, int Unchanged)> UpsertBlueprintsAsync(IEnumerable blueprints) + { + var inserted = 0; + var unchanged = 0; + foreach (var blueprint in blueprints) + { + var existing = await _blueprints.Find( + value => value.ExternalId == blueprint.ExternalId && value.Version == blueprint.Version) + .FirstOrDefaultAsync(); + if (existing is not null) + { + blueprint.Id = existing.Id; + var incomingDocument = blueprint.ToBsonDocument(); + var existingDocument = existing.ToBsonDocument(); + incomingDocument.Remove("_id"); + existingDocument.Remove("_id"); + if (incomingDocument != existingDocument) + throw new InvalidDataException( + $"{blueprint.ExternalId} v{blueprint.Version} is immutable; publish changes with a new version"); + unchanged++; + continue; + } + blueprint.Id = ObjectId.GenerateNewId().ToString(); + await _blueprints.InsertOneAsync(blueprint); + inserted++; + } + return (inserted, unchanged); + } +} diff --git a/api/QuestionsApi/Services/DynamicQuestionTokenService.cs b/api/QuestionsApi/Services/DynamicQuestionTokenService.cs new file mode 100644 index 0000000..e1a396e --- /dev/null +++ b/api/QuestionsApi/Services/DynamicQuestionTokenService.cs @@ -0,0 +1,67 @@ +using QuestionsApi.Models; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; + +namespace QuestionsApi.Services; + +public sealed class DynamicQuestionTokenService +{ + private readonly byte[] _key; + private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web); + + public DynamicQuestionTokenService(IConfiguration configuration) + { + var key = configuration["DynamicQuestions:SigningKey"] + ?? configuration["Jwt:Key"] + ?? "development-dynamic-question-signing-key-change-me"; + _key = SHA256.HashData(Encoding.UTF8.GetBytes(key)); + } + + public string SignGeneration(DynamicGenerationClaims claims) => Sign(claims); + public string SignRun(DynamicRunClaims claims) => Sign(claims); + public DynamicGenerationClaims ReadGeneration(string token, string userId) => Read(token, userId); + public DynamicRunClaims ReadRun(string token, string userId) => Read(token, userId); + + private string Sign(T value) + { + var payload = Base64Url(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(value, Json))); + using var hmac = new HMACSHA256(_key); + return $"{payload}.{Base64Url(hmac.ComputeHash(Encoding.UTF8.GetBytes(payload)))}"; + } + + private T Read(string token, string userId) where T : class + { + var parts = (token ?? "").Split('.'); + if (parts.Length != 2) throw new ArgumentException("token is invalid"); + using var hmac = new HMACSHA256(_key); + var expected = hmac.ComputeHash(Encoding.UTF8.GetBytes(parts[0])); + var supplied = FromBase64Url(parts[1]); + if (!CryptographicOperations.FixedTimeEquals(expected, supplied)) throw new ArgumentException("token is invalid"); + var value = JsonSerializer.Deserialize(FromBase64Url(parts[0]), Json) + ?? throw new ArgumentException("token is invalid"); + var tokenUser = value switch + { + DynamicGenerationClaims generation => generation.UserId, + DynamicRunClaims run => run.UserId, + _ => "" + }; + var expires = value switch + { + DynamicGenerationClaims generation => generation.ExpiresUtc, + DynamicRunClaims run => run.ExpiresUtc, + _ => DateTime.MinValue + }; + if (!string.Equals(tokenUser, userId, StringComparison.Ordinal) || expires < DateTime.UtcNow) + throw new ArgumentException("token is expired or belongs to another user"); + return value; + } + + private static string Base64Url(byte[] value) => Convert.ToBase64String(value).TrimEnd('=').Replace('+', '-').Replace('/', '_'); + private static byte[] FromBase64Url(string value) + { + value = value.Replace('-', '+').Replace('_', '/'); + value += new string('=', (4 - value.Length % 4) % 4); + return Convert.FromBase64String(value); + } +} diff --git a/api/QuestionsApi/Services/DynamicReviewExportCommand.cs b/api/QuestionsApi/Services/DynamicReviewExportCommand.cs new file mode 100644 index 0000000..fb85faf --- /dev/null +++ b/api/QuestionsApi/Services/DynamicReviewExportCommand.cs @@ -0,0 +1,75 @@ +using Microsoft.Extensions.Configuration; +using QuestionsApi.Models; +using System.Text.Json; + +namespace QuestionsApi.Services; + +public static class DynamicReviewExportCommand +{ + public static async Task RunAsync(string[] args, TextWriter output) + { + static string Argument(string[] values, string name, string fallback = "") + { + var index = Array.FindIndex(values, value => value.Equals(name, StringComparison.OrdinalIgnoreCase)); + return index >= 0 && index + 1 < values.Length ? values[index + 1] : fallback; + } + var source = Argument(args, "--source", Path.Combine(AppContext.BaseDirectory, "content", "dynamic_questions")); + var destination = Argument(args, "--output"); + if (!Directory.Exists(source) || string.IsNullOrWhiteSpace(destination)) + { + await output.WriteLineAsync("Usage: export-dynamic-review --source --output "); + return 2; + } + var jsonOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web) + { PropertyNameCaseInsensitive = true, WriteIndented = true }; + var blueprints = new List(); + foreach (var file in Directory.EnumerateFiles(source, "*.json", SearchOption.AllDirectories)) + blueprints.AddRange(JsonSerializer.Deserialize>( + await File.ReadAllTextAsync(file), jsonOptions) ?? []); + blueprints = DynamicBlueprintResolver.ResolveAll(blueprints); + blueprints = blueprints.Where(value => value.IsPublished && value.QualityStandardVersion >= 2).ToList(); + + var configuration = new ConfigurationBuilder() + .AddJsonFile("appsettings.json", optional: true) + .AddEnvironmentVariables() + .Build(); + var authored = (await new QuestionStore(new MongoDatabaseProvider(configuration)).GetPublishedAsync()) + .OrderBy(value => value.ExternalId, StringComparer.Ordinal) + .Take(blueprints.Count * 3) + .ToList(); + if (authored.Count < blueprints.Count * 3) + throw new InvalidOperationException("Not enough published authored questions for a balanced review packet."); + + var generator = new DynamicQuestionGenerator(); + var entries = new List<(Question Question, string Source, string Detail)>(); + foreach (var blueprint in blueprints) + for (var seed = 0; seed < 3; seed++) + { + var generated = generator.Generate(blueprint, $"review-{seed}", $"review-{blueprint.ExternalId}-{seed}"); + entries.Add((generated.Question, "dynamic", $"{blueprint.ExternalId}:{generated.CaseFamilyId}")); + } + entries.AddRange(authored.Select(value => (value, "authored", value.ExternalId))); + var ordered = entries.OrderBy(value => Convert.ToHexString( + System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(value.Detail)))).ToList(); + + Directory.CreateDirectory(destination); + var packet = ordered.Select((entry, index) => new + { + reviewId = $"Q{index + 1:D2}", + entry.Question.Stem, + entry.Question.LeadIn, + entry.Question.Choices, + entry.Question.Labs, + entry.Question.Explanation, + entry.Question.ChoiceExplanations, + entry.Question.ReviewFollowups, + entry.Question.WrongAnswerFollowups, + scoring = new { realism = 0, distractors = 0, explanation = 0, step3Relevance = 0, criticalIssue = "", predictedSource = "" } + }); + var key = ordered.Select((entry, index) => new { reviewId = $"Q{index + 1:D2}", entry.Source, entry.Detail }); + await File.WriteAllTextAsync(Path.Combine(destination, "review-packet.json"), JsonSerializer.Serialize(packet, jsonOptions)); + await File.WriteAllTextAsync(Path.Combine(destination, "answer-key.json"), JsonSerializer.Serialize(key, jsonOptions)); + await output.WriteLineAsync($"Exported {ordered.Count} blinded questions to {destination}."); + return 0; + } +} diff --git a/api/QuestionsApi/Services/DynamicReviewPolicy.cs b/api/QuestionsApi/Services/DynamicReviewPolicy.cs new file mode 100644 index 0000000..e19e282 --- /dev/null +++ b/api/QuestionsApi/Services/DynamicReviewPolicy.cs @@ -0,0 +1,22 @@ +namespace QuestionsApi.Services; + +public static class DynamicReviewPolicy +{ + private static readonly int[] IntervalsDays = [1, 3, 7, 21, 45]; + + public static DateTime FirstDue(DateTime nowUtc) => nowUtc.AddDays(IntervalsDays[0]); + + public static (int Stage, DateTime? DueUtc, bool Mastered) AfterReview( + int currentStage, + bool isCorrect, + DateTime nowUtc) + { + if (!isCorrect) + return (0, FirstDue(nowUtc), false); + + var nextStage = Math.Clamp(currentStage + 1, 1, IntervalsDays.Length); + if (nextStage >= IntervalsDays.Length) + return (nextStage, null, true); + return (nextStage, nowUtc.AddDays(IntervalsDays[nextStage]), false); + } +} diff --git a/api/QuestionsApi/Services/FlagMediaQuestionsCommand.cs b/api/QuestionsApi/Services/FlagMediaQuestionsCommand.cs new file mode 100644 index 0000000..ad5ee12 --- /dev/null +++ b/api/QuestionsApi/Services/FlagMediaQuestionsCommand.cs @@ -0,0 +1,21 @@ +namespace QuestionsApi.Services; + +// One-time sweep: flags every question that currently has at least one image so it can be +// pulled from student rotation for review, without touching IsPublished. Idempotent (only +// touches questions not already flagged, via QuestionStore.FlagAllWithMediaAsync's filter) - +// safe to re-run. +public static class FlagMediaQuestionsCommand +{ + private const string Reason = "Bulk-flagged for image review"; + private const string FlaggedBy = "system"; + + public static async Task RunAsync(IConfiguration configuration, TextWriter output) + { + var mongo = new MongoDatabaseProvider(configuration); + var questions = new QuestionStore(mongo); + + var flagged = await questions.FlagAllWithMediaAsync(FlaggedBy, Reason); + await output.WriteLineAsync($"Flagged {flagged} question(s) with images for review."); + return 0; + } +} diff --git a/api/QuestionsApi/Services/Flashcards/FlashcardClozeParser.cs b/api/QuestionsApi/Services/Flashcards/FlashcardClozeParser.cs new file mode 100644 index 0000000..6549a0f --- /dev/null +++ b/api/QuestionsApi/Services/Flashcards/FlashcardClozeParser.cs @@ -0,0 +1,57 @@ +using System.Text.RegularExpressions; + +namespace QuestionsApi.Services.Flashcards; + +public sealed record ClozeDeletion(int Number, string Answer, string? Hint); + +// {{c1::answer}} or {{c1::answer::hint}}. Numbers need not be contiguous or start at 1; +// each distinct number becomes its own independently scheduled card (a "sibling"). +public static partial class FlashcardClozeParser +{ + [GeneratedRegex(@"\{\{c(\d+)::((?:(?!\}\}).)*)\}\}", RegexOptions.Singleline)] + private static partial Regex ClozeRegex(); + + public static List Parse(string text) + { + if (string.IsNullOrEmpty(text)) return []; + var results = new List(); + foreach (Match match in ClozeRegex().Matches(text)) + { + if (!int.TryParse(match.Groups[1].Value, out var number) || number < 1) + continue; + var inner = match.Groups[2].Value; + var parts = inner.Split("::", 2); + var answer = parts[0].Trim(); + var hint = parts.Length > 1 ? parts[1].Trim() : null; + if (answer.Length == 0) continue; + results.Add(new ClozeDeletion(number, answer, string.IsNullOrEmpty(hint) ? null : hint)); + } + return results; + } + + public static List DistinctClozeNumbers(string text) => + Parse(text).Select(v => v.Number).Distinct().OrderBy(v => v).ToList(); + + public static bool HasValidCloze(string text) => DistinctClozeNumbers(text).Count > 0; + + // Renders the front for a given cloze number: that number's answer is hidden (replaced + // by its hint or "..."), all other cloze numbers are revealed as their answer text. + public static string RenderFront(string text, int clozeNumber) => + ClozeRegex().Replace(text, match => + { + if (!int.TryParse(match.Groups[1].Value, out var number)) return match.Value; + var inner = match.Groups[2].Value; + var parts = inner.Split("::", 2); + if (number != clozeNumber) return parts[0].Trim(); + var hint = parts.Length > 1 ? parts[1].Trim() : ""; + return hint.Length > 0 ? $"[{hint}]" : "[...]"; + }); + + public static string RenderBack(string text, int clozeNumber) => + ClozeRegex().Replace(text, match => + { + var inner = match.Groups[2].Value; + var parts = inner.Split("::", 2); + return parts[0].Trim(); + }); +} diff --git a/api/QuestionsApi/Services/Flashcards/FlashcardCsv.cs b/api/QuestionsApi/Services/Flashcards/FlashcardCsv.cs new file mode 100644 index 0000000..6f6a54b --- /dev/null +++ b/api/QuestionsApi/Services/Flashcards/FlashcardCsv.cs @@ -0,0 +1,64 @@ +using System.Text; + +namespace QuestionsApi.Services.Flashcards; + +// Minimal RFC 4180 CSV reader/writer (quoted fields, embedded commas/newlines, "" escaping). +// UTF-8, text-only, matches the flashcard CSV contract exactly. +public static class FlashcardCsv +{ + public static readonly string[] Headers = + ["stable_id", "deck", "type", "prompt", "answer", "extra", "tags", "source_question_external_id"]; + + public static List ParseRows(string text) + { + var rows = new List(); + var field = new StringBuilder(); + var row = new List(); + var inQuotes = false; + var i = 0; + var n = text.Length; + + while (i < n) + { + var c = text[i]; + if (inQuotes) + { + if (c == '"') + { + if (i + 1 < n && text[i + 1] == '"') { field.Append('"'); i += 2; continue; } + inQuotes = false; i++; continue; + } + field.Append(c); i++; continue; + } + + switch (c) + { + case '"': + inQuotes = true; i++; break; + case ',': + row.Add(field.ToString()); field.Clear(); i++; break; + case '\r': + i++; break; + case '\n': + row.Add(field.ToString()); field.Clear(); + rows.Add(row.ToArray()); row = []; + i++; break; + default: + field.Append(c); i++; break; + } + } + if (field.Length > 0 || row.Count > 0) + { + row.Add(field.ToString()); + rows.Add(row.ToArray()); + } + return rows; + } + + public static string WriteRow(IEnumerable fields) => string.Join(",", fields.Select(Escape)); + + private static string Escape(string field) => + field.IndexOfAny(['"', ',', '\n', '\r']) >= 0 + ? "\"" + field.Replace("\"", "\"\"") + "\"" + : field; +} diff --git a/api/QuestionsApi/Services/Flashcards/FlashcardDeckStore.cs b/api/QuestionsApi/Services/Flashcards/FlashcardDeckStore.cs new file mode 100644 index 0000000..0dfe78a --- /dev/null +++ b/api/QuestionsApi/Services/Flashcards/FlashcardDeckStore.cs @@ -0,0 +1,143 @@ +using MongoDB.Driver; +using QuestionsApi.Models; + +namespace QuestionsApi.Services.Flashcards; + +public sealed record FlashcardDeckUpdateResult(FlashcardDeck? Deck, FlashcardDeck? Current) +{ + public bool Succeeded => Deck is not null; +} + +public sealed class FlashcardDeckStore +{ + private readonly IMongoCollection _decks; + + public FlashcardDeckStore(MongoDatabaseProvider mongo) + { + _decks = mongo.Database.GetCollection("FlashcardDecks"); + _decks.Indexes.CreateMany([ + new CreateIndexModel( + Builders.IndexKeys.Ascending(v => v.UserId).Ascending(v => v.NormalizedName), + new CreateIndexOptions { Unique = true, Name = "flashcard_deck_unique_user_name" }), + new CreateIndexModel( + Builders.IndexKeys.Ascending(v => v.UserId).Ascending(v => v.StableId), + new CreateIndexOptions { Unique = true, Name = "flashcard_deck_unique_user_stable" }) + ]); + } + + public static string Normalize(string name) => name.Trim().ToLowerInvariant(); + + public Task> ListAsync(string userId) => + _decks.Find(v => v.UserId == userId && v.ArchivedUtc == null).SortBy(v => v.Name).ToListAsync(); + + public async Task GetAsync(string userId, string deckId) => + await _decks.Find(v => v.UserId == userId && v.Id == deckId).FirstOrDefaultAsync(); + + // Read-only: the caller's own decks plus every system (shared, admin-curated) deck. Never + // used to back a write path - mutations must stay strictly own-user via ListAsync/GetAsync. + public Task> ListVisibleAsync(string userId) => + _decks.Find(v => (v.UserId == userId || v.UserId == FlashcardOwners.System) && v.ArchivedUtc == null) + .SortBy(v => v.Name).ToListAsync(); + + public async Task GetVisibleAsync(string userId, string deckId) => + await _decks.Find(v => (v.UserId == userId || v.UserId == FlashcardOwners.System) && v.Id == deckId) + .FirstOrDefaultAsync(); + + // No owner filter at all - used only by the controller's mutation guard to check IsSystem + // regardless of who's asking. + public async Task GetAnyAsync(string deckId) => + await _decks.Find(v => v.Id == deckId).FirstOrDefaultAsync(); + + public async Task GetOrCreateDefaultDeckAsync(string userId) + { + var existing = await _decks.Find(v => v.UserId == userId && v.IsDefault).FirstOrDefaultAsync(); + if (existing is not null) return existing; + + var deck = new FlashcardDeck + { + UserId = userId, + Name = "Default", + NormalizedName = Normalize("Default"), + IsDefault = true + }; + try + { + await _decks.InsertOneAsync(deck); + return deck; + } + catch (MongoWriteException exception) when (exception.WriteError?.Category == ServerErrorCategory.DuplicateKey) + { + return await _decks.Find(v => v.UserId == userId && v.IsDefault).FirstOrDefaultAsync() + ?? throw new InvalidOperationException("default deck race with no winner"); + } + } + + public async Task UpsertAsync(string userId, UpsertFlashcardDeckRequest request, string? deckId) + { + var now = DateTime.UtcNow; + var normalized = Normalize(request.Name); + + if (string.IsNullOrWhiteSpace(deckId)) + { + var created = new FlashcardDeck + { + UserId = userId, + Name = request.Name.Trim(), + NormalizedName = normalized, + Description = request.Description.Trim(), + NewCardsPerDay = request.NewCardsPerDay, + ReviewsPerDay = request.ReviewsPerDay, + DesiredRetention = request.DesiredRetention, + CreatedUtc = now, + UpdatedUtc = now + }; + try + { + await _decks.InsertOneAsync(created); + return new FlashcardDeckUpdateResult(created, null); + } + catch (MongoWriteException exception) when (exception.WriteError?.Category == ServerErrorCategory.DuplicateKey) + { + return new FlashcardDeckUpdateResult(null, await GetByNameAsync(userId, normalized)); + } + } + + var expectedRevision = request.ExpectedRevision ?? 0; + var updated = await _decks.FindOneAndUpdateAsync( + v => v.UserId == userId && v.Id == deckId && v.Revision == expectedRevision, + Builders.Update + .Set(v => v.Name, request.Name.Trim()) + .Set(v => v.NormalizedName, normalized) + .Set(v => v.Description, request.Description.Trim()) + .Set(v => v.NewCardsPerDay, request.NewCardsPerDay) + .Set(v => v.ReviewsPerDay, request.ReviewsPerDay) + .Set(v => v.DesiredRetention, request.DesiredRetention) + .Set(v => v.UpdatedUtc, now) + .Inc(v => v.Revision, 1), + new FindOneAndUpdateOptions { ReturnDocument = ReturnDocument.After }); + + return updated is not null + ? new FlashcardDeckUpdateResult(updated, null) + : new FlashcardDeckUpdateResult(null, await GetAsync(userId, deckId)); + } + + public async Task GetByNameAsync(string userId, string normalizedName) => + await _decks.Find(v => v.UserId == userId && v.NormalizedName == normalizedName).FirstOrDefaultAsync(); + + public async Task ArchiveAsync(string userId, string deckId, bool archived) + { + var deck = await GetAsync(userId, deckId); + if (deck is not null && deck.IsDefault) return new FlashcardDeckUpdateResult(null, deck); + + var updated = await _decks.FindOneAndUpdateAsync( + v => v.UserId == userId && v.Id == deckId && !v.IsDefault, + Builders.Update + .Set(v => v.ArchivedUtc, archived ? DateTime.UtcNow : null) + .Set(v => v.UpdatedUtc, DateTime.UtcNow) + .Inc(v => v.Revision, 1), + new FindOneAndUpdateOptions { ReturnDocument = ReturnDocument.After }); + return updated is not null + ? new FlashcardDeckUpdateResult(updated, null) + : new FlashcardDeckUpdateResult(null, deck); + } +} diff --git a/api/QuestionsApi/Services/Flashcards/FlashcardImportExportService.cs b/api/QuestionsApi/Services/Flashcards/FlashcardImportExportService.cs new file mode 100644 index 0000000..b776e2c --- /dev/null +++ b/api/QuestionsApi/Services/Flashcards/FlashcardImportExportService.cs @@ -0,0 +1,393 @@ +using System.Text; +using System.Text.Json; +using MongoDB.Driver; +using QuestionsApi.Models; + +namespace QuestionsApi.Services.Flashcards; + +public sealed class FlashcardImportExportService +{ + private const int MaxNotesPerImport = 10_000; + private const long MaxImportBytes = 25 * 1024 * 1024; + + private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true }; + + private readonly FlashcardDeckStore _decks; + private readonly FlashcardNoteStore _notes; + private readonly FlashcardScheduleStore _schedules; + private readonly FlashcardMediaStore _media; + private readonly FlashcardSettingsStore _settings; + private readonly IMongoCollection _previews; + + public FlashcardImportExportService( + MongoDatabaseProvider mongo, + FlashcardDeckStore decks, + FlashcardNoteStore notes, + FlashcardScheduleStore schedules, + FlashcardMediaStore media, + FlashcardSettingsStore settings) + { + _decks = decks; + _notes = notes; + _schedules = schedules; + _media = media; + _settings = settings; + _previews = mongo.Database.GetCollection("FlashcardImportPreviews"); + _previews.Indexes.CreateOne(new CreateIndexModel( + Builders.IndexKeys.Ascending(v => v.ExpiresUtc), + new CreateIndexOptions { ExpireAfter = TimeSpan.Zero, Name = "flashcard_import_preview_ttl" })); + } + + public static void ValidateSize(long byteLength) + { + if (byteLength > MaxImportBytes) + throw new FlashcardValidationException("import file must be 25 MB or smaller"); + } + + public async Task<(string Csv, List Omissions)> ExportCsvAsync(string userId) + { + var deckList = await _decks.ListAsync(userId); + var deckNames = deckList.ToDictionary(v => v.Id!, v => v.Name, StringComparer.Ordinal); + var allNotes = await _notes.ListAllActiveAsync(userId); + + var sb = new StringBuilder(); + sb.Append(FlashcardCsv.WriteRow(FlashcardCsv.Headers)).Append('\n'); + foreach (var note in allNotes.OrderBy(v => v.CreatedUtc)) + { + var deckName = deckNames.GetValueOrDefault(note.DeckId, "Default"); + sb.Append(FlashcardCsv.WriteRow([ + note.StableId, deckName, note.Type, note.Prompt, note.Answer, note.Extra, + string.Join(';', note.Tags), note.SourceQuestionExternalId + ])).Append('\n'); + } + + var omissions = new List + { + new() { Kind = "schedule", Reason = "CSV export omits FSRS scheduling state" }, + new() { Kind = "history", Reason = "CSV export omits review history" } + }; + if (allNotes.Any(v => v.Images.Count > 0)) + omissions.Insert(0, new FlashcardExportOmission { Kind = "images", Reason = "CSV export omits attached images" }); + return (sb.ToString(), omissions); + } + + public async Task ExportJsonAsync(string userId) + { + var export = new FlashcardJsonExport + { + Settings = await _settings.GetAsync(userId), + Decks = await _decks.ListAsync(userId) + }; + + foreach (var note in await _notes.ListAllActiveAsync(userId)) + { + var cards = await _schedules.GetByNoteIdAsync(userId, note.Id!); + var logs = new List(); + foreach (var card in cards) + logs.AddRange(await _schedules.GetReviewLogsForCardAsync(userId, card.Id!)); + + var images = new List(); + foreach (var image in note.Images) + { + var asset = await _media.GetAsync(userId, image.AssetId); + if (asset is null) continue; + images.Add(new FlashcardJsonExportImage + { + AssetId = asset.Id!, + FileName = asset.FileName, + ContentType = asset.ContentType, + Base64Bytes = Convert.ToBase64String(asset.Bytes) + }); + } + + export.Notes.Add(new FlashcardJsonExportNote { Note = note, Cards = cards, ReviewLogs = logs, Images = images }); + } + return export; + } + + public async Task PreviewCsvAsync(string userId, string csvText) + { + var rows = FlashcardCsv.ParseRows(csvText); + if (rows.Count == 0) throw new FlashcardValidationException("CSV file is empty"); + if (!rows[0].Select(v => v.Trim()).SequenceEqual(FlashcardCsv.Headers, StringComparer.OrdinalIgnoreCase)) + throw new FlashcardValidationException($"CSV header must be: {string.Join(",", FlashcardCsv.Headers)}"); + if (rows.Count - 1 > MaxNotesPerImport) + throw new FlashcardValidationException($"import limited to {MaxNotesPerImport:N0} notes"); + + var existingNotes = await _notes.ListAllActiveAsync(userId); + var byStableId = existingNotes.Where(v => v.StableId.Length > 0) + .ToDictionary(v => v.StableId, StringComparer.Ordinal); + var fingerprints = existingNotes.Select(v => v.ContentFingerprint).ToHashSet(StringComparer.Ordinal); + + var records = new List(); + for (var r = 1; r < rows.Count; r++) + { + var cols = rows[r]; + var record = new FlashcardImportPendingRecord { RowNumber = r + 1 }; + if (cols.Length != FlashcardCsv.Headers.Length) + { + record.Action = FlashcardImportRowActions.Error; + record.Error = $"expected {FlashcardCsv.Headers.Length} columns, found {cols.Length}"; + records.Add(record); + continue; + } + + var stableId = cols[0].Trim(); + var deckName = cols[1].Trim(); + var type = cols[2].Trim().ToLowerInvariant(); + var prompt = cols[3]; + var answer = cols[4]; + var extra = cols[5]; + var tags = cols[6].Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList(); + var sourceQuestionId = cols[7].Trim(); + + record.StableId = stableId; + record.DeckName = deckName.Length == 0 ? "Default" : deckName; + record.Type = type; + record.Prompt = prompt; + record.Answer = answer; + record.Extra = extra; + record.Tags = tags; + record.SourceQuestionExternalId = sourceQuestionId; + + if (type != FlashcardTypes.Basic && type != FlashcardTypes.Cloze) + { + record.Action = FlashcardImportRowActions.Error; + record.Error = "type must be 'basic' or 'cloze'"; + } + else if (prompt.Trim().Length == 0) + { + record.Action = FlashcardImportRowActions.Error; + record.Error = "prompt is required"; + } + else if (type == FlashcardTypes.Cloze && !FlashcardClozeParser.HasValidCloze(prompt)) + { + record.Action = FlashcardImportRowActions.Error; + record.Error = "cloze prompt must contain at least one {{cN::answer}} marker"; + } + else + { + var fingerprint = FlashcardNoteStore.Fingerprint(type, prompt, answer); + if (stableId.Length > 0 && byStableId.TryGetValue(stableId, out var existing)) + { + record.Action = existing.ContentFingerprint == fingerprint + ? FlashcardImportRowActions.Skip + : FlashcardImportRowActions.Update; + } + else if (fingerprints.Contains(fingerprint)) + { + record.Action = FlashcardImportRowActions.Skip; + } + else + { + record.Action = FlashcardImportRowActions.Create; + if (stableId.Length == 0) record.StableId = Guid.NewGuid().ToString(); + } + } + records.Add(record); + } + + return await SavePreviewAsync(userId, FlashcardImportFormats.Csv, records); + } + + public async Task PreviewJsonAsync(string userId, string jsonText) + { + FlashcardJsonExport parsed; + try + { + parsed = JsonSerializer.Deserialize(jsonText, JsonOptions) + ?? throw new FlashcardValidationException("empty JSON document"); + } + catch (JsonException exception) + { + throw new FlashcardValidationException($"invalid JSON: {exception.Message}"); + } + + if (parsed.Notes.Count > MaxNotesPerImport) + throw new FlashcardValidationException($"import limited to {MaxNotesPerImport:N0} notes"); + + var existingNotes = await _notes.ListAllActiveAsync(userId); + var byStableId = existingNotes.Where(v => v.StableId.Length > 0) + .ToDictionary(v => v.StableId, StringComparer.Ordinal); + + var records = new List(); + var rowNumber = 0; + foreach (var item in parsed.Notes) + { + rowNumber++; + var note = item.Note; + var stableId = string.IsNullOrWhiteSpace(note.StableId) ? Guid.NewGuid().ToString() : note.StableId; + var type = FlashcardTypes.All.Contains(note.Type) ? note.Type : FlashcardTypes.Basic; + var record = new FlashcardImportPendingRecord + { + RowNumber = rowNumber, + StableId = stableId, + DeckName = parsed.Decks.FirstOrDefault(d => d.Id == note.DeckId)?.Name ?? "Default", + Type = type, + Prompt = note.Prompt, + Answer = note.Answer, + Extra = note.Extra, + Tags = note.Tags, + SourceQuestionExternalId = note.SourceQuestionExternalId, + Cards = item.Cards, + ReviewLogs = item.ReviewLogs + }; + + record.Action = type == FlashcardTypes.Cloze && !FlashcardClozeParser.HasValidCloze(note.Prompt) + ? FlashcardImportRowActions.Error + : byStableId.ContainsKey(stableId) ? FlashcardImportRowActions.Update : FlashcardImportRowActions.Create; + if (record.Action == FlashcardImportRowActions.Error) + record.Error = "cloze prompt must contain at least one {{cN::answer}} marker"; + + records.Add(record); + } + + return await SavePreviewAsync(userId, FlashcardImportFormats.Json, records); + } + + public async Task CommitAsync(string userId, string importToken) + { + var preview = await _previews.Find(v => v.Id == importToken && v.UserId == userId).FirstOrDefaultAsync(); + if (preview is null) throw new FlashcardValidationException("import preview not found or expired; re-run preview"); + + var deckCache = new Dictionary(StringComparer.OrdinalIgnoreCase); + var response = new FlashcardImportCommitResponse(); + + foreach (var record in preview.Records) + { + if (record.Action == FlashcardImportRowActions.Error) + { + response.Errored++; + response.Errors.Add(ToRow(record)); + continue; + } + if (record.Action == FlashcardImportRowActions.Skip) + { + response.Skipped++; + continue; + } + + var deckId = await ResolveDeckIdAsync(userId, record.DeckName, deckCache); + var existing = await _notes.GetByStableIdAsync(userId, record.StableId); + var request = new UpsertFlashcardNoteRequest + { + DeckId = deckId, + StableId = record.StableId, + Type = record.Type, + Prompt = record.Prompt, + Answer = record.Answer, + Extra = record.Extra, + Tags = record.Tags, + SourceQuestionExternalId = record.SourceQuestionExternalId, + ExpectedRevision = existing?.Revision + }; + + FlashcardNoteUpdateResult result; + try + { + result = await _notes.UpsertAsync(userId, request, existing?.Id); + } + catch (FlashcardValidationException exception) + { + response.Errored++; + response.Errors.Add(new FlashcardImportPreviewRow + { + RowNumber = record.RowNumber, Action = FlashcardImportRowActions.Error, + Error = exception.Message, StableId = record.StableId + }); + continue; + } + + if (!result.Succeeded) + { + response.Errored++; + response.Errors.Add(new FlashcardImportPreviewRow + { + RowNumber = record.RowNumber, Action = FlashcardImportRowActions.Error, + Error = "note changed since preview; re-run import", StableId = record.StableId + }); + continue; + } + + if (record.Action == FlashcardImportRowActions.Create) response.Created++; else response.Updated++; + + foreach (var card in record.Cards) + await _schedules.ImportScheduleStateAsync(userId, result.Note!.Id!, card); + foreach (var log in record.ReviewLogs) + { + var targetCard = (await _schedules.GetByNoteIdAsync(userId, result.Note!.Id!)) + .FirstOrDefault(v => v.Variant == record.Cards.FirstOrDefault(c => c.Id == log.CardId)?.Variant); + if (targetCard is not null) + await _schedules.ImportReviewLogAsync(userId, targetCard.Id!, log); + } + } + + await _previews.DeleteOneAsync(v => v.Id == importToken); + return response; + } + + private async Task SavePreviewAsync( + string userId, string format, List records) + { + var preview = new FlashcardImportPreview + { + Id = Guid.NewGuid().ToString(), + UserId = userId, + Format = format, + Records = records + }; + await _previews.InsertOneAsync(preview); + + return new FlashcardImportPreviewResponse + { + ImportToken = preview.Id, + CreateCount = records.Count(v => v.Action == FlashcardImportRowActions.Create), + UpdateCount = records.Count(v => v.Action == FlashcardImportRowActions.Update), + SkipCount = records.Count(v => v.Action == FlashcardImportRowActions.Skip), + ErrorCount = records.Count(v => v.Action == FlashcardImportRowActions.Error), + Rows = records.Select(ToRow).ToList() + }; + } + + private static FlashcardImportPreviewRow ToRow(FlashcardImportPendingRecord record) => new() + { + RowNumber = record.RowNumber, + Action = record.Action, + Error = record.Error, + StableId = record.StableId, + Deck = record.DeckName, + Type = record.Type, + Prompt = Truncate(record.Prompt), + Answer = Truncate(record.Answer) + }; + + private static string Truncate(string value) => value.Length > 200 ? value[..200] + "…" : value; + + private async Task ResolveDeckIdAsync(string userId, string deckName, Dictionary cache) + { + var key = deckName.Trim(); + if (key.Length == 0) key = "Default"; + if (cache.TryGetValue(key, out var cached)) return cached; + + if (string.Equals(key, "Default", StringComparison.OrdinalIgnoreCase)) + { + var defaultDeck = await _decks.GetOrCreateDefaultDeckAsync(userId); + cache[key] = defaultDeck.Id!; + return defaultDeck.Id!; + } + + var normalized = FlashcardDeckStore.Normalize(key); + var existing = await _decks.GetByNameAsync(userId, normalized); + if (existing is not null) + { + cache[key] = existing.Id!; + return existing.Id!; + } + + var created = await _decks.UpsertAsync(userId, new UpsertFlashcardDeckRequest { Name = key }, null); + var deckId = created.Deck?.Id ?? (await _decks.GetByNameAsync(userId, normalized))?.Id + ?? throw new InvalidOperationException("deck resolution failed during import"); + cache[key] = deckId; + return deckId; + } +} diff --git a/api/QuestionsApi/Services/Flashcards/FlashcardMediaStore.cs b/api/QuestionsApi/Services/Flashcards/FlashcardMediaStore.cs new file mode 100644 index 0000000..c54f9fc --- /dev/null +++ b/api/QuestionsApi/Services/Flashcards/FlashcardMediaStore.cs @@ -0,0 +1,41 @@ +using MongoDB.Bson; +using MongoDB.Driver; +using QuestionsApi.Models; + +namespace QuestionsApi.Services.Flashcards; + +public sealed class FlashcardMediaStore +{ + private readonly IMongoCollection _assets; + + public FlashcardMediaStore(MongoDatabaseProvider mongo) + { + _assets = mongo.Database.GetCollection("FlashcardMediaAssets"); + _assets.Indexes.CreateOne(new CreateIndexModel( + Builders.IndexKeys.Ascending(v => v.UserId), + new CreateIndexOptions { Name = "flashcard_media_user" })); + } + + public async Task CreateAsync(string userId, string fileName, string contentType, byte[] bytes) + { + var asset = new FlashcardMediaAsset + { + UserId = userId, + FileName = fileName, + ContentType = contentType, + Bytes = bytes, + CreatedUtc = DateTime.UtcNow + }; + await _assets.InsertOneAsync(asset); + return asset; + } + + public async Task GetAsync(string userId, string id) + { + if (!ObjectId.TryParse(id, out _)) return null; + return await _assets.Find(v => v.UserId == userId && v.Id == id).FirstOrDefaultAsync(); + } + + public Task DeleteAsync(string userId, string id) => + _assets.DeleteOneAsync(v => v.UserId == userId && v.Id == id).ContinueWith(t => t.Result.DeletedCount); +} diff --git a/api/QuestionsApi/Services/Flashcards/FlashcardNoteStore.cs b/api/QuestionsApi/Services/Flashcards/FlashcardNoteStore.cs new file mode 100644 index 0000000..5c02d07 --- /dev/null +++ b/api/QuestionsApi/Services/Flashcards/FlashcardNoteStore.cs @@ -0,0 +1,239 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.RegularExpressions; +using MongoDB.Driver; +using QuestionsApi.Models; + +namespace QuestionsApi.Services.Flashcards; + +public sealed record FlashcardNoteUpdateResult(FlashcardNote? Note, FlashcardNote? Current) +{ + public bool Succeeded => Note is not null; +} + +public sealed class FlashcardNoteStore +{ + private readonly IMongoCollection _notes; + private readonly FlashcardScheduleStore _schedules; + + public FlashcardNoteStore(MongoDatabaseProvider mongo, FlashcardScheduleStore schedules) + { + _notes = mongo.Database.GetCollection("FlashcardNotes"); + _schedules = schedules; + _notes.Indexes.CreateMany([ + new CreateIndexModel( + Builders.IndexKeys.Ascending(v => v.UserId).Ascending(v => v.StableId), + new CreateIndexOptions { Unique = true, Name = "flashcard_note_unique_user_stable" }), + new CreateIndexModel( + Builders.IndexKeys.Ascending(v => v.UserId).Ascending(v => v.DeckId), + new CreateIndexOptions { Name = "flashcard_note_user_deck" }), + new CreateIndexModel( + Builders.IndexKeys.Ascending(v => v.UserId).Ascending(v => v.SourceQuestionExternalId), + new CreateIndexOptions { Name = "flashcard_note_user_source_question" }), + new CreateIndexModel( + Builders.IndexKeys.Ascending(v => v.UserId).Ascending(v => v.ContentFingerprint), + new CreateIndexOptions { Name = "flashcard_note_user_fingerprint" }) + ]); + } + + public static string Fingerprint(string type, string prompt, string answer) => + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes($"{type}{prompt.Trim()}{answer.Trim()}"))); + + public async Task GetAsync(string userId, string noteId) => + await _notes.Find(v => v.UserId == userId && v.Id == noteId).FirstOrDefaultAsync(); + + public async Task GetByStableIdAsync(string userId, string stableId) => + await _notes.Find(v => v.UserId == userId && v.StableId == stableId).FirstOrDefaultAsync(); + + public Task> ListByDeckAsync(string userId, string deckId) => + _notes.Find(v => v.UserId == userId && v.DeckId == deckId && v.ArchivedUtc == null).ToListAsync(); + + public Task> ListAllActiveAsync(string userId) => + _notes.Find(v => v.UserId == userId && v.ArchivedUtc == null).ToListAsync(); + + // Read-only: the caller's own notes plus every system (shared, admin-curated) note. Never + // used to back a write path - mutations must stay strictly own-user via the methods above. + public Task> ListByDeckVisibleAsync(string userId, string deckId) => + _notes.Find(v => (v.UserId == userId || v.UserId == FlashcardOwners.System) + && v.DeckId == deckId && v.ArchivedUtc == null).ToListAsync(); + + public Task> ListAllVisibleActiveAsync(string userId) => + _notes.Find(v => (v.UserId == userId || v.UserId == FlashcardOwners.System) && v.ArchivedUtc == null) + .ToListAsync(); + + public async Task GetVisibleAsync(string userId, string noteId) => + await _notes.Find(v => (v.UserId == userId || v.UserId == FlashcardOwners.System) && v.Id == noteId) + .FirstOrDefaultAsync(); + + // No owner filter at all - used only by the controller's mutation guard to check IsSystem + // regardless of who's asking. + public async Task GetAnyAsync(string noteId) => + await _notes.Find(v => v.Id == noteId).FirstOrDefaultAsync(); + + public async Task<(List Items, int Total)> SearchVisibleAsync(string userId, FlashcardLibraryQuery query) + { + var filter = Builders.Filter.Or( + Builders.Filter.Eq(v => v.UserId, userId), + Builders.Filter.Eq(v => v.UserId, FlashcardOwners.System)); + if (query.State != FlashcardArchiveFilter.IncludeArchived) + filter &= Builders.Filter.Eq(v => v.ArchivedUtc, null); + if (!string.IsNullOrWhiteSpace(query.DeckId)) + filter &= Builders.Filter.Eq(v => v.DeckId, query.DeckId); + if (!string.IsNullOrWhiteSpace(query.Type)) + filter &= Builders.Filter.Eq(v => v.Type, query.Type); + if (!string.IsNullOrWhiteSpace(query.Tag)) + filter &= Builders.Filter.AnyEq(v => v.Tags, query.Tag); + if (!string.IsNullOrWhiteSpace(query.Search)) + { + var pattern = new MongoDB.Bson.BsonRegularExpression(Regex.Escape(query.Search), "i"); + filter &= Builders.Filter.Or( + Builders.Filter.Regex(v => v.Prompt, pattern), + Builders.Filter.Regex(v => v.Answer, pattern), + Builders.Filter.Regex(v => v.Extra, pattern)); + } + + var total = checked((int)await _notes.CountDocumentsAsync(filter)); + var page = Math.Max(1, query.Page); + var pageSize = Math.Clamp(query.PageSize, 1, 200); + var items = await _notes.Find(filter) + .SortByDescending(v => v.UpdatedUtc) + .Skip((page - 1) * pageSize) + .Limit(pageSize) + .ToListAsync(); + return (items, total); + } + + public async Task<(List Items, int Total)> SearchAsync(string userId, FlashcardLibraryQuery query) + { + var filter = Builders.Filter.Eq(v => v.UserId, userId); + if (query.State != FlashcardArchiveFilter.IncludeArchived) + filter &= Builders.Filter.Eq(v => v.ArchivedUtc, null); + if (!string.IsNullOrWhiteSpace(query.DeckId)) + filter &= Builders.Filter.Eq(v => v.DeckId, query.DeckId); + if (!string.IsNullOrWhiteSpace(query.Type)) + filter &= Builders.Filter.Eq(v => v.Type, query.Type); + if (!string.IsNullOrWhiteSpace(query.Tag)) + filter &= Builders.Filter.AnyEq(v => v.Tags, query.Tag); + if (!string.IsNullOrWhiteSpace(query.Search)) + { + var pattern = new MongoDB.Bson.BsonRegularExpression(Regex.Escape(query.Search), "i"); + filter &= Builders.Filter.Or( + Builders.Filter.Regex(v => v.Prompt, pattern), + Builders.Filter.Regex(v => v.Answer, pattern), + Builders.Filter.Regex(v => v.Extra, pattern)); + } + + var total = checked((int)await _notes.CountDocumentsAsync(filter)); + var page = Math.Max(1, query.Page); + var pageSize = Math.Clamp(query.PageSize, 1, 200); + var items = await _notes.Find(filter) + .SortByDescending(v => v.UpdatedUtc) + .Skip((page - 1) * pageSize) + .Limit(pageSize) + .ToListAsync(); + return (items, total); + } + + public async Task UpsertAsync(string userId, UpsertFlashcardNoteRequest request, string? noteId) + { + var type = FlashcardTypes.All.Contains(request.Type) ? request.Type : FlashcardTypes.Basic; + if (type == FlashcardTypes.Cloze && !FlashcardClozeParser.HasValidCloze(request.Prompt)) + throw new FlashcardValidationException("cloze note must contain at least one {{cN::answer}} marker"); + + var now = DateTime.UtcNow; + var fingerprint = Fingerprint(type, request.Prompt, request.Answer); + + FlashcardNote? saved; + if (string.IsNullOrWhiteSpace(noteId)) + { + var created = new FlashcardNote + { + UserId = userId, + StableId = string.IsNullOrWhiteSpace(request.StableId) ? Guid.NewGuid().ToString() : request.StableId, + DeckId = request.DeckId, + Type = type, + Prompt = request.Prompt, + Answer = request.Answer, + Extra = request.Extra, + Tags = NormalizeTags(request.Tags), + Images = request.Images, + SourceQuestionExternalId = request.SourceQuestionExternalId, + ContentFingerprint = fingerprint, + CreatedUtc = now, + UpdatedUtc = now + }; + try + { + await _notes.InsertOneAsync(created); + saved = created; + } + catch (MongoWriteException exception) when (exception.WriteError?.Category == ServerErrorCategory.DuplicateKey) + { + return new FlashcardNoteUpdateResult(null, await GetByStableIdAsync(userId, created.StableId)); + } + } + else + { + var expectedRevision = request.ExpectedRevision ?? 0; + var updated = await _notes.FindOneAndUpdateAsync( + v => v.UserId == userId && v.Id == noteId && v.Revision == expectedRevision, + Builders.Update + .Set(v => v.DeckId, request.DeckId) + .Set(v => v.Type, type) + .Set(v => v.Prompt, request.Prompt) + .Set(v => v.Answer, request.Answer) + .Set(v => v.Extra, request.Extra) + .Set(v => v.Tags, NormalizeTags(request.Tags)) + .Set(v => v.Images, request.Images) + .Set(v => v.SourceQuestionExternalId, request.SourceQuestionExternalId) + .Set(v => v.ContentFingerprint, fingerprint) + .Set(v => v.UpdatedUtc, now) + .Inc(v => v.Revision, 1), + new FindOneAndUpdateOptions { ReturnDocument = ReturnDocument.After }); + if (updated is null) + return new FlashcardNoteUpdateResult(null, await GetAsync(userId, noteId)); + saved = updated; + } + + await _schedules.SyncVariantsAsync(saved); + return new FlashcardNoteUpdateResult(saved, null); + } + + public async Task ArchiveAsync(string userId, string noteId, bool archived) + { + var updated = await _notes.FindOneAndUpdateAsync( + v => v.UserId == userId && v.Id == noteId, + Builders.Update + .Set(v => v.ArchivedUtc, archived ? DateTime.UtcNow : null) + .Set(v => v.UpdatedUtc, DateTime.UtcNow) + .Inc(v => v.Revision, 1), + new FindOneAndUpdateOptions { ReturnDocument = ReturnDocument.After }); + return updated is not null + ? new FlashcardNoteUpdateResult(updated, null) + : new FlashcardNoteUpdateResult(null, await GetAsync(userId, noteId)); + } + + public async Task MoveAsync(string userId, IReadOnlyCollection noteIds, string targetDeckId) + { + var result = await _notes.UpdateManyAsync( + v => v.UserId == userId && noteIds.Contains(v.Id!), + Builders.Update.Set(v => v.DeckId, targetDeckId).Set(v => v.UpdatedUtc, DateTime.UtcNow).Inc(v => v.Revision, 1)); + return result.ModifiedCount; + } + + public Task CountInDeckAsync(string userId, string deckId) => + _notes.CountDocumentsAsync(v => v.UserId == userId && v.DeckId == deckId && v.ArchivedUtc == null); + + private static List NormalizeTags(List tags) => tags + .Select(v => v.Trim()) + .Where(v => v.Length > 0) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); +} + +public sealed class FlashcardValidationException(string message) : Exception(message); + +public static class FlashcardArchiveFilter +{ + public const string IncludeArchived = "archived"; +} diff --git a/api/QuestionsApi/Services/Flashcards/FlashcardOwnershipGuard.cs b/api/QuestionsApi/Services/Flashcards/FlashcardOwnershipGuard.cs new file mode 100644 index 0000000..4ecafcd --- /dev/null +++ b/api/QuestionsApi/Services/Flashcards/FlashcardOwnershipGuard.cs @@ -0,0 +1,8 @@ +namespace QuestionsApi.Services.Flashcards; + +// Pure authorization decision for mutating a flashcard deck/note, kept dependency-free (like +// FlashcardScheduling) so it's unit testable without a controller or Mongo. +public static class FlashcardOwnershipGuard +{ + public static bool CanMutate(bool isSuper, bool targetIsSystem) => isSuper || !targetIsSystem; +} diff --git a/api/QuestionsApi/Services/Flashcards/FlashcardScheduleStore.cs b/api/QuestionsApi/Services/Flashcards/FlashcardScheduleStore.cs new file mode 100644 index 0000000..32e7e8c --- /dev/null +++ b/api/QuestionsApi/Services/Flashcards/FlashcardScheduleStore.cs @@ -0,0 +1,392 @@ +using MongoDB.Driver; +using QuestionsApi.Models; + +namespace QuestionsApi.Services.Flashcards; + +public enum FlashcardReviewOutcomeKind { Success, Duplicate, Conflict, NotFound } + +public sealed class FlashcardReviewOutcome +{ + public FlashcardReviewOutcomeKind Kind { get; init; } + public FlashcardSchedule? Schedule { get; init; } + public static FlashcardReviewOutcome NotFound() => new() { Kind = FlashcardReviewOutcomeKind.NotFound }; + public static FlashcardReviewOutcome Conflict(FlashcardSchedule? current) => new() { Kind = FlashcardReviewOutcomeKind.Conflict, Schedule = current }; + public static FlashcardReviewOutcome Duplicate(FlashcardSchedule? current) => new() { Kind = FlashcardReviewOutcomeKind.Duplicate, Schedule = current }; + public static FlashcardReviewOutcome Success(FlashcardSchedule schedule) => new() { Kind = FlashcardReviewOutcomeKind.Success, Schedule = schedule }; +} + +public sealed class FlashcardScheduleStore +{ + private readonly IMongoCollection _schedules; + private readonly IMongoCollection _reviewLogs; + private readonly IFlashcardScheduler _scheduler; + + public FlashcardScheduleStore(MongoDatabaseProvider mongo, IFlashcardScheduler scheduler) + { + _schedules = mongo.Database.GetCollection("FlashcardSchedules"); + _reviewLogs = mongo.Database.GetCollection("FlashcardReviewLogs"); + _scheduler = scheduler; + + _schedules.Indexes.CreateMany([ + new CreateIndexModel( + Builders.IndexKeys.Ascending(v => v.UserId).Ascending(v => v.NoteId).Ascending(v => v.Variant), + new CreateIndexOptions { Unique = true, Name = "flashcard_schedule_unique_user_note_variant" }), + new CreateIndexModel( + Builders.IndexKeys.Ascending(v => v.UserId).Ascending(v => v.State).Ascending(v => v.DueUtc), + new CreateIndexOptions { Name = "flashcard_schedule_user_state_due" }) + ]); + _reviewLogs.Indexes.CreateMany([ + new CreateIndexModel( + Builders.IndexKeys.Ascending(v => v.UserId).Descending(v => v.ReviewedUtc), + new CreateIndexOptions { Name = "flashcard_review_log_user_reviewed" }), + new CreateIndexModel( + Builders.IndexKeys.Ascending(v => v.UserId).Ascending(v => v.CardId), + new CreateIndexOptions { Name = "flashcard_review_log_user_card" }) + ]); + } + + // Basic notes always have exactly one card (variant 0). Cloze notes get one independently + // scheduled card per distinct {{cN::...}} number found in the prompt. Existing variants are + // never removed on re-sync (even if a cloze number disappears from edited text) so a note + // edit never discards review history. + public async Task SyncVariantsAsync(FlashcardNote note) + { + var desiredVariants = note.Type == FlashcardTypes.Cloze + ? FlashcardClozeParser.DistinctClozeNumbers(note.Prompt) + : [0]; + if (desiredVariants.Count == 0) return; + + var existing = await GetByNoteIdAsync(note.UserId, note.Id!); + var existingVariants = existing.Select(v => v.Variant).ToHashSet(); + var now = DateTime.UtcNow; + foreach (var variant in desiredVariants.Where(v => !existingVariants.Contains(v))) + { + var schedule = new FlashcardSchedule + { + UserId = note.UserId, + NoteId = note.Id!, + Variant = variant, + State = FlashcardScheduleStates.New, + DueUtc = now, + CreatedUtc = now + }; + try + { + await _schedules.InsertOneAsync(schedule); + } + catch (MongoWriteException exception) when (exception.WriteError?.Category == ServerErrorCategory.DuplicateKey) + { + // Concurrent save already created this variant. + } + } + } + + // Sibling of SyncVariantsAsync: materializes this note's variant schedule rows under a + // DIFFERENT user's ownership (the studying user, not the note's own owner). Used only for + // system notes, so a regular user studying shared content gets independent, per-user FSRS + // state. Deliberately duplicated rather than sharing a parameterized private helper with + // SyncVariantsAsync, which is called eagerly on every regular note save and must stay + // untouched/low-risk. + public async Task SyncVariantsForStudyingUserAsync(string studyingUserId, FlashcardNote note) + { + var desiredVariants = note.Type == FlashcardTypes.Cloze + ? FlashcardClozeParser.DistinctClozeNumbers(note.Prompt) + : [0]; + if (desiredVariants.Count == 0) return; + + var existing = await GetByNoteIdAsync(studyingUserId, note.Id!); + var existingVariants = existing.Select(v => v.Variant).ToHashSet(); + var now = DateTime.UtcNow; + foreach (var variant in desiredVariants.Where(v => !existingVariants.Contains(v))) + { + var schedule = new FlashcardSchedule + { + UserId = studyingUserId, + NoteId = note.Id!, + Variant = variant, + State = FlashcardScheduleStates.New, + DueUtc = now, + CreatedUtc = now + }; + try + { + await _schedules.InsertOneAsync(schedule); + } + catch (MongoWriteException exception) when (exception.WriteError?.Category == ServerErrorCategory.DuplicateKey) + { + // Concurrent save already created this variant. + } + } + } + + public async Task GetAsync(string userId, string cardId) => + await _schedules.Find(v => v.UserId == userId && v.Id == cardId).FirstOrDefaultAsync(); + + public Task> GetByNoteIdAsync(string userId, string noteId) => + _schedules.Find(v => v.UserId == userId && v.NoteId == noteId).ToListAsync(); + + public async Task> GetDueAsync( + string userId, IReadOnlyCollection noteIds, DateTime nowUtc, DateTime studyDayStartUtc, int limit) + { + if (noteIds.Count == 0 || limit <= 0) return []; + return await _schedules.Find(v => v.UserId == userId + && noteIds.Contains(v.NoteId) + && v.State != FlashcardScheduleStates.New + && !v.IsSuspended + && (v.BuriedUntilUtc == null || v.BuriedUntilUtc <= studyDayStartUtc) + && v.DueUtc <= nowUtc) + .SortBy(v => v.DueUtc) + .Limit(limit) + .ToListAsync(); + } + + public async Task> GetNewAsync( + string userId, IReadOnlyCollection noteIds, DateTime studyDayStartUtc, int limit) + { + if (noteIds.Count == 0 || limit <= 0) return []; + return await _schedules.Find(v => v.UserId == userId + && noteIds.Contains(v.NoteId) + && v.State == FlashcardScheduleStates.New + && !v.IsSuspended + && (v.BuriedUntilUtc == null || v.BuriedUntilUtc <= studyDayStartUtc)) + .SortBy(v => v.CreatedUtc) + .Limit(limit) + .ToListAsync(); + } + + public Task CountDueAsync(string userId, IReadOnlyCollection noteIds, DateTime nowUtc, DateTime studyDayStartUtc) => + noteIds.Count == 0 + ? Task.FromResult(0L) + : _schedules.CountDocumentsAsync(v => v.UserId == userId + && noteIds.Contains(v.NoteId) + && v.State != FlashcardScheduleStates.New + && !v.IsSuspended + && (v.BuriedUntilUtc == null || v.BuriedUntilUtc <= studyDayStartUtc) + && v.DueUtc <= nowUtc); + + public Task CountNewAsync(string userId, IReadOnlyCollection noteIds, DateTime studyDayStartUtc) => + noteIds.Count == 0 + ? Task.FromResult(0L) + : _schedules.CountDocumentsAsync(v => v.UserId == userId + && noteIds.Contains(v.NoteId) + && v.State == FlashcardScheduleStates.New + && !v.IsSuspended + && (v.BuriedUntilUtc == null || v.BuriedUntilUtc <= studyDayStartUtc)); + + public Task CountReviewsSinceAsync(string userId, DateTime sinceUtc) => + _reviewLogs.CountDocumentsAsync(v => v.UserId == userId && v.ReviewedUtc >= sinceUtc && v.UndoneUtc == null); + + public Task CountByStateAsync(string userId, IReadOnlyCollection noteIds, string state) => + noteIds.Count == 0 + ? Task.FromResult(0L) + : _schedules.CountDocumentsAsync(v => v.UserId == userId + && noteIds.Contains(v.NoteId) + && v.State == state + && !v.IsSuspended); + + // A card counts toward "mastered" once it has graduated to long-term review and reached a + // stability (FSRS's estimate of days until retention drops to ~90%) past this threshold - + // the same rough "won't need attention again soon" cutoff several SRS apps use for a mastery + // stat. Kept separate from the plain Review-state count, which includes recently-graduated + // cards still building stability. + public const double MasteryStabilityDaysThreshold = 21.0; + + public Task CountMasteredAsync(string userId, IReadOnlyCollection noteIds) => + noteIds.Count == 0 + ? Task.FromResult(0L) + : _schedules.CountDocumentsAsync(v => v.UserId == userId + && noteIds.Contains(v.NoteId) + && v.State == FlashcardScheduleStates.Review + && !v.IsSuspended + && v.Stability >= MasteryStabilityDaysThreshold); + + // Bounded lookback (streaks longer than this are vanishingly rare and this keeps the query + // cheap); returns raw timestamps so the caller can compute consecutive-day streaks in the + // user's own calendar-day semantics. + public Task> GetReviewTimestampsSinceAsync(string userId, DateTime sinceUtc) => + _reviewLogs.Find(v => v.UserId == userId && v.ReviewedUtc >= sinceUtc && v.UndoneUtc == null) + .Project(v => v.ReviewedUtc) + .ToListAsync(); + + public async Task CountNewIntroducedSinceAsync(string userId, DateTime sinceUtc) => + checked((int)await _reviewLogs.CountDocumentsAsync(v => v.UserId == userId + && v.ReviewedUtc >= sinceUtc && v.UndoneUtc == null && v.Before.State == FlashcardScheduleStates.New)); + + public Task> GetReviewsSinceAsync(string userId, DateTime sinceUtc) => + _reviewLogs.Find(v => v.UserId == userId && v.ReviewedUtc >= sinceUtc).SortBy(v => v.ReviewedUtc).ToListAsync(); + + public Task> GetReviewLogsForCardAsync(string userId, string cardId) => + _reviewLogs.Find(v => v.UserId == userId && v.CardId == cardId).SortBy(v => v.ReviewedUtc).ToListAsync(); + + // Used only by JSON import: restores a previously-exported card's FSRS state verbatim + // (not a normal review) so importing preserves history instead of re-deriving it. + public Task ImportScheduleStateAsync(string userId, string noteId, FlashcardSchedule imported) => + _schedules.UpdateOneAsync( + v => v.UserId == userId && v.NoteId == noteId && v.Variant == imported.Variant, + Builders.Update + .Set(v => v.State, imported.State) + .Set(v => v.Step, imported.Step) + .Set(v => v.Stability, imported.Stability) + .Set(v => v.Difficulty, imported.Difficulty) + .Set(v => v.DueUtc, imported.DueUtc) + .Set(v => v.LastReviewUtc, imported.LastReviewUtc) + .Set(v => v.IsSuspended, imported.IsSuspended) + .Set(v => v.BuriedUntilUtc, imported.BuriedUntilUtc) + .Inc(v => v.Revision, 1)); + + public async Task ImportReviewLogAsync(string userId, string cardId, FlashcardReviewLog imported) + { + var log = new FlashcardReviewLog + { + Id = string.IsNullOrWhiteSpace(imported.Id) ? Guid.NewGuid().ToString() : imported.Id, + UserId = userId, + CardId = cardId, + Rating = imported.Rating, + ReviewedUtc = imported.ReviewedUtc, + DurationSeconds = imported.DurationSeconds, + Before = imported.Before, + After = imported.After, + CreatedUtc = imported.CreatedUtc, + UndoneUtc = imported.UndoneUtc + }; + try + { + await _reviewLogs.InsertOneAsync(log); + } + catch (MongoWriteException exception) when (exception.WriteError?.Category == ServerErrorCategory.DuplicateKey) + { + // Already imported (re-running the same import commit). + } + } + + public List PreviewRatingOptions(FlashcardSchedule schedule, DateTime nowUtc) => + _scheduler.PreviewRatingOptions(schedule, nowUtc); + + public async Task SubmitReviewAsync(string userId, FlashcardReviewRequest request) + { + var schedule = await GetAsync(userId, request.CardId); + if (schedule is null) return FlashcardReviewOutcome.NotFound(); + + if (schedule.Revision != request.ExpectedRevision) + { + var priorLog = await _reviewLogs.Find(v => v.Id == request.ClientReviewId && v.UserId == userId).FirstOrDefaultAsync(); + return priorLog is not null && priorLog.CardId == request.CardId + ? FlashcardReviewOutcome.Duplicate(schedule) + : FlashcardReviewOutcome.Conflict(schedule); + } + + var before = Snapshot(schedule); + var result = _scheduler.Review(schedule, request.Rating, request.ReviewedUtc, request.DurationSeconds); + + var updated = await _schedules.FindOneAndUpdateAsync( + v => v.UserId == userId && v.Id == request.CardId && v.Revision == request.ExpectedRevision, + Builders.Update + .Set(v => v.State, result.State) + .Set(v => v.Step, result.Step) + .Set(v => v.Stability, result.Stability) + .Set(v => v.Difficulty, result.Difficulty) + .Set(v => v.DueUtc, result.DueUtc) + .Set(v => v.LastReviewUtc, result.LastReviewUtc) + .Set(v => v.AlgorithmVersion, FsrsFlashcardScheduler.AlgorithmVersion) + .Inc(v => v.Revision, 1), + new FindOneAndUpdateOptions { ReturnDocument = ReturnDocument.After }); + + if (updated is null) + { + var raceLog = await _reviewLogs.Find(v => v.Id == request.ClientReviewId && v.UserId == userId).FirstOrDefaultAsync(); + var current = await GetAsync(userId, request.CardId); + return raceLog is not null && raceLog.CardId == request.CardId + ? FlashcardReviewOutcome.Duplicate(current) + : FlashcardReviewOutcome.Conflict(current); + } + + var log = new FlashcardReviewLog + { + Id = request.ClientReviewId, + UserId = userId, + CardId = request.CardId, + Rating = request.Rating, + ReviewedUtc = request.ReviewedUtc, + DurationSeconds = Math.Clamp(request.DurationSeconds, 0, 86_400), + Before = before, + After = Snapshot(updated), + CreatedUtc = DateTime.UtcNow + }; + try + { + await _reviewLogs.InsertOneAsync(log); + } + catch (MongoWriteException exception) when (exception.WriteError?.Category == ServerErrorCategory.DuplicateKey) + { + // A retried duplicate request already recorded this review; the schedule update + // above only applies once because it matched the pre-review revision exactly. + } + + // Bury cloze siblings (basic notes have exactly one variant, so this is a no-op for them). + var buriedUntil = FlashcardScheduling.NextStudyDayStart(request.StudyDayStartUtc); + await _schedules.UpdateManyAsync( + v => v.UserId == userId && v.NoteId == updated.NoteId && v.Id != updated.Id, + Builders.Update.Set(v => v.BuriedUntilUtc, buriedUntil)); + + return FlashcardReviewOutcome.Success(updated); + } + + public async Task UndoAsync(string userId, string reviewLogId) + { + var log = await _reviewLogs.Find(v => v.Id == reviewLogId && v.UserId == userId && v.UndoneUtc == null).FirstOrDefaultAsync(); + if (log is null) return FlashcardReviewOutcome.NotFound(); + + var current = await GetAsync(userId, log.CardId); + if (current is null) return FlashcardReviewOutcome.NotFound(); + if (current.Revision != log.After.Revision) + return FlashcardReviewOutcome.Conflict(current); + + var restored = await _schedules.FindOneAndUpdateAsync( + v => v.UserId == userId && v.Id == log.CardId && v.Revision == log.After.Revision, + Builders.Update + .Set(v => v.State, log.Before.State) + .Set(v => v.Step, log.Before.Step) + .Set(v => v.Stability, log.Before.Stability) + .Set(v => v.Difficulty, log.Before.Difficulty) + .Set(v => v.DueUtc, log.Before.DueUtc) + .Set(v => v.LastReviewUtc, log.Before.LastReviewUtc) + .Inc(v => v.Revision, 1), + new FindOneAndUpdateOptions { ReturnDocument = ReturnDocument.After }); + + if (restored is null) return FlashcardReviewOutcome.Conflict(await GetAsync(userId, log.CardId)); + + await _reviewLogs.UpdateOneAsync( + v => v.Id == reviewLogId, + Builders.Update.Set(v => v.UndoneUtc, DateTime.UtcNow)); + return FlashcardReviewOutcome.Success(restored); + } + + public async Task SetSuspendedAsync(string userId, IReadOnlyCollection cardIds, bool suspended) + { + if (cardIds.Count == 0) return 0; + var result = await _schedules.UpdateManyAsync( + v => v.UserId == userId && cardIds.Contains(v.Id!), + Builders.Update.Set(v => v.IsSuspended, suspended).Inc(v => v.Revision, 1)); + return result.ModifiedCount; + } + + public async Task BuryAsync(string userId, IReadOnlyCollection cardIds, DateTime untilUtc) + { + if (cardIds.Count == 0) return 0; + var result = await _schedules.UpdateManyAsync( + v => v.UserId == userId && cardIds.Contains(v.Id!), + Builders.Update.Set(v => v.BuriedUntilUtc, untilUtc).Inc(v => v.Revision, 1)); + return result.ModifiedCount; + } + + private static FlashcardScheduleSnapshot Snapshot(FlashcardSchedule schedule) => new() + { + State = schedule.State, + Step = schedule.Step, + Stability = schedule.Stability, + Difficulty = schedule.Difficulty, + DueUtc = schedule.DueUtc, + LastReviewUtc = schedule.LastReviewUtc, + Revision = schedule.Revision + }; +} diff --git a/api/QuestionsApi/Services/Flashcards/FlashcardScheduler.cs b/api/QuestionsApi/Services/Flashcards/FlashcardScheduler.cs new file mode 100644 index 0000000..67c3508 --- /dev/null +++ b/api/QuestionsApi/Services/Flashcards/FlashcardScheduler.cs @@ -0,0 +1,116 @@ +using FSRS.Core.Enums; +using FSRS.Core.Models; +using FSRS.Core.Services; +using QuestionsApi.Models; + +namespace QuestionsApi.Services.Flashcards; + +public sealed class FlashcardScheduleResult +{ + public string State { get; init; } = FlashcardScheduleStates.Learning; + public int? Step { get; init; } + public double Stability { get; init; } + public double Difficulty { get; init; } + public DateTime DueUtc { get; init; } + public DateTime LastReviewUtc { get; init; } +} + +public interface IFlashcardScheduler +{ + FlashcardScheduleResult Review(FlashcardSchedule schedule, string rating, DateTime reviewedUtc, int durationSeconds); + List PreviewRatingOptions(FlashcardSchedule schedule, DateTime nowUtc); +} + +// Wraps FSRS.Core 1.0.7 behind an internal interface, pinned to the repo's fixed defaults +// (90% desired retention, 36,500 day max interval, no per-user parameter optimization). +// Fuzzing is disabled so scheduling is deterministic: reproducible for tests/support, and +// so a review's outcome depends only on stored state, never on wall-clock randomness. +public sealed class FsrsFlashcardScheduler : IFlashcardScheduler +{ + public const string AlgorithmVersion = "fsrs-core-1.0.7"; + public const double DesiredRetention = 0.90; + public const int MaximumIntervalDays = 36_500; + + private readonly Scheduler _scheduler = new( + desiredRetention: DesiredRetention, + maximumInterval: MaximumIntervalDays, + enableFuzzing: false); + + public FlashcardScheduleResult Review(FlashcardSchedule schedule, string rating, DateTime reviewedUtc, int durationSeconds) + { + var card = ToCard(schedule); + var (updated, _) = _scheduler.ReviewCard(card, ParseRating(rating), reviewedUtc, Math.Max(0, durationSeconds) * 1000); + return FromCard(updated); + } + + public List PreviewRatingOptions(FlashcardSchedule schedule, DateTime nowUtc) + { + var options = new List(); + foreach (var rating in FlashcardRatings.All) + { + var card = ToCard(schedule); + var (updated, _) = _scheduler.ReviewCard(card, ParseRating(rating), nowUtc); + options.Add(new FlashcardRatingOption + { + Rating = rating, + DueUtc = updated.Due, + IntervalLabel = FormatInterval(updated.Due - nowUtc) + }); + } + return options; + } + + private static Card ToCard(FlashcardSchedule schedule) => + schedule.LastReviewUtc is null + ? new Card() + : new Card( + state: ParseState(schedule.State), + step: schedule.Step, + stability: schedule.Stability, + difficulty: schedule.Difficulty, + due: schedule.DueUtc, + lastReview: schedule.LastReviewUtc); + + private static FlashcardScheduleResult FromCard(Card card) => new() + { + State = FormatState(card.State), + Step = card.Step, + Stability = card.Stability ?? 0, + Difficulty = card.Difficulty ?? 0, + DueUtc = card.Due, + LastReviewUtc = card.LastReview ?? DateTime.UtcNow + }; + + private static Rating ParseRating(string rating) => rating switch + { + FlashcardRatings.Again => Rating.Again, + FlashcardRatings.Hard => Rating.Hard, + FlashcardRatings.Good => Rating.Good, + FlashcardRatings.Easy => Rating.Easy, + _ => throw new ArgumentOutOfRangeException(nameof(rating), rating, "unknown flashcard rating") + }; + + private static State ParseState(string state) => state switch + { + FlashcardScheduleStates.Review => State.Review, + FlashcardScheduleStates.Relearning => State.Relearning, + _ => State.Learning + }; + + private static string FormatState(State state) => state switch + { + State.Review => FlashcardScheduleStates.Review, + State.Relearning => FlashcardScheduleStates.Relearning, + _ => FlashcardScheduleStates.Learning + }; + + public static string FormatInterval(TimeSpan span) + { + if (span.TotalMinutes < 1) return "<1m"; + if (span.TotalHours < 1) return $"{(int)Math.Round(span.TotalMinutes)}m"; + if (span.TotalDays < 1) return $"{(int)Math.Round(span.TotalHours)}h"; + if (span.TotalDays < 30) return $"{(int)Math.Round(span.TotalDays)}d"; + if (span.TotalDays < 365) return $"{Math.Round(span.TotalDays / 30, 1)}mo"; + return $"{Math.Round(span.TotalDays / 365, 1)}y"; + } +} diff --git a/api/QuestionsApi/Services/Flashcards/FlashcardScheduling.cs b/api/QuestionsApi/Services/Flashcards/FlashcardScheduling.cs new file mode 100644 index 0000000..663eb48 --- /dev/null +++ b/api/QuestionsApi/Services/Flashcards/FlashcardScheduling.cs @@ -0,0 +1,54 @@ +namespace QuestionsApi.Services.Flashcards; + +// Small pure helpers pulled out of FlashcardStudyService/FlashcardScheduleStore so the +// daily-limit and sibling-bury rules can be unit tested without a live Mongo instance. +public static class FlashcardScheduling +{ + // A card reviewed on a given local study day buries its siblings until the next local day. + public static DateTime NextStudyDayStart(DateTime studyDayStartUtc) => studyDayStartUtc.Date.AddDays(1); + + // Effective per-day budget: deck override wins over the global default, minus what's + // already been consumed on the client's current local study day. + public static int ResolveDailyBudget(int globalLimit, int? deckOverride, int consumedToday) => + Math.Max(0, (deckOverride ?? globalLimit) - consumedToday); + + // A streak counts consecutive calendar days with at least one review, ending either today or + // yesterday (so the streak doesn't visibly reset to 0 the moment a new day starts before + // today's first review) - the same semantics as most habit-streak trackers. + public static int ComputeStreakDays(IReadOnlyCollection reviewTimestamps, DateTime todayUtc) + { + var reviewDays = reviewTimestamps.Select(v => v.Date).ToHashSet(); + var cursor = todayUtc.Date; + if (!reviewDays.Contains(cursor)) + { + cursor = cursor.AddDays(-1); + if (!reviewDays.Contains(cursor)) return 0; + } + var streak = 0; + while (reviewDays.Contains(cursor)) + { + streak++; + cursor = cursor.AddDays(-1); + } + return streak; + } + + // Picks which candidate in an already-sorted pool becomes the next card. Non-shuffle: the + // first not already excluded this session (today's deterministic oldest-first behavior). + // Shuffle: a random pick among the non-excluded candidates - `randomIndex(n)` must return a + // value in [0, n); production passes Random.Shared.Next, tests pass a fixed stub so the + // excluded-id-to-original-index mapping is verifiable without relying on real randomness. + public static int? PickIndex(IReadOnlyList poolIds, ISet exclude, bool shuffle, Func randomIndex) + { + if (!shuffle) + { + for (var i = 0; i < poolIds.Count; i++) + if (!exclude.Contains(poolIds[i])) return i; + return null; + } + var candidates = new List(); + for (var i = 0; i < poolIds.Count; i++) + if (!exclude.Contains(poolIds[i])) candidates.Add(i); + return candidates.Count == 0 ? null : candidates[randomIndex(candidates.Count)]; + } +} diff --git a/api/QuestionsApi/Services/Flashcards/FlashcardSettingsStore.cs b/api/QuestionsApi/Services/Flashcards/FlashcardSettingsStore.cs new file mode 100644 index 0000000..f7a6830 --- /dev/null +++ b/api/QuestionsApi/Services/Flashcards/FlashcardSettingsStore.cs @@ -0,0 +1,36 @@ +using MongoDB.Driver; +using QuestionsApi.Models; + +namespace QuestionsApi.Services.Flashcards; + +public sealed class FlashcardSettingsStore +{ + private readonly IMongoCollection _settings; + + public FlashcardSettingsStore(MongoDatabaseProvider mongo) + { + _settings = mongo.Database.GetCollection("FlashcardSettings"); + _settings.Indexes.CreateOne(new CreateIndexModel( + Builders.IndexKeys.Ascending(v => v.UserId), + new CreateIndexOptions { Unique = true, Name = "flashcard_settings_unique_user" })); + } + + public async Task GetAsync(string userId) => + await _settings.Find(v => v.UserId == userId).FirstOrDefaultAsync() + ?? new FlashcardSettings { UserId = userId }; + + public async Task UpdateAsync(string userId, UpdateFlashcardSettingsRequest request) + { + var now = DateTime.UtcNow; + return await _settings.FindOneAndUpdateAsync( + v => v.UserId == userId, + Builders.Update + .Set(v => v.NewCardsPerDay, Math.Clamp(request.NewCardsPerDay, 0, 2000)) + .Set(v => v.ReviewsPerDay, Math.Clamp(request.ReviewsPerDay, 0, 5000)) + .Set(v => v.DesiredRetention, FsrsFlashcardScheduler.DesiredRetention) + .Set(v => v.UpdatedUtc, now) + .Inc(v => v.Revision, 1) + .SetOnInsert(v => v.UserId, userId), + new FindOneAndUpdateOptions { IsUpsert = true, ReturnDocument = ReturnDocument.After }); + } +} diff --git a/api/QuestionsApi/Services/Flashcards/FlashcardStudyService.cs b/api/QuestionsApi/Services/Flashcards/FlashcardStudyService.cs new file mode 100644 index 0000000..4ccdffe --- /dev/null +++ b/api/QuestionsApi/Services/Flashcards/FlashcardStudyService.cs @@ -0,0 +1,158 @@ +using QuestionsApi.Models; + +namespace QuestionsApi.Services.Flashcards; + +// Composes decks/notes/schedules/settings to answer "what's next" and daily-count questions. +// Kept separate from the individual stores to avoid a circular dependency (notes need to +// sync schedule variants; schedules must not need to know about notes/decks). +public sealed class FlashcardStudyService( + FlashcardDeckStore decks, + FlashcardNoteStore notes, + FlashcardScheduleStore schedules, + FlashcardSettingsStore settingsStore) +{ + // Extra rows fetched beyond the budget when shuffling, so there's an actual pool to + // randomize over instead of just enough rows to skip past this session's exclusions. + private const int ShuffleCandidatePoolPadding = 40; + + public async Task GetNextCardAsync(string userId, FlashcardStudyRequest request) + { + var now = DateTime.UtcNow; + var studyDayStart = request.StudyDayStartUtc.Date; + var noteIds = await ResolveNoteIdsAsync(userId, request.DeckId); + var (newBudget, reviewBudget) = await ResolveBudgetsAsync(userId, request.DeckId, studyDayStart); + var exclude = request.ExcludeCardIds.Where(v => !string.IsNullOrWhiteSpace(v)).ToHashSet(StringComparer.Ordinal); + + // Overdue/due cards first (oldest due first, or shuffled within the due pool), then new + // cards (oldest first, or shuffled within the new pool) - shuffle never lets a new card + // jump ahead of a due one, it only randomizes selection within each pool. + FlashcardSchedule? card = null; + if (reviewBudget > 0) + { + var dueLimit = (request.Shuffle ? Math.Max(reviewBudget, ShuffleCandidatePoolPadding) : reviewBudget) + exclude.Count + 1; + var due = await schedules.GetDueAsync(userId, noteIds, now, studyDayStart, dueLimit); + var pickedIndex = FlashcardScheduling.PickIndex(due.Select(v => v.Id!).ToList(), exclude, request.Shuffle, Random.Shared.Next); + if (pickedIndex is int dueIndex) card = due[dueIndex]; + } + if (card is null && newBudget > 0) + { + var newLimit = (request.Shuffle ? Math.Max(newBudget, ShuffleCandidatePoolPadding) : newBudget) + exclude.Count + 1; + var fresh = await schedules.GetNewAsync(userId, noteIds, studyDayStart, newLimit); + var pickedIndex = FlashcardScheduling.PickIndex(fresh.Select(v => v.Id!).ToList(), exclude, request.Shuffle, Random.Shared.Next); + if (pickedIndex is int newIndex) card = fresh[newIndex]; + } + + var dueRemaining = checked((int)await schedules.CountDueAsync(userId, noteIds, now, studyDayStart)); + var newRemaining = checked((int)await schedules.CountNewAsync(userId, noteIds, studyDayStart)); + var response = new FlashcardStudyCardResponse + { + DueRemaining = Math.Min(dueRemaining, reviewBudget), + NewRemaining = Math.Min(newRemaining, newBudget) + }; + if (card is null) return response; + + var note = await notes.GetVisibleAsync(userId, card.NoteId); + if (note is null) return response; + + response.Note = note; + response.Card = card; + response.RatingOptions = schedules.PreviewRatingOptions(card, now); + return response; + } + + public async Task GetDashboardCountsAsync(string userId, DateTime nowUtc, DateTime weekStartUtc) + { + var studyDayStart = nowUtc.Date; + var noteIds = await ResolveNoteIdsAsync(userId, ""); + var (newBudget, _) = await ResolveBudgetsAsync(userId, "", studyDayStart); + var due = checked((int)await schedules.CountDueAsync(userId, noteIds, nowUtc, studyDayStart)); + var newAvailable = Math.Min(checked((int)await schedules.CountNewAsync(userId, noteIds, studyDayStart)), newBudget); + var reviewedThisWeek = checked((int)await schedules.CountReviewsSinceAsync(userId, weekStartUtc)); + var learning = checked((int)await schedules.CountByStateAsync(userId, noteIds, FlashcardScheduleStates.Learning)) + + checked((int)await schedules.CountByStateAsync(userId, noteIds, FlashcardScheduleStates.Relearning)); + var review = checked((int)await schedules.CountByStateAsync(userId, noteIds, FlashcardScheduleStates.Review)); + var reviewsToday = checked((int)await schedules.CountReviewsSinceAsync(userId, studyDayStart)); + var streakTimestamps = await schedules.GetReviewTimestampsSinceAsync(userId, nowUtc.AddDays(-400)); + var settings = await settingsStore.GetAsync(userId); + return new FlashcardDashboardCounts + { + Due = due, + NewAvailable = newAvailable, + ReviewedThisWeek = reviewedThisWeek, + Learning = learning, + Review = review, + CurrentStreakDays = FlashcardScheduling.ComputeStreakDays(streakTimestamps, nowUtc.Date), + ReviewsToday = reviewsToday, + DailyGoal = settings.ReviewsPerDay + }; + } + + public async Task> GetDeckSummariesAsync(string userId, DateTime nowUtc, DateTime studyDayStart) + { + var deckList = await decks.ListVisibleAsync(userId); + var result = new List(); + foreach (var deck in deckList) + { + var deckNotes = await notes.ListByDeckVisibleAsync(userId, deck.Id!); + await EnsureSystemScheduleRowsAsync(userId, deckNotes); + var deckNoteIds = deckNotes.Select(v => v.Id!).ToList(); + var due = checked((int)await schedules.CountDueAsync(userId, deckNoteIds, nowUtc, studyDayStart)); + var fresh = checked((int)await schedules.CountNewAsync(userId, deckNoteIds, studyDayStart)); + var learning = checked((int)await schedules.CountByStateAsync(userId, deckNoteIds, FlashcardScheduleStates.Learning)) + + checked((int)await schedules.CountByStateAsync(userId, deckNoteIds, FlashcardScheduleStates.Relearning)); + var review = checked((int)await schedules.CountByStateAsync(userId, deckNoteIds, FlashcardScheduleStates.Review)); + var mastered = checked((int)await schedules.CountMasteredAsync(userId, deckNoteIds)); + result.Add(new FlashcardDeckSummary + { + Deck = deck, + Total = deckNoteIds.Count, + Due = due, + New = fresh, + Learning = learning, + Review = review, + Mastered = mastered + }); + } + return result; + } + + + private async Task> ResolveNoteIdsAsync(string userId, string deckId) + { + var activeNotes = string.IsNullOrWhiteSpace(deckId) + ? await notes.ListAllVisibleActiveAsync(userId) + : await notes.ListByDeckVisibleAsync(userId, deckId); + await EnsureSystemScheduleRowsAsync(userId, activeNotes); + return activeNotes.Select(v => v.Id!).ToList(); + } + + // Lazily materializes this user's own FlashcardSchedule rows for any note they don't own + // (i.e. system notes) before it's counted/served. A no-op once a user has touched a given + // system note (SyncVariantsForStudyingUserAsync is duplicate-key-tolerant and additive-only). + private async Task EnsureSystemScheduleRowsAsync(string userId, List activeNotes) + { + foreach (var note in activeNotes.Where(v => v.IsSystem)) + await schedules.SyncVariantsForStudyingUserAsync(userId, note); + } + + private async Task<(int NewBudget, int ReviewBudget)> ResolveBudgetsAsync(string userId, string deckId, DateTime studyDayStart) + { + var settings = await settingsStore.GetAsync(userId); + var newLimit = settings.NewCardsPerDay; + var reviewLimit = settings.ReviewsPerDay; + if (!string.IsNullOrWhiteSpace(deckId)) + { + var deck = await decks.GetVisibleAsync(userId, deckId); + if (deck is not null) + { + newLimit = deck.NewCardsPerDay ?? newLimit; + reviewLimit = deck.ReviewsPerDay ?? reviewLimit; + } + } + var reviewedToday = checked((int)await schedules.CountReviewsSinceAsync(userId, studyDayStart)); + var newIntroducedToday = await schedules.CountNewIntroducedSinceAsync(userId, studyDayStart); + return ( + FlashcardScheduling.ResolveDailyBudget(newLimit, null, newIntroducedToday), + FlashcardScheduling.ResolveDailyBudget(reviewLimit, null, reviewedToday)); + } +} diff --git a/api/QuestionsApi/Services/QuestionBookmarkStore.cs b/api/QuestionsApi/Services/QuestionBookmarkStore.cs index bfdc6fd..33310de 100644 --- a/api/QuestionsApi/Services/QuestionBookmarkStore.cs +++ b/api/QuestionsApi/Services/QuestionBookmarkStore.cs @@ -105,7 +105,11 @@ public sealed class QuestionBookmarkStore } var questionMatch = new BsonDocument(); - if (!includeUnpublished) questionMatch["Question.IsPublished"] = true; + if (!includeUnpublished) + { + questionMatch["Question.IsPublished"] = true; + questionMatch["Question.FlaggedForReviewUtc"] = BsonNull.Value; + } if (!string.IsNullOrWhiteSpace(system) && system != "All Systems") questionMatch["Question.System"] = system.Trim(); if (!string.IsNullOrWhiteSpace(discipline)) questionMatch["Question.Discipline"] = discipline.Trim(); if (!string.IsNullOrWhiteSpace(query)) @@ -172,7 +176,11 @@ public sealed class QuestionBookmarkStore new("$unwind", "$Question") }; if (!includeUnpublished) - pipeline.Add(new BsonDocument("$match", new BsonDocument("Question.IsPublished", true))); + pipeline.Add(new BsonDocument("$match", new BsonDocument + { + { "Question.IsPublished", true }, + { "Question.FlaggedForReviewUtc", BsonNull.Value } + })); pipeline.Add(new BsonDocument("$group", new BsonDocument { { "_id", BsonNull.Value }, diff --git a/api/QuestionsApi/Services/QuestionReportStore.cs b/api/QuestionsApi/Services/QuestionReportStore.cs index a082097..1b8b5f4 100644 --- a/api/QuestionsApi/Services/QuestionReportStore.cs +++ b/api/QuestionsApi/Services/QuestionReportStore.cs @@ -79,6 +79,39 @@ public class QuestionReportStore return report; } + public async Task CreateDynamicAsync( + DynamicGenerationClaims claims, + string userId, + string category, + string details) + { + var now = DateTime.UtcNow; + var report = new QuestionReport + { + QuestionExternalId = $"dynamic:{claims.BlueprintExternalId}:v{claims.BlueprintVersion}", + UserId = userId, + Category = category, + Details = details, + Status = QuestionReportStatus.Open, + SourceType = "dynamic", + DynamicGeneration = new DynamicGenerationDescriptor + { + BlueprintExternalId = claims.BlueprintExternalId, + BlueprintVersion = claims.BlueprintVersion, + GeneratorVersion = claims.GeneratorVersion, + Seed = claims.Seed, + CaseFamilyId = claims.CaseFamilyId, + DifficultyTier = claims.DifficultyTier, + FamilyElo = claims.FamilyElo, + EffectiveAbilityElo = claims.EffectiveAbilityElo + }, + CreatedUtc = now, + UpdatedUtc = now + }; + await _reports.InsertOneAsync(report); + return report; + } + public async Task GetByIdAsync(string id) { if (!ObjectId.TryParse(id, out _)) diff --git a/api/QuestionsApi/Services/QuestionStore.cs b/api/QuestionsApi/Services/QuestionStore.cs index 710e89a..9ade674 100644 --- a/api/QuestionsApi/Services/QuestionStore.cs +++ b/api/QuestionsApi/Services/QuestionStore.cs @@ -6,6 +6,9 @@ namespace QuestionsApi.Services; public class QuestionStore { + private const string MediaReviewReason = "Contains media - pending review"; + private const string MediaReviewFlaggedBy = "system"; + private readonly IMongoCollection _col; public QuestionStore(MongoDatabaseProvider mongo) @@ -26,7 +29,10 @@ public class QuestionStore var filters = new List>(); if (!includeUnpublished) + { filters.Add(builder.Eq(q => q.IsPublished, true)); + filters.Add(builder.Eq(q => q.FlaggedForReviewUtc, null)); + } if (!string.IsNullOrWhiteSpace(req.System) && req.System != "All Systems") filters.Add(builder.Eq(q => q.System, req.System.Trim())); if (!string.IsNullOrWhiteSpace(req.Discipline)) @@ -65,7 +71,7 @@ public class QuestionStore await _col.Find(Builders.Filter.Empty).ToListAsync(); public async Task> GetPublishedAsync() => - await _col.Find(question => question.IsPublished).ToListAsync(); + await _col.Find(question => question.IsPublished && question.FlaggedForReviewUtc == null).ToListAsync(); public async Task> GetByExternalIdsAsync(IEnumerable externalIds) { @@ -102,6 +108,7 @@ public class QuestionStore var question = FromRequest(req); question.CreatedUtc = DateTime.UtcNow; question.UpdatedUtc = DateTime.UtcNow; + ApplyMediaReviewGate(question); try { @@ -125,6 +132,10 @@ public class QuestionStore return null; question.CreatedUtc = existing.CreatedUtc; + question.FlaggedForReviewUtc = existing.FlaggedForReviewUtc; + question.FlagReason = existing.FlagReason; + question.FlaggedByUserId = existing.FlaggedByUserId; + ApplyMediaReviewGate(question); var result = await _col.ReplaceOneAsync(q => q.Id == id, question); return result.MatchedCount == 0 ? null : question; } @@ -143,12 +154,16 @@ public class QuestionStore { question.Id = existing.Id; question.CreatedUtc = existing.CreatedUtc; + question.FlaggedForReviewUtc = existing.FlaggedForReviewUtc; + question.FlagReason = existing.FlagReason; + question.FlaggedByUserId = existing.FlaggedByUserId; } else { question.Id = ObjectId.GenerateNewId().ToString(); question.CreatedUtc = DateTime.UtcNow; } + ApplyMediaReviewGate(question); await _col.ReplaceOneAsync(filter, question, new ReplaceOptions { IsUpsert = true }); count++; @@ -167,7 +182,7 @@ public class QuestionStore { var filter = includeUnpublished ? Builders.Filter.Empty - : Builders.Filter.Eq(q => q.IsPublished, true); + : Builders.Filter.Eq(q => q.IsPublished, true) & Builders.Filter.Eq(q => q.FlaggedForReviewUtc, null); var systems = await _col.Distinct(q => q.System, filter).ToListAsync(); return systems.Where(s => !string.IsNullOrWhiteSpace(s)).OrderBy(s => s).ToList(); } @@ -181,6 +196,45 @@ public class QuestionStore return requested.Except(existing, StringComparer.Ordinal).OrderBy(id => id).ToList(); } + // One-time bulk sweep: flags every question that has at least one image and isn't already + // flagged, in a single update rather than fetching everything into memory. + public async Task FlagAllWithMediaAsync(string flaggedByUserId, string reason) + { + var filter = Builders.Filter.Exists("Media.0", true) + & Builders.Filter.Eq(q => q.FlaggedForReviewUtc, null); + var update = Builders.Update + .Set(q => q.FlaggedForReviewUtc, DateTime.UtcNow) + .Set(q => q.FlagReason, reason) + .Set(q => q.FlaggedByUserId, flaggedByUserId); + var result = await _col.UpdateManyAsync(filter, update); + return result.ModifiedCount; + } + + public async Task FlagAsync(string id, string reason, string flaggedByUserId) + { + var update = Builders.Update + .Set(q => q.FlaggedForReviewUtc, DateTime.UtcNow) + .Set(q => q.FlagReason, reason) + .Set(q => q.FlaggedByUserId, flaggedByUserId); + return await _col.FindOneAndUpdateAsync(q => q.Id == id, update, + new FindOneAndUpdateOptions { ReturnDocument = ReturnDocument.After }); + } + + public async Task UnflagAsync(string id) + { + var update = Builders.Update + .Set(q => q.FlaggedForReviewUtc, null) + .Set(q => q.FlagReason, "") + .Set(q => q.FlaggedByUserId, ""); + return await _col.FindOneAndUpdateAsync(q => q.Id == id, update, + new FindOneAndUpdateOptions { ReturnDocument = ReturnDocument.After }); + } + + public async Task> GetFlaggedAsync() => + await _col.Find(q => q.FlaggedForReviewUtc != null) + .SortByDescending(q => q.FlaggedForReviewUtc) + .ToListAsync(); + public async Task UpdateReferencesByExternalIdAsync(IEnumerable updates) { var now = DateTime.UtcNow; @@ -198,6 +252,18 @@ public class QuestionStore return (int)result.MatchedCount; } + // Questions with media must be reviewed before reaching students - gate any question that + // gains media and isn't already flagged, so newly created/imported/edited questions can't + // slip past the media-review requirement the way a one-time sweep alone would miss. + internal static void ApplyMediaReviewGate(Question question) + { + if (question.Media.Count == 0 || question.FlaggedForReviewUtc is not null) + return; + question.FlaggedForReviewUtc = DateTime.UtcNow; + question.FlagReason = MediaReviewReason; + question.FlaggedByUserId = MediaReviewFlaggedBy; + } + private static Question FromRequest(UpsertQuestionRequest req) => new() { ExternalId = req.ExternalId.Trim(), diff --git a/api/QuestionsApi/Services/QuestionVisibility.cs b/api/QuestionsApi/Services/QuestionVisibility.cs new file mode 100644 index 0000000..12687a3 --- /dev/null +++ b/api/QuestionsApi/Services/QuestionVisibility.cs @@ -0,0 +1,9 @@ +namespace QuestionsApi.Services; + +// Pure student-visibility decision for a question, kept dependency-free (like FlashcardScheduling) +// so it's unit testable without a controller or Mongo. Consolidates what used to be duplicated +// inline across QuestionsController/QuestionReportsController. +public static class QuestionVisibility +{ + public static bool IsVisibleToStudent(bool isPublished, bool isFlagged) => isPublished && !isFlagged; +} diff --git a/api/QuestionsApi/Services/ReviewQueueStore.cs b/api/QuestionsApi/Services/ReviewQueueStore.cs index bf63817..54d0125 100644 --- a/api/QuestionsApi/Services/ReviewQueueStore.cs +++ b/api/QuestionsApi/Services/ReviewQueueStore.cs @@ -129,7 +129,7 @@ public sealed class ReviewQueueStore foreach (var candidate in candidates) { var question = await _questions.GetByExternalIdAsync(candidate.QuestionExternalId); - if (question is { IsPublished: true }) return candidate; + if (question is { IsPublished: true, FlaggedForReviewUtc: null }) return candidate; await _items.UpdateOneAsync( value => value.Id == candidate.Id && value.State == ReviewQueueStates.Pending, Builders.Update diff --git a/api/QuestionsApi/Services/SeedFlashcardsCommand.cs b/api/QuestionsApi/Services/SeedFlashcardsCommand.cs new file mode 100644 index 0000000..89123ba --- /dev/null +++ b/api/QuestionsApi/Services/SeedFlashcardsCommand.cs @@ -0,0 +1,62 @@ +using QuestionsApi.Models; +using QuestionsApi.Services.Flashcards; + +namespace QuestionsApi.Services; + +// Idempotent, single-target seed for testing "system" (shared, admin-curated) flashcards. +// Unlike ContentPromotionCommand (cross-environment copy, source+target connections), this only +// ever writes to the one database the app is already configured against, using the exact same +// store methods a real user's create/edit path uses - just under the reserved FlashcardOwners.System +// identity - so seeded content is created/updated through the same validation and revision +// handling as any other deck/note. +public static class SeedFlashcardsCommand +{ + public static async Task RunAsync(IConfiguration configuration, TextWriter output) + { + var mongo = new MongoDatabaseProvider(configuration); + var scheduleStore = new FlashcardScheduleStore(mongo, new FsrsFlashcardScheduler()); + var deckStore = new FlashcardDeckStore(mongo); + var noteStore = new FlashcardNoteStore(mongo, scheduleStore); + + var deck = await UpsertSystemDeckAsync(deckStore, "Step 3 Essentials", "Official curated review deck"); + await UpsertSystemNoteAsync(noteStore, deck.Id!, FlashcardTypes.Basic, + "A 62-year-old woman presents with sudden, painless, monocular vision loss. What is the diagnosis?", + "Central retinal artery occlusion", "\"Cherry-red spot\" on fundoscopy is the classic finding."); + await UpsertSystemNoteAsync(noteStore, deck.Id!, FlashcardTypes.Basic, + "Thyroid storm: correct order of pharmacologic treatment?", + "Beta-blocker (propranolol) first -> thionamide (PTU/methimazole) -> iodine solution (started >=1hr after thionamide) -> glucocorticoids", + "Giving iodine before the thionamide can fuel new hormone synthesis (Wolff-Chaikoff escape risk)."); + await UpsertSystemNoteAsync(noteStore, deck.Id!, FlashcardTypes.Cloze, + "First-line treatment for uncomplicated hypertension in a Black patient is {{c1::a thiazide diuretic or calcium channel blocker}}.", + "", ""); + + await output.WriteLineAsync($"Seeded system flashcards into deck '{deck.Name}' ({deck.Id})."); + return 0; + } + + private static async Task UpsertSystemDeckAsync(FlashcardDeckStore deckStore, string name, string description) + { + var existing = await deckStore.GetByNameAsync(FlashcardOwners.System, FlashcardDeckStore.Normalize(name)); + var request = new UpsertFlashcardDeckRequest { Name = name, Description = description }; + var result = await deckStore.UpsertAsync(FlashcardOwners.System, request, existing?.Id); + return result.Deck ?? existing ?? throw new InvalidOperationException($"failed to seed deck '{name}'"); + } + + private static async Task UpsertSystemNoteAsync( + FlashcardNoteStore noteStore, string deckId, string type, string prompt, string answer, string extra) + { + var fingerprint = FlashcardNoteStore.Fingerprint(type, prompt, answer); + var existingNotes = await noteStore.ListByDeckAsync(FlashcardOwners.System, deckId); + var existing = existingNotes.FirstOrDefault(v => v.ContentFingerprint == fingerprint); + var request = new UpsertFlashcardNoteRequest + { + DeckId = deckId, + Type = type, + Prompt = prompt, + Answer = answer, + Extra = extra, + ExpectedRevision = existing?.Revision + }; + await noteStore.UpsertAsync(FlashcardOwners.System, request, existing?.Id); + } +} diff --git a/api/QuestionsApi/Services/ServiceMetrics.cs b/api/QuestionsApi/Services/ServiceMetrics.cs index 8270bac..f1365d3 100644 --- a/api/QuestionsApi/Services/ServiceMetrics.cs +++ b/api/QuestionsApi/Services/ServiceMetrics.cs @@ -13,6 +13,10 @@ public sealed class ServiceMetrics private long _httpRequests; private long _httpDurationMicroseconds; private long _answerStatisticsBackfillComplete; + private readonly System.Collections.Concurrent.ConcurrentDictionary _dynamicSelections = new(); + private readonly System.Collections.Concurrent.ConcurrentDictionary _dynamicAttempts = new(); + private readonly System.Collections.Concurrent.ConcurrentDictionary _dynamicCorrect = new(); + private long _dynamicTargetGapTotal; public void AttemptCreated() => Interlocked.Increment(ref _attemptsCreated); public void AttemptDuplicate() => Interlocked.Increment(ref _attemptDuplicates); @@ -29,6 +33,16 @@ public sealed class ServiceMetrics Interlocked.Increment(ref _httpRequests); Interlocked.Add(ref _httpDurationMicroseconds, (long)(duration.TotalMilliseconds * 1000)); } + public void DynamicSelected(string tier, int targetGap) + { + _dynamicSelections.AddOrUpdate(tier, 1, (_, value) => value + 1); + Interlocked.Add(ref _dynamicTargetGapTotal, Math.Abs(targetGap)); + } + public void DynamicAttempt(string tier, bool correct) + { + _dynamicAttempts.AddOrUpdate(tier, 1, (_, value) => value + 1); + if (correct) _dynamicCorrect.AddOrUpdate(tier, 1, (_, value) => value + 1); + } public string RenderPrometheus() { @@ -39,6 +53,16 @@ public sealed class ServiceMetrics AppendCounter(output, "step3_rating_failures_total", Interlocked.Read(ref _ratingFailures)); AppendCounter(output, "step3_answer_reconciliations_total", Interlocked.Read(ref _reconciliations)); AppendCounter(output, "step3_http_requests_total", Interlocked.Read(ref _httpRequests)); + output.AppendLine("# TYPE step3_dynamic_selections_total counter"); + output.AppendLine("# TYPE step3_dynamic_attempts_total counter"); + output.AppendLine("# TYPE step3_dynamic_correct_total counter"); + foreach (var tier in _dynamicSelections.Keys.Order()) + { + AppendLabeledSample(output, "step3_dynamic_selections_total", tier, _dynamicSelections.GetValueOrDefault(tier)); + AppendLabeledSample(output, "step3_dynamic_attempts_total", tier, _dynamicAttempts.GetValueOrDefault(tier)); + AppendLabeledSample(output, "step3_dynamic_correct_total", tier, _dynamicCorrect.GetValueOrDefault(tier)); + } + AppendCounter(output, "step3_dynamic_target_gap_points_total", Interlocked.Read(ref _dynamicTargetGapTotal)); output.AppendLine("# TYPE step3_http_request_duration_seconds summary"); output.Append("step3_http_request_duration_seconds_count ") .Append(Interlocked.Read(ref _httpRequests)) @@ -62,4 +86,9 @@ public sealed class ServiceMetrics output.Append("# TYPE ").Append(name).AppendLine(" counter"); output.Append(name).Append(' ').Append(value).AppendLine(); } + + private static void AppendLabeledSample(StringBuilder output, string name, string tier, long value) + { + output.Append(name).Append("{tier=\"").Append(tier.Replace("\"", "")).Append("\"} ").Append(value).AppendLine(); + } } diff --git a/api/QuestionsApi/Services/UserProfileStore.cs b/api/QuestionsApi/Services/UserProfileStore.cs index e798da5..2e3c086 100644 --- a/api/QuestionsApi/Services/UserProfileStore.cs +++ b/api/QuestionsApi/Services/UserProfileStore.cs @@ -14,9 +14,12 @@ public sealed class UserProfileStore [ "QuestionAttempts", "UserRatings", "QuestionBookmarks", "UserNotepads", "ReviewQueueItems", "QuestionReviewAttempts", "ReviewQueueBackfillStates", + "DynamicQuestionAttempts", "DynamicObjectiveMastery", "DynamicReviewItems", "CcsAttempts", "CcsUserRatings", "CcsRatedCaseResults", "ExamSimulations", "Step3Outcomes", "QuestionReports", "CcsCaseReports", "AppFeedback", "UserStudyProfiles", - "ExternalIdentities", "ExternalAuthTransactions" + "ExternalIdentities", "ExternalAuthTransactions", + "FlashcardDecks", "FlashcardNotes", "FlashcardSchedules", "FlashcardReviewLogs", + "FlashcardMediaAssets", "FlashcardSettings", "FlashcardImportPreviews" ]; public UserProfileStore(MongoDatabaseProvider mongo) @@ -48,6 +51,20 @@ public sealed class UserProfileStore new FindOneAndUpdateOptions { IsUpsert = true, ReturnDocument = ReturnDocument.After }); } + public async Task UpdateTextScaleAsync(string userId, UpdateTextScaleRequest request) + { + var update = Builders.Update.Set(value => value.UpdatedUtc, DateTime.UtcNow); + if (request.QuestionTextScale is { } questionScale) + update = update.Set(value => value.QuestionTextScale, questionScale); + if (request.CcsTextScale is { } ccsScale) + update = update.Set(value => value.CcsTextScale, ccsScale); + update = update.SetOnInsert(value => value.UserId, userId); + return await _profiles.FindOneAndUpdateAsync( + value => value.UserId == userId, + update, + new FindOneAndUpdateOptions { IsUpsert = true, ReturnDocument = ReturnDocument.After }); + } + public Task IsDeletedAsync(string userId) => _deletedUsers.Find(value => value.UserId == userId).AnyAsync(); diff --git a/assets/branding/ranaze-app-icon.ico b/assets/branding/ranaze-app-icon.ico new file mode 100644 index 0000000..75d5383 Binary files /dev/null and b/assets/branding/ranaze-app-icon.ico differ diff --git a/assets/branding/ranaze-app-icon.png b/assets/branding/ranaze-app-icon.png index 40ed501..d6b2341 100644 Binary files a/assets/branding/ranaze-app-icon.png and b/assets/branding/ranaze-app-icon.png differ diff --git a/assets/branding/ranaze-mark.png b/assets/branding/ranaze-mark.png index 5798d9d..83f59bf 100644 Binary files a/assets/branding/ranaze-mark.png and b/assets/branding/ranaze-mark.png differ diff --git a/assets/icons/brands/google.svg b/assets/icons/brands/google.svg new file mode 100644 index 0000000..64b3094 --- /dev/null +++ b/assets/icons/brands/google.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/icons/brands/google.svg.import b/assets/icons/brands/google.svg.import new file mode 100644 index 0000000..678b412 --- /dev/null +++ b/assets/icons/brands/google.svg.import @@ -0,0 +1,43 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bc3ttuqtwb8e8" +path="res://.godot/imported/google.svg-96c1802a2f0dcbabfd95c7970a307f34.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/icons/brands/google.svg" +dest_files=["res://.godot/imported/google.svg-96c1802a2f0dcbabfd95c7970a307f34.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 +svg/scale=1.0 +editor/scale_with_editor_scale=false +editor/convert_colors_with_editor_theme=false diff --git a/assets/icons/brands/steam-dark.svg b/assets/icons/brands/steam-dark.svg new file mode 100644 index 0000000..675408f --- /dev/null +++ b/assets/icons/brands/steam-dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/brands/steam-dark.svg.import b/assets/icons/brands/steam-dark.svg.import new file mode 100644 index 0000000..5b89b35 --- /dev/null +++ b/assets/icons/brands/steam-dark.svg.import @@ -0,0 +1,43 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://b5f60sr0rt5tw" +path="res://.godot/imported/steam-dark.svg-0b8589df363e0bd197044b86e8158d1c.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/icons/brands/steam-dark.svg" +dest_files=["res://.godot/imported/steam-dark.svg-0b8589df363e0bd197044b86e8158d1c.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 +svg/scale=1.0 +editor/scale_with_editor_scale=false +editor/convert_colors_with_editor_theme=false diff --git a/assets/icons/brands/steam-light.svg b/assets/icons/brands/steam-light.svg new file mode 100644 index 0000000..0929818 --- /dev/null +++ b/assets/icons/brands/steam-light.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/brands/steam-light.svg.import b/assets/icons/brands/steam-light.svg.import new file mode 100644 index 0000000..50ec463 --- /dev/null +++ b/assets/icons/brands/steam-light.svg.import @@ -0,0 +1,43 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://b8sp3ihkkfidy" +path="res://.godot/imported/steam-light.svg-43e1d1f7b2cea83339ab43c99a461f9e.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/icons/brands/steam-light.svg" +dest_files=["res://.godot/imported/steam-light.svg-43e1d1f7b2cea83339ab43c99a461f9e.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 +svg/scale=1.0 +editor/scale_with_editor_scale=false +editor/convert_colors_with_editor_theme=false diff --git a/assets/icons/lucide/calculator.svg b/assets/icons/lucide/calculator.svg new file mode 100644 index 0000000..6cffcf7 --- /dev/null +++ b/assets/icons/lucide/calculator.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/assets/icons/lucide/calculator.svg.import b/assets/icons/lucide/calculator.svg.import new file mode 100644 index 0000000..74f4cf4 --- /dev/null +++ b/assets/icons/lucide/calculator.svg.import @@ -0,0 +1,43 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dy8lr6snglfpm" +path="res://.godot/imported/calculator.svg-0f64f216f45259cc59d80d23ea9a79a8.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/icons/lucide/calculator.svg" +dest_files=["res://.godot/imported/calculator.svg-0f64f216f45259cc59d80d23ea9a79a8.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 +svg/scale=1.0 +editor/scale_with_editor_scale=false +editor/convert_colors_with_editor_theme=false diff --git a/assets/icons/lucide/link.svg b/assets/icons/lucide/link.svg new file mode 100644 index 0000000..f6b9fed --- /dev/null +++ b/assets/icons/lucide/link.svg @@ -0,0 +1,14 @@ + + + + diff --git a/assets/icons/lucide/link.svg.import b/assets/icons/lucide/link.svg.import new file mode 100644 index 0000000..7133b18 --- /dev/null +++ b/assets/icons/lucide/link.svg.import @@ -0,0 +1,43 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bndvpquam5yd7" +path="res://.godot/imported/link.svg-0a6581aa8302c8c4308f0bb54259bf1e.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/icons/lucide/link.svg" +dest_files=["res://.godot/imported/link.svg-0a6581aa8302c8c4308f0bb54259bf1e.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 +svg/scale=1.0 +editor/scale_with_editor_scale=false +editor/convert_colors_with_editor_theme=false diff --git a/assets/icons/medmind/badge-circle-filled.svg b/assets/icons/medmind/badge-circle-filled.svg new file mode 100644 index 0000000..5661376 --- /dev/null +++ b/assets/icons/medmind/badge-circle-filled.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/medmind/badge-circle-filled.svg.import b/assets/icons/medmind/badge-circle-filled.svg.import new file mode 100644 index 0000000..d50f21c --- /dev/null +++ b/assets/icons/medmind/badge-circle-filled.svg.import @@ -0,0 +1,43 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://neyyjsv1yi2y" +path="res://.godot/imported/badge-circle-filled.svg-c615dfa6e3ba91563385092d88757ba4.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/icons/medmind/badge-circle-filled.svg" +dest_files=["res://.godot/imported/badge-circle-filled.svg-c615dfa6e3ba91563385092d88757ba4.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 +svg/scale=1.0 +editor/scale_with_editor_scale=false +editor/convert_colors_with_editor_theme=false diff --git a/assets/icons/medmind/badge-circle-outline.svg b/assets/icons/medmind/badge-circle-outline.svg new file mode 100644 index 0000000..5c299dc --- /dev/null +++ b/assets/icons/medmind/badge-circle-outline.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/medmind/badge-circle-outline.svg.import b/assets/icons/medmind/badge-circle-outline.svg.import new file mode 100644 index 0000000..3edb651 --- /dev/null +++ b/assets/icons/medmind/badge-circle-outline.svg.import @@ -0,0 +1,43 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://2epi5x1l2k3k" +path="res://.godot/imported/badge-circle-outline.svg-48e5795ac9a851c7826f85026138d065.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/icons/medmind/badge-circle-outline.svg" +dest_files=["res://.godot/imported/badge-circle-outline.svg-48e5795ac9a851c7826f85026138d065.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 +svg/scale=1.0 +editor/scale_with_editor_scale=false +editor/convert_colors_with_editor_theme=false diff --git a/assets/icons/medmind/clear-highlight-dark.svg b/assets/icons/medmind/clear-highlight-dark.svg index 9a1932e..63368e5 100644 --- a/assets/icons/medmind/clear-highlight-dark.svg +++ b/assets/icons/medmind/clear-highlight-dark.svg @@ -1,5 +1,9 @@ - - - + + + + + + + diff --git a/assets/icons/medmind/clear-highlight-dark.svg.import b/assets/icons/medmind/clear-highlight-dark.svg.import index 1e99851..06e2d62 100644 --- a/assets/icons/medmind/clear-highlight-dark.svg.import +++ b/assets/icons/medmind/clear-highlight-dark.svg.import @@ -2,7 +2,7 @@ importer="texture" type="CompressedTexture2D" -uid="uid://bx3x2rqpurqqw" +uid="uid://fl5g0hqtqegt" path="res://.godot/imported/clear-highlight-dark.svg-67d9eee7c448efe7f572e5fc819b8ea0.ctex" metadata={ "vram_texture": false diff --git a/assets/icons/medmind/clear-highlight-light.svg b/assets/icons/medmind/clear-highlight-light.svg index 454dd9e..94fc078 100644 --- a/assets/icons/medmind/clear-highlight-light.svg +++ b/assets/icons/medmind/clear-highlight-light.svg @@ -1,5 +1,9 @@ - - - + + + + + + + diff --git a/assets/icons/medmind/clear-highlight-light.svg.import b/assets/icons/medmind/clear-highlight-light.svg.import index c18ede7..8c2773c 100644 --- a/assets/icons/medmind/clear-highlight-light.svg.import +++ b/assets/icons/medmind/clear-highlight-light.svg.import @@ -2,7 +2,7 @@ importer="texture" type="CompressedTexture2D" -uid="uid://bgpsqicfixhew" +uid="uid://4vrs2omb0iy6" path="res://.godot/imported/clear-highlight-light.svg-93f7a6cb5f22325bc90fadf2e89641fc.ctex" metadata={ "vram_texture": false diff --git a/assets/icons/medmind/flag-dark.svg b/assets/icons/medmind/flag-dark.svg new file mode 100644 index 0000000..61b57f3 --- /dev/null +++ b/assets/icons/medmind/flag-dark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/medmind/flag-dark.svg.import b/assets/icons/medmind/flag-dark.svg.import new file mode 100644 index 0000000..3e8ff28 --- /dev/null +++ b/assets/icons/medmind/flag-dark.svg.import @@ -0,0 +1,43 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://pffbgabfvqoi" +path="res://.godot/imported/flag-dark.svg-991f8446e8d6e35dda3f6918b79c2529.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/icons/medmind/flag-dark.svg" +dest_files=["res://.godot/imported/flag-dark.svg-991f8446e8d6e35dda3f6918b79c2529.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 +svg/scale=1.0 +editor/scale_with_editor_scale=false +editor/convert_colors_with_editor_theme=false diff --git a/assets/icons/medmind/flag-light.svg b/assets/icons/medmind/flag-light.svg new file mode 100644 index 0000000..c760024 --- /dev/null +++ b/assets/icons/medmind/flag-light.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/medmind/flag-light.svg.import b/assets/icons/medmind/flag-light.svg.import new file mode 100644 index 0000000..c47f1b6 --- /dev/null +++ b/assets/icons/medmind/flag-light.svg.import @@ -0,0 +1,43 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://rrjvnmtbkc2c" +path="res://.godot/imported/flag-light.svg-028b26500c0b5f2e10e3a85231e2e56d.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/icons/medmind/flag-light.svg" +dest_files=["res://.godot/imported/flag-light.svg-028b26500c0b5f2e10e3a85231e2e56d.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 +svg/scale=1.0 +editor/scale_with_editor_scale=false +editor/convert_colors_with_editor_theme=false diff --git a/assets/icons/medmind/report-question-dark.svg b/assets/icons/medmind/report-question-dark.svg index 442a927..007fb72 100644 --- a/assets/icons/medmind/report-question-dark.svg +++ b/assets/icons/medmind/report-question-dark.svg @@ -1,5 +1,5 @@ - - - + + + diff --git a/assets/icons/medmind/report-question-dark.svg.import b/assets/icons/medmind/report-question-dark.svg.import index c2a2de3..9d75388 100644 --- a/assets/icons/medmind/report-question-dark.svg.import +++ b/assets/icons/medmind/report-question-dark.svg.import @@ -2,7 +2,7 @@ importer="texture" type="CompressedTexture2D" -uid="uid://d1rf1y5ym0abp" +uid="uid://2h8hksovbjvl" path="res://.godot/imported/report-question-dark.svg-0c56358e403130829c20d7684d988f4f.ctex" metadata={ "vram_texture": false diff --git a/assets/icons/medmind/report-question-light.svg b/assets/icons/medmind/report-question-light.svg index 425c867..acb7af3 100644 --- a/assets/icons/medmind/report-question-light.svg +++ b/assets/icons/medmind/report-question-light.svg @@ -1,5 +1,5 @@ - - - + + + diff --git a/assets/icons/medmind/report-question-light.svg.import b/assets/icons/medmind/report-question-light.svg.import index 856d312..8b09f03 100644 --- a/assets/icons/medmind/report-question-light.svg.import +++ b/assets/icons/medmind/report-question-light.svg.import @@ -2,7 +2,7 @@ importer="texture" type="CompressedTexture2D" -uid="uid://btqfcwm8c1ymy" +uid="uid://gljo3k630b6f" path="res://.godot/imported/report-question-light.svg-d6a637ee28802ff5e54da98728d1db20.ctex" metadata={ "vram_texture": false diff --git a/content/ccs/dka-emergency-001.json b/content/ccs/dka-emergency-001.json index 65a5bfc..0e023dd 100644 --- a/content/ccs/dka-emergency-001.json +++ b/content/ccs/dka-emergency-001.json @@ -1,25 +1,25 @@ { "orders": [ - { "code": "iv_access", "canonicalName": "Peripheral intravenous access", "category": "procedure", "aliases": ["iv", "intravenous line"], "defaultTurnaroundMinutes": 0, "availableLocations": ["emergency_department", "icu"] }, - { "code": "cardiac_monitor", "canonicalName": "Continuous cardiac monitoring", "category": "monitoring", "aliases": ["telemetry", "cardiac monitor"], "defaultTurnaroundMinutes": 0, "availableLocations": ["emergency_department", "icu"] }, - { "code": "pulse_oximetry", "canonicalName": "Continuous pulse oximetry", "category": "monitoring", "aliases": ["pulse ox", "oxygen saturation"], "defaultTurnaroundMinutes": 0, "availableLocations": ["emergency_department", "icu"] }, - { "code": "normal_saline_bolus", "canonicalName": "0.9% sodium chloride intravenous bolus", "category": "medication", "aliases": ["normal saline", "isotonic saline", "fluid bolus"], "defaultTurnaroundMinutes": 0, "availableLocations": ["emergency_department", "icu"] }, - { "code": "balanced_crystalloid_bolus", "canonicalName": "Balanced crystalloid intravenous bolus", "category": "medication", "aliases": ["lactated ringers", "ringer lactate", "iv fluids"], "defaultTurnaroundMinutes": 0, "availableLocations": ["emergency_department", "icu"] }, - { "code": "bmp", "canonicalName": "Basic metabolic panel", "category": "laboratory", "aliases": ["electrolytes", "chemistry", "potassium", "creatinine"], "defaultTurnaroundMinutes": 12, "availableLocations": ["emergency_department", "icu", "ward"] }, - { "code": "venous_blood_gas", "canonicalName": "Venous blood gas", "category": "laboratory", "aliases": ["vbg", "venous ph"], "defaultTurnaroundMinutes": 8, "availableLocations": ["emergency_department", "icu"] }, - { "code": "beta_hydroxybutyrate", "canonicalName": "Serum beta-hydroxybutyrate", "category": "laboratory", "aliases": ["ketones", "serum ketones", "bohb"], "defaultTurnaroundMinutes": 15, "availableLocations": ["emergency_department", "icu", "ward"] }, - { "code": "cbc", "canonicalName": "Complete blood count", "category": "laboratory", "aliases": ["cbc", "blood count"], "defaultTurnaroundMinutes": 12, "availableLocations": [] }, - { "code": "urinalysis", "canonicalName": "Urinalysis", "category": "laboratory", "aliases": ["urine test", "ua"], "defaultTurnaroundMinutes": 15, "availableLocations": [] }, - { "code": "pregnancy_test", "canonicalName": "Serum beta-hCG pregnancy test", "category": "laboratory", "aliases": ["pregnancy test", "hcg"], "defaultTurnaroundMinutes": 15, "availableLocations": [] }, - { "code": "ecg", "canonicalName": "12-lead electrocardiogram", "category": "diagnostic", "aliases": ["ekg", "electrocardiogram"], "defaultTurnaroundMinutes": 10, "availableLocations": ["emergency_department", "icu", "ward"] }, - { "code": "insulin_infusion", "canonicalName": "Regular insulin intravenous infusion", "category": "medication", "aliases": ["insulin drip", "iv insulin", "regular insulin"], "defaultTurnaroundMinutes": 0, "availableLocations": ["emergency_department", "icu"] }, - { "code": "potassium_chloride_iv", "canonicalName": "Potassium chloride added to intravenous fluid", "category": "medication", "aliases": ["potassium replacement", "kcl", "replace potassium"], "defaultTurnaroundMinutes": 0, "availableLocations": ["emergency_department", "icu"] }, - { "code": "dextrose_half_normal_saline", "canonicalName": "Dextrose 5% in 0.45% sodium chloride", "category": "medication", "aliases": ["d5 half normal", "dextrose fluid", "d5 0.45 saline"], "defaultTurnaroundMinutes": 0, "availableLocations": ["emergency_department", "icu"] }, - { "code": "sodium_bicarbonate_iv", "canonicalName": "Intravenous sodium bicarbonate", "category": "medication", "aliases": ["bicarbonate", "bicarb"], "defaultTurnaroundMinutes": 0, "availableLocations": ["emergency_department", "icu"] }, - { "code": "ceftriaxone", "canonicalName": "Intravenous ceftriaxone", "category": "medication", "aliases": ["rocephin", "antibiotic"], "defaultTurnaroundMinutes": 0, "availableLocations": [] }, - { "code": "serial_bmp", "canonicalName": "Basic metabolic panel every 4 hours", "category": "laboratory", "aliases": ["repeat electrolytes", "serial chemistry", "monitor potassium"], "defaultTurnaroundMinutes": 0, "availableLocations": ["emergency_department", "icu"] }, - { "code": "hourly_glucose", "canonicalName": "Capillary glucose every hour", "category": "monitoring", "aliases": ["hourly glucose", "fingerstick glucose", "poc glucose"], "defaultTurnaroundMinutes": 0, "availableLocations": ["emergency_department", "icu"] }, - { "code": "icu_admission", "canonicalName": "Admit to intensive care unit", "category": "disposition", "aliases": ["icu", "intensive care admission"], "defaultTurnaroundMinutes": 0, "availableLocations": ["emergency_department"] } + { "code": "iv_access", "canonicalName": "Peripheral intravenous access", "category": "procedure", "aliases": ["iv", "intravenous line"], "defaultTurnaroundMinutes": 0, "availableLocations": ["emergency_department", "icu"], "displayRoute": "Peripheral IV", "displayFrequency": "Once", "resultSection": "treatment" }, + { "code": "cardiac_monitor", "canonicalName": "Continuous cardiac monitoring", "category": "monitoring", "aliases": ["telemetry", "cardiac monitor"], "defaultTurnaroundMinutes": 0, "availableLocations": ["emergency_department", "icu"], "displayRoute": "", "displayFrequency": "Continuous", "resultSection": "treatment" }, + { "code": "pulse_oximetry", "canonicalName": "Continuous pulse oximetry", "category": "monitoring", "aliases": ["pulse ox", "oxygen saturation"], "defaultTurnaroundMinutes": 0, "availableLocations": ["emergency_department", "icu"], "displayRoute": "", "displayFrequency": "Continuous", "resultSection": "treatment" }, + { "code": "normal_saline_bolus", "canonicalName": "0.9% sodium chloride intravenous bolus", "category": "medication", "aliases": ["normal saline", "isotonic saline", "fluid bolus"], "defaultTurnaroundMinutes": 0, "availableLocations": ["emergency_department", "icu"], "displayRoute": "IV", "displayFrequency": "Bolus", "resultSection": "treatment" }, + { "code": "balanced_crystalloid_bolus", "canonicalName": "Balanced crystalloid intravenous bolus", "category": "medication", "aliases": ["lactated ringers", "ringer lactate", "iv fluids"], "defaultTurnaroundMinutes": 0, "availableLocations": ["emergency_department", "icu"], "displayRoute": "IV", "displayFrequency": "Bolus", "resultSection": "treatment" }, + { "code": "bmp", "canonicalName": "Basic metabolic panel", "category": "laboratory", "aliases": ["electrolytes", "chemistry", "potassium", "creatinine"], "defaultTurnaroundMinutes": 12, "availableLocations": ["emergency_department", "icu", "ward"], "displayRoute": "Blood", "displayFrequency": "Once", "resultSection": "lab" }, + { "code": "venous_blood_gas", "canonicalName": "Venous blood gas", "category": "laboratory", "aliases": ["vbg", "venous ph"], "defaultTurnaroundMinutes": 8, "availableLocations": ["emergency_department", "icu"], "displayRoute": "Venous blood", "displayFrequency": "Once", "resultSection": "lab" }, + { "code": "beta_hydroxybutyrate", "canonicalName": "Serum beta-hydroxybutyrate", "category": "laboratory", "aliases": ["ketones", "serum ketones", "bohb"], "defaultTurnaroundMinutes": 15, "availableLocations": ["emergency_department", "icu", "ward"], "displayRoute": "Blood", "displayFrequency": "Once", "resultSection": "lab" }, + { "code": "cbc", "canonicalName": "Complete blood count", "category": "laboratory", "aliases": ["cbc", "blood count"], "defaultTurnaroundMinutes": 12, "availableLocations": [], "displayRoute": "Blood", "displayFrequency": "Once", "resultSection": "lab" }, + { "code": "urinalysis", "canonicalName": "Urinalysis", "category": "laboratory", "aliases": ["urine test", "ua"], "defaultTurnaroundMinutes": 15, "availableLocations": [], "displayRoute": "Urine", "displayFrequency": "Once", "resultSection": "lab" }, + { "code": "pregnancy_test", "canonicalName": "Serum beta-hCG pregnancy test", "category": "laboratory", "aliases": ["pregnancy test", "hcg"], "defaultTurnaroundMinutes": 15, "availableLocations": [], "displayRoute": "Blood", "displayFrequency": "Once", "resultSection": "lab" }, + { "code": "ecg", "canonicalName": "12-lead electrocardiogram", "category": "diagnostic", "aliases": ["ekg", "electrocardiogram"], "defaultTurnaroundMinutes": 10, "availableLocations": ["emergency_department", "icu", "ward"], "displayRoute": "", "displayFrequency": "Once", "resultSection": "other_test" }, + { "code": "insulin_infusion", "canonicalName": "Regular insulin intravenous infusion", "category": "medication", "aliases": ["insulin drip", "iv insulin", "regular insulin"], "defaultTurnaroundMinutes": 0, "availableLocations": ["emergency_department", "icu"], "displayRoute": "IV", "displayFrequency": "Continuous", "resultSection": "treatment" }, + { "code": "potassium_chloride_iv", "canonicalName": "Potassium chloride added to intravenous fluid", "category": "medication", "aliases": ["potassium replacement", "kcl", "replace potassium"], "defaultTurnaroundMinutes": 0, "availableLocations": ["emergency_department", "icu"], "displayRoute": "IV", "displayFrequency": "Continuous", "resultSection": "treatment" }, + { "code": "dextrose_half_normal_saline", "canonicalName": "Dextrose 5% in 0.45% sodium chloride", "category": "medication", "aliases": ["d5 half normal", "dextrose fluid", "d5 0.45 saline"], "defaultTurnaroundMinutes": 0, "availableLocations": ["emergency_department", "icu"], "displayRoute": "IV", "displayFrequency": "Continuous", "resultSection": "treatment" }, + { "code": "sodium_bicarbonate_iv", "canonicalName": "Intravenous sodium bicarbonate", "category": "medication", "aliases": ["bicarbonate", "bicarb"], "defaultTurnaroundMinutes": 0, "availableLocations": ["emergency_department", "icu"], "displayRoute": "IV", "displayFrequency": "Once", "resultSection": "treatment" }, + { "code": "ceftriaxone", "canonicalName": "Intravenous ceftriaxone", "category": "medication", "aliases": ["rocephin", "antibiotic"], "defaultTurnaroundMinutes": 0, "availableLocations": [], "displayRoute": "IV", "displayFrequency": "Once", "resultSection": "treatment" }, + { "code": "serial_bmp", "canonicalName": "Basic metabolic panel every 4 hours", "category": "laboratory", "aliases": ["repeat electrolytes", "serial chemistry", "monitor potassium"], "defaultTurnaroundMinutes": 0, "availableLocations": ["emergency_department", "icu"], "displayRoute": "Blood", "displayFrequency": "Every 4 hours", "resultSection": "lab" }, + { "code": "hourly_glucose", "canonicalName": "Capillary glucose every hour", "category": "monitoring", "aliases": ["hourly glucose", "fingerstick glucose", "poc glucose"], "defaultTurnaroundMinutes": 0, "availableLocations": ["emergency_department", "icu"], "displayRoute": "Capillary", "displayFrequency": "Every hour", "resultSection": "treatment" }, + { "code": "icu_admission", "canonicalName": "Admit to intensive care unit", "category": "disposition", "aliases": ["icu", "intensive care admission"], "defaultTurnaroundMinutes": 0, "availableLocations": ["emergency_department"], "displayRoute": "", "displayFrequency": "Once", "resultSection": "treatment" } ], "cases": [ { @@ -36,7 +36,14 @@ "reasonForEncounter": "Two days of vomiting, abdominal discomfort, and progressive lethargy after stopping insulin when oral intake declined.", "appearance": "She is lethargic but arousable. Mucous membranes are dry, and respirations are deep and rapid.", "history": "She uses basal-bolus insulin and has no known kidney disease. She reports no dysuria, cough, diarrhea, alcohol use, or pregnancy symptoms. She has taken no insulin for approximately 36 hours.", - "vitals": { "temperature": "37.3 C (99.1 F)", "bloodPressure": "88/56 mm Hg", "pulse": "118/min", "respirations": "28/min", "oxygenSaturation": "98% on room air" }, + "historySections": [ + { "code": "hpi", "title": "History of present illness", "text": "Two days of vomiting, abdominal discomfort, and progressive lethargy followed reduced oral intake. She stopped all insulin approximately 36 hours ago." }, + { "code": "past_medical_history", "title": "Past medical history", "text": "Type 1 diabetes mellitus treated with a basal-bolus insulin regimen. No known kidney disease." }, + { "code": "medications", "title": "Medications", "text": "Basal and mealtime insulin; no doses for approximately 36 hours." }, + { "code": "allergies", "title": "Allergies", "text": "No known drug allergies." }, + { "code": "review_of_systems", "title": "Review of systems", "text": "No dysuria, cough, diarrhea, alcohol use, or pregnancy symptoms." } + ], + "vitals": { "temperature": "37.3 C (99.1 F)", "bloodPressure": "88/56 mm Hg", "pulse": "118/min", "respirations": "28/min", "oxygenSaturation": "98% on room air", "height": "165 cm (65 in)", "weight": "62 kg (137 lb)", "bmi": "22.8 kg/m²" }, "simulatedStartUtc": "2026-07-15T08:00:00Z", "initialOrderCodes": [] }, diff --git a/content/dynamic_questions/adaptive-v3.json b/content/dynamic_questions/adaptive-v3.json new file mode 100644 index 0000000..f5ee1f1 --- /dev/null +++ b/content/dynamic_questions/adaptive-v3.json @@ -0,0 +1,72 @@ +[ + { + "externalId":"dyn-gold-dka","version":3,"baseBlueprintVersion":2, + "qualityStandardVersion":3,"isPublished":true, + "caseFamilies":[ + {"id":"dka-fnd-infection","baseFamilyId":"dka-infection","difficultyTier":"foundation","nominalElo":850,"stemTemplate":"The combination of hyperglycemia, ketonemia, and an anion-gap metabolic acidosis confirms diabetic ketoacidosis."}, + {"id":"dka-fnd-new","baseFamilyId":"dka-new-onset","difficultyTier":"foundation","nominalElo":850,"stemTemplate":"The patient is clinically volume depleted, and there is no evidence of heart or kidney failure."}, + {"id":"dka-std-pump","baseFamilyId":"dka-pump-failure","difficultyTier":"standard","nominalElo":1050,"stemTemplate":"The emergency team prepares simultaneous cardiac monitoring and repeat electrolyte measurements."}, + {"id":"dka-std-infection","baseFamilyId":"dka-infection","difficultyTier":"standard","nominalElo":1050,"stemTemplate":"The urinary infection will require treatment, but the team must first sequence the metabolic resuscitation correctly."}, + {"id":"dka-adv-new","baseFamilyId":"dka-new-onset","difficultyTier":"advanced","nominalElo":1250,"stemTemplate":"Although the measured serum potassium is not low, the patient has had substantial urinary potassium losses and has not yet received insulin."}, + {"id":"dka-adv-pump","baseFamilyId":"dka-pump-failure","difficultyTier":"advanced","nominalElo":1250,"stemTemplate":"A family member asks whether reconnecting the pump alone will reverse the acute physiology."}, + {"id":"dka-exp-infection","baseFamilyId":"dka-infection","difficultyTier":"expert","nominalElo":1450,"stemTemplate":"The patient received one antibiotic dose at urgent care; this does not explain the persistent shock physiology or alter the immediate metabolic priority."}, + {"id":"dka-exp-pump","baseFamilyId":"dka-pump-failure","difficultyTier":"expert","nominalElo":1450,"stemTemplate":"Point-of-care ultrasound shows a small collapsible inferior vena cava and preserved left ventricular systolic function."} + ] + }, + { + "externalId":"dyn-gold-af","version":3,"baseBlueprintVersion":2, + "qualityStandardVersion":3,"isPublished":true, + "caseFamilies":[ + {"id":"af-fnd-sepsis","baseFamilyId":"af-sepsis","difficultyTier":"foundation","nominalElo":850,"stemTemplate":"The hypotension and confusion began with the rapid rhythm and are attributed to the tachyarrhythmia."}, + {"id":"af-fnd-edema","baseFamilyId":"af-pulmonary-edema","difficultyTier":"foundation","nominalElo":850,"stemTemplate":"The patient has a pulse but has acute pulmonary edema and shock caused by the rapid rhythm."}, + {"id":"af-std-ischemia","baseFamilyId":"af-ischemia","difficultyTier":"standard","nominalElo":1050,"stemTemplate":"The exact duration of atrial fibrillation is unknown."}, + {"id":"af-std-sepsis","baseFamilyId":"af-sepsis","difficultyTier":"standard","nominalElo":1050,"stemTemplate":"Antibiotics and source control are underway, but perfusion continues to deteriorate as the ventricular rate rises."}, + {"id":"af-adv-edema","baseFamilyId":"af-pulmonary-edema","difficultyTier":"advanced","nominalElo":1250,"stemTemplate":"The patient took the usual beta blocker this morning, and additional atrioventricular nodal blockade could worsen the low-output state."}, + {"id":"af-adv-ischemia","baseFamilyId":"af-ischemia","difficultyTier":"advanced","nominalElo":1250,"stemTemplate":"A postoperative hemoglobin measurement is mildly decreased but stable and does not account for the abrupt hemodynamic change."}, + {"id":"af-exp-sepsis","baseFamilyId":"af-sepsis","difficultyTier":"expert","nominalElo":1450,"stemTemplate":"A prior ECG showed sinus rhythm with a normal PR interval; the current tracing has no pre-excitation and the instability persists after a cautious fluid challenge."}, + {"id":"af-exp-ischemia","baseFamilyId":"af-ischemia","difficultyTier":"expert","nominalElo":1450,"stemTemplate":"Bedside echocardiography shows no pericardial effusion or right ventricular dilation, narrowing the cause of shock to the rapid atrial arrhythmia."} + ] + }, + { + "externalId":"dyn-gold-pyelo-pregnancy","version":3,"baseBlueprintVersion":2, + "qualityStandardVersion":3,"isPublished":true, + "caseFamilies":[ + {"id":"pyelo-fnd-classic","baseFamilyId":"pyelo-classic","difficultyTier":"foundation","nominalElo":850,"stemTemplate":"The fever and costovertebral-angle tenderness distinguish this upper urinary tract infection from uncomplicated cystitis."}, + {"id":"pyelo-fnd-recurrent","baseFamilyId":"pyelo-recurrent","difficultyTier":"foundation","nominalElo":850,"stemTemplate":"There is no drug allergy, and renal function is normal."}, + {"id":"pyelo-std-systemic","baseFamilyId":"pyelo-systemic","difficultyTier":"standard","nominalElo":1050,"stemTemplate":"There is no evidence of obstructing nephrolithiasis on bedside assessment."}, + {"id":"pyelo-std-classic","baseFamilyId":"pyelo-classic","difficultyTier":"standard","nominalElo":1050,"stemTemplate":"The patient can take oral medication but remains febrile and systemically ill."}, + {"id":"pyelo-adv-recurrent","baseFamilyId":"pyelo-recurrent","difficultyTier":"advanced","nominalElo":1250,"stemTemplate":"The earlier urine isolate was susceptible to nitrofurantoin, but susceptibility does not overcome inadequate renal parenchymal penetration."}, + {"id":"pyelo-adv-systemic","baseFamilyId":"pyelo-systemic","difficultyTier":"advanced","nominalElo":1250,"stemTemplate":"Mild uterine irritability resolves with hydration, and there is no indication for delivery."}, + {"id":"pyelo-exp-classic","baseFamilyId":"pyelo-classic","difficultyTier":"expert","nominalElo":1450,"stemTemplate":"A preliminary culture reports a gram-negative rod; definitive susceptibilities will not be available until tomorrow."}, + {"id":"pyelo-exp-systemic","baseFamilyId":"pyelo-systemic","difficultyTier":"expert","nominalElo":1450,"stemTemplate":"The lactate and respiratory rate raise concern for evolving maternal sepsis, making disposition as important as antibiotic spectrum."} + ] + }, + { + "externalId":"dyn-gold-sah","version":3,"baseBlueprintVersion":2, + "qualityStandardVersion":3,"isPublished":true, + "caseFamilies":[ + {"id":"sah-fnd-exertion","baseFamilyId":"sah-exertion","difficultyTier":"foundation","nominalElo":850,"stemTemplate":"The sudden maximal-at-onset headache and meningismus are classic warning features for subarachnoid hemorrhage."}, + {"id":"sah-fnd-sentinel","baseFamilyId":"sah-sentinel","difficultyTier":"foundation","nominalElo":850,"stemTemplate":"This is a first thunderclap headache, not a recurrence of a previously evaluated migraine syndrome."}, + {"id":"sah-std-focal","baseFamilyId":"sah-focal","difficultyTier":"standard","nominalElo":1050,"stemTemplate":"The patient is stable enough for immediate diagnostic imaging."}, + {"id":"sah-std-exertion","baseFamilyId":"sah-exertion","difficultyTier":"standard","nominalElo":1050,"stemTemplate":"The absence of persistent focal weakness does not reduce the urgency of excluding hemorrhage."}, + {"id":"sah-adv-sentinel","baseFamilyId":"sah-sentinel","difficultyTier":"advanced","nominalElo":1250,"stemTemplate":"The presentation occurred recently enough that the initial imaging strategy has high diagnostic yield, but a negative result must still be interpreted in clinical context."}, + {"id":"sah-adv-focal","baseFamilyId":"sah-focal","difficultyTier":"advanced","nominalElo":1250,"stemTemplate":"The partial third-nerve finding raises concern for an aneurysmal source but does not replace the initial study for acute blood."}, + {"id":"sah-exp-exertion","baseFamilyId":"sah-exertion","difficultyTier":"expert","nominalElo":1450,"stemTemplate":"A nonfocal examination and transient improvement after analgesia are potentially misleading and should not alter the first diagnostic step."}, + {"id":"sah-exp-sentinel","baseFamilyId":"sah-sentinel","difficultyTier":"expert","nominalElo":1450,"stemTemplate":"Family history increases pretest probability, while the choice between subsequent lumbar puncture and angiographic evaluation depends on the result and timing of the initial study."} + ] + }, + { + "externalId":"dyn-gold-arr","version":3,"baseBlueprintVersion":2, + "qualityStandardVersion":3,"isPublished":true, + "caseFamilies":[ + {"id":"arr-fnd-cardio","baseFamilyId":"arr-cardiovascular","difficultyTier":"foundation","nominalElo":850,"stemTemplate":"Calculate the arithmetic difference between the two event rates rather than a relative measure."}, + {"id":"arr-fnd-infection","baseFamilyId":"arr-infection","difficultyTier":"foundation","nominalElo":850,"stemTemplate":"The requested measure should be reported in percentage points."}, + {"id":"arr-std-readmit","baseFamilyId":"arr-readmission","difficultyTier":"standard","nominalElo":1050,"stemTemplate":"Do not convert the result to number needed to treat unless specifically asked."}, + {"id":"arr-std-cardio","baseFamilyId":"arr-cardiovascular","difficultyTier":"standard","nominalElo":1050,"stemTemplate":"The trial reports sufficient data to calculate absolute and relative effects directly."}, + {"id":"arr-adv-infection","baseFamilyId":"arr-infection","difficultyTier":"advanced","nominalElo":1250,"stemTemplate":"A secondary table emphasizes a large relative reduction, but the question asks for the absolute treatment effect."}, + {"id":"arr-adv-readmit","baseFamilyId":"arr-readmission","difficultyTier":"advanced","nominalElo":1250,"stemTemplate":"The intervention has similar follow-up completeness in both groups, so no adjustment for attrition is needed."}, + {"id":"arr-exp-cardio","baseFamilyId":"arr-cardiovascular","difficultyTier":"expert","nominalElo":1450,"stemTemplate":"The authors additionally report an adjusted hazard ratio; that time-to-event estimate is not needed for this requested absolute measure."}, + {"id":"arr-exp-readmit","baseFamilyId":"arr-readmission","difficultyTier":"expert","nominalElo":1450,"stemTemplate":"Cost-effectiveness and relative-risk results are presented as distractors, but neither changes the calculation from the observed event proportions."} + ] + } +] diff --git a/content/dynamic_questions/gold-pilot-v2.json b/content/dynamic_questions/gold-pilot-v2.json new file mode 100644 index 0000000..be85011 --- /dev/null +++ b/content/dynamic_questions/gold-pilot-v2.json @@ -0,0 +1,144 @@ +[ + { + "externalId": "dyn-gold-dka", "version": 2, "objectiveId": "endocrine.dka-initial-management", + "title": "Initial management of diabetic ketoacidosis", "system": "Endocrine", "discipline": "Medicine", + "performanceCategory": "Patient Care: Management", "physicianTask": "Emergency Management", "difficulty": 3, + "qualityStandardVersion": 2, "correctConceptId": "dka.restore-intravascular-volume", "isPublished": true, + "correctAnswer": {"id":"correct","textTemplate":"Begin intravenous 0.9% saline","explanationTemplate":"DKA causes major osmotic diuresis and extracellular volume depletion; isotonic crystalloid is the immediate first intervention while potassium and insulin plans are prepared."}, + "distractors": [ + {"id":"insulin","textTemplate":"Begin an intravenous insulin infusion before giving fluid","explanationTemplate":"Insulin is essential, but initial volume restoration comes first and serum potassium must be known because insulin can precipitate dangerous hypokalemia."}, + {"id":"bicarbonate","textTemplate":"Give intravenous sodium bicarbonate immediately","explanationTemplate":"Bicarbonate is not routine DKA therapy and is generally reserved for extreme acidemia, typically pH below 6.9."}, + {"id":"potassium","textTemplate":"Give intravenous potassium without further assessment","explanationTemplate":"Total-body potassium is depleted, but replacement depends on the measured potassium and urine output; empiric immediate potassium can be hazardous."}, + {"id":"dextrose","textTemplate":"Begin intravenous dextrose-containing fluid","explanationTemplate":"Dextrose is added later as glucose approaches the treatment threshold so insulin can continue clearing ketones."}, + {"id":"dialysis","textTemplate":"Arrange immediate hemodialysis","explanationTemplate":"Routine DKA responds to fluids, potassium-guided insulin, and treatment of the trigger; dialysis is not the initial intervention."}, + {"id":"subcutaneous","textTemplate":"Give only a dose of subcutaneous long-acting insulin","explanationTemplate":"A critically ill patient with DKA requires controlled acute resuscitation, not long-acting insulin as sole therapy."} + ], + "explanationTemplate":"This patient has DKA with severe extracellular volume depletion from osmotic diuresis. The first action is rapid isotonic crystalloid resuscitation. Measure potassium promptly, begin intravenous insulin when potassium is safe, add dextrose as glucose falls, and treat the precipitating illness.", + "keyTakeaway":"In DKA, restore intravascular volume first; potassium status determines when insulin can safely begin.", + "usmlePearl":"A normal or elevated initial serum potassium does not mean potassium stores are adequate; total-body potassium is depleted.", + "reviewFollowups":[{"promptTemplate":"After initial fluid resuscitation, the serum potassium is 2.9 mEq/L. What is the next step?","choices":[{"id":"k","textTemplate":"Replace potassium before insulin"},{"id":"i","textTemplate":"Start insulin immediately"},{"id":"b","textTemplate":"Give bicarbonate"},{"id":"d","textTemplate":"Start dextrose"}],"correctChoiceId":"k","teachingPointTemplate":"When potassium is below 3.3 mEq/L, replace potassium and hold insulin until potassium rises because insulin shifts potassium intracellularly."}], + "caseFamilies": [ + { + "id":"dka-infection","stemTemplate":"A {{age}}-year-old woman with type 1 diabetes mellitus is brought to the emergency department for 2 days of dysuria, fever, progressive nausea, and diffuse abdominal pain. She has missed several insulin doses because she was not eating. Temperature is {{temp}}°C, blood pressure is {{sbp}}/{{dbp}} mm Hg, pulse is {{pulse}}/min, and respirations are deep at {{resp}}/min. She is drowsy, has dry mucous membranes, and has suprapubic tenderness without guarding. Urinalysis shows pyuria and positive nitrites. Additional laboratory results are shown. Which of the following is the most appropriate immediate next step?","leadIn":"Which of the following is the most appropriate immediate next step?", + "variables":{"age":{"type":"integer","minimum":19,"maximum":42},"temp":{"type":"decimal","minimum":38.1,"maximum":39.2,"step":0.1,"precision":1},"sbp":{"type":"integer","minimum":84,"maximum":96},"dbp":{"type":"integer","minimum":48,"maximum":60},"pulse":{"type":"integer","minimum":112,"maximum":132},"resp":{"type":"integer","minimum":26,"maximum":34},"glucose":{"type":"integer","minimum":480,"maximum":680,"step":10},"sodium":{"type":"integer","minimum":128,"maximum":134},"potassium":{"type":"decimal","minimum":4.6,"maximum":5.6,"step":0.1,"precision":1},"bicarb":{"type":"integer","minimum":8,"maximum":14},"ph":{"type":"decimal","minimum":7.08,"maximum":7.22,"step":0.01,"precision":2}}, + "labs":[{"name":"Glucose","valueTemplate":"{{glucose}}","unit":"mg/dL","reference":"70–100"},{"name":"Sodium","valueTemplate":"{{sodium}}","unit":"mEq/L","reference":"136–145"},{"name":"Potassium","valueTemplate":"{{potassium}}","unit":"mEq/L","reference":"3.5–5.0"},{"name":"Bicarbonate","valueTemplate":"{{bicarb}}","unit":"mEq/L","reference":"22–28"},{"name":"Arterial pH","valueTemplate":"{{ph}}","unit":"","reference":"7.35–7.45"}] + }, + { + "id":"dka-new-onset","stemTemplate":"A {{age}}-year-old man with no known medical conditions presents with 3 weeks of polyuria, polydipsia, and weight loss followed by repeated vomiting and increasing confusion. Blood pressure is {{sbp}}/{{dbp}} mm Hg, pulse is {{pulse}}/min, and respirations are rapid and deep. Examination shows sunken eyes, markedly dry oral mucosa, delayed capillary refill, and a fruity odor to the breath. The abdomen is mildly tender without rebound. Serum beta-hydroxybutyrate is elevated, and laboratory results are shown. Which intervention should be performed first?","leadIn":"Which intervention should be performed first?", + "variables":{"age":{"type":"integer","minimum":18,"maximum":34},"sbp":{"type":"integer","minimum":86,"maximum":100},"dbp":{"type":"integer","minimum":50,"maximum":64},"pulse":{"type":"integer","minimum":108,"maximum":128},"glucose":{"type":"integer","minimum":410,"maximum":620,"step":10},"potassium":{"type":"decimal","minimum":4.1,"maximum":5.3,"step":0.1,"precision":1},"bicarb":{"type":"integer","minimum":7,"maximum":13},"gap":{"type":"integer","minimum":24,"maximum":34}}, + "labs":[{"name":"Glucose","valueTemplate":"{{glucose}}","unit":"mg/dL","reference":"70–100"},{"name":"Potassium","valueTemplate":"{{potassium}}","unit":"mEq/L","reference":"3.5–5.0"},{"name":"Bicarbonate","valueTemplate":"{{bicarb}}","unit":"mEq/L","reference":"22–28"},{"name":"Anion gap","valueTemplate":"{{gap}}","unit":"mEq/L","reference":"8–12"}] + }, + { + "id":"dka-pump-failure","stemTemplate":"A {{age}}-year-old woman with type 1 diabetes mellitus managed with an insulin pump presents after awakening with intense thirst, vomiting, and shortness of breath. The pump tubing is found to be kinked. She is lethargic but follows commands. Blood pressure is {{sbp}}/{{dbp}} mm Hg, pulse is {{pulse}}/min, and respirations are {{resp}}/min with a deep pattern. Her extremities are cool, mucous membranes are dry, and jugular venous pressure is low. Bedside glucose reads HIGH; confirmatory laboratory results are shown. What is the best initial treatment?","leadIn":"What is the best initial treatment?", + "variables":{"age":{"type":"integer","minimum":20,"maximum":48},"sbp":{"type":"integer","minimum":88,"maximum":102},"dbp":{"type":"integer","minimum":52,"maximum":66},"pulse":{"type":"integer","minimum":110,"maximum":130},"resp":{"type":"integer","minimum":25,"maximum":33},"glucose":{"type":"integer","minimum":520,"maximum":720,"step":10},"potassium":{"type":"decimal","minimum":4.4,"maximum":5.7,"step":0.1,"precision":1},"bicarb":{"type":"integer","minimum":9,"maximum":15},"ph":{"type":"decimal","minimum":7.10,"maximum":7.24,"step":0.01,"precision":2}}, + "labs":[{"name":"Glucose","valueTemplate":"{{glucose}}","unit":"mg/dL","reference":"70–100"},{"name":"Potassium","valueTemplate":"{{potassium}}","unit":"mEq/L","reference":"3.5–5.0"},{"name":"Bicarbonate","valueTemplate":"{{bicarb}}","unit":"mEq/L","reference":"22–28"},{"name":"Venous pH","valueTemplate":"{{ph}}","unit":"","reference":"7.32–7.42"}] + } + ], + "references":[{"title":"Hyperglycemic Crises in Adults With Diabetes","publisher":"American Diabetes Association","url":"https://diabetesjournals.org/care/article/47/8/1257/156806/Hyperglycemic-Crises-in-Adults-With-Diabetes-A","supports":"Fluid, potassium, and insulin sequence"}], + "tags":["emergency","diabetes","gold"] + }, + { + "externalId":"dyn-gold-af","version":2,"objectiveId":"cardio.unstable-atrial-fibrillation", + "title":"Unstable atrial fibrillation", "system":"Cardiovascular", "discipline":"Medicine", + "performanceCategory":"Patient Care: Management", "physicianTask":"Emergency Management", "difficulty": 3, + "qualityStandardVersion":2,"correctConceptId":"tachyarrhythmia.synchronized-cardioversion","isPublished":true, + "correctAnswer":{"id":"correct","textTemplate":"Perform immediate synchronized cardioversion","explanationTemplate":"A tachyarrhythmia causing hypotension, ischemia, shock, pulmonary edema, or altered mental status requires synchronized cardioversion."}, + "distractors":[ + {"id":"metoprolol","textTemplate":"Give intravenous metoprolol","explanationTemplate":"Rate-control medication can worsen hypotension and delays definitive therapy in an unstable patient."}, + {"id":"diltiazem","textTemplate":"Begin an intravenous diltiazem infusion","explanationTemplate":"Diltiazem is appropriate for selected stable patients but can worsen shock and is not first-line when instability is attributable to the rhythm."}, + {"id":"adenosine","textTemplate":"Give a rapid intravenous bolus of adenosine","explanationTemplate":"Adenosine may clarify or terminate regular narrow-complex tachycardia; it does not treat irregular atrial fibrillation."}, + {"id":"amiodarone","textTemplate":"Begin an intravenous amiodarone infusion and observe","explanationTemplate":"Medication should not delay synchronized cardioversion when atrial fibrillation is producing hemodynamic instability."}, + {"id":"anticoagulation","textTemplate":"Start anticoagulation and arrange cardioversion in 3 weeks","explanationTemplate":"The usual elective anticoagulation strategy does not apply when immediate cardioversion is needed to stabilize the patient."}, + {"id":"defibrillation","textTemplate":"Perform unsynchronized defibrillation","explanationTemplate":"Unsynchronized shock is used for pulseless rhythms or polymorphic ventricular tachycardia; a patient with unstable atrial fibrillation and a pulse needs synchronization."} + ], + "explanationTemplate":"The irregular narrow-complex tachycardia is causing end-organ hypoperfusion. Immediate synchronized cardioversion is indicated. Sedate if feasible without delaying shock, then address precipitants and thromboembolic risk after stabilization.", + "keyTakeaway":"Cardiovert an unstable tachyarrhythmia immediately when the rhythm is causing hypotension or other end-organ compromise.", + "usmlePearl":"The duration of atrial fibrillation changes anticoagulation planning, but it does not justify delaying cardioversion in an unstable patient.", + "reviewFollowups":[{"promptTemplate":"Which feature most clearly makes this an unstable tachyarrhythmia?","choices":[{"id":"h","textTemplate":"Hypotension with altered mental status"},{"id":"r","textTemplate":"An irregular rhythm alone"},{"id":"a","textTemplate":"Age older than 65 years"},{"id":"d","textTemplate":"A duration longer than 30 minutes"}],"correctChoiceId":"h","teachingPointTemplate":"Instability is defined by consequences such as hypotension, shock, ischemic chest discomfort, acute heart failure, or altered mental status attributable to the rhythm."}], + "caseFamilies":[ + {"id":"af-sepsis","stemTemplate":"A {{age}}-year-old woman hospitalized for pneumonia suddenly becomes confused and diaphoretic. Blood pressure falls to {{sbp}}/{{dbp}} mm Hg, pulse is irregular at {{rate}}/min, respirations are 28/min, and oxygen saturation is 93% with supplemental oxygen. Cardiac examination shows an irregularly irregular rhythm without a murmur. The lungs have right basilar crackles. An ECG shows absent discrete P waves and irregularly spaced narrow QRS complexes. A fluid bolus has not improved the blood pressure. Which intervention is most appropriate now?","leadIn":"Which intervention is most appropriate now?","variables":{"age":{"type":"integer","minimum":54,"maximum":79},"sbp":{"type":"integer","minimum":72,"maximum":84},"dbp":{"type":"integer","minimum":42,"maximum":54},"rate":{"type":"integer","minimum":158,"maximum":188}}}, + {"id":"af-pulmonary-edema","stemTemplate":"A {{age}}-year-old man with hypertension presents with abrupt palpitations and severe dyspnea. He is anxious and can speak only in short phrases. Blood pressure is {{sbp}}/{{dbp}} mm Hg, pulse is irregular at {{rate}}/min, and oxygen saturation is {{sat}}% on room air. Jugular venous pressure is elevated, diffuse inspiratory crackles are present, and the extremities are cool. ECG shows atrial fibrillation with rapid ventricular response and no ST-segment elevation. Noninvasive oxygen support is initiated. What is the next step in management?","leadIn":"What is the next step in management?","variables":{"age":{"type":"integer","minimum":58,"maximum":84},"sbp":{"type":"integer","minimum":76,"maximum":88},"dbp":{"type":"integer","minimum":44,"maximum":58},"rate":{"type":"integer","minimum":150,"maximum":182},"sat":{"type":"integer","minimum":78,"maximum":87}}}, + {"id":"af-ischemia","stemTemplate":"A {{age}}-year-old woman develops sudden palpitations, substernal pressure, and lightheadedness while recovering after abdominal surgery. Blood pressure is {{sbp}}/{{dbp}} mm Hg and pulse is irregular at {{rate}}/min. She is pale, intermittently confused, and has weak peripheral pulses. ECG demonstrates an irregular narrow-complex tachycardia without visible P waves and diffuse rate-related ST depressions. Electrolyte samples have been drawn, and intravenous access is established. Which of the following is the best immediate management?","leadIn":"Which of the following is the best immediate management?","variables":{"age":{"type":"integer","minimum":47,"maximum":73},"sbp":{"type":"integer","minimum":70,"maximum":86},"dbp":{"type":"integer","minimum":40,"maximum":54},"rate":{"type":"integer","minimum":162,"maximum":194}}} + ], + "references":[{"title":"Adult Tachycardia With a Pulse Algorithm","publisher":"American Heart Association","url":"https://cpr.heart.org/en/resuscitation-science/cpr-and-ecc-guidelines/algorithms","supports":"Immediate synchronized cardioversion for unstable tachyarrhythmia"}], + "tags":["cardiology","emergency","gold"] + }, + { + "externalId":"dyn-gold-pyelo-pregnancy","version":2,"objectiveId":"ob.pregnancy-pyelonephritis", + "title":"Pyelonephritis in pregnancy", "system":"Pregnancy", "discipline":"Obstetrics", + "performanceCategory":"Patient Care: Management", "physicianTask":"Pharmacotherapy", "difficulty":3, + "qualityStandardVersion":2,"correctConceptId":"pregnancy.pyelonephritis.inpatient-parenteral-antibiotics","isPublished":true, + "correctAnswer":{"id":"correct","textTemplate":"Admit for intravenous ceftriaxone","explanationTemplate":"Acute pyelonephritis in pregnancy warrants initial inpatient management with a parenteral antibiotic that penetrates renal tissue, followed by culture-directed therapy."}, + "distractors":[ + {"id":"nitrofurantoin","textTemplate":"Discharge with oral nitrofurantoin","explanationTemplate":"Nitrofurantoin treats lower urinary infection but does not reach adequate renal parenchymal concentrations for pyelonephritis."}, + {"id":"fosfomycin","textTemplate":"Give one oral dose of fosfomycin","explanationTemplate":"Fosfomycin is a lower-tract option and is inadequate for a febrile upper urinary tract infection."}, + {"id":"await","textTemplate":"Withhold antibiotics until urine culture results return","explanationTemplate":"Delaying therapy risks maternal sepsis, respiratory complications, and preterm labor; obtain cultures and treat empirically."}, + {"id":"ciprofloxacin","textTemplate":"Discharge with oral ciprofloxacin","explanationTemplate":"Pregnancy and systemic upper-tract illness make outpatient fluoroquinolone treatment inappropriate."}, + {"id":"delivery","textTemplate":"Proceed with immediate delivery","explanationTemplate":"Pyelonephritis alone is not an indication for delivery; stabilize and treat the infection while monitoring mother and fetus."}, + {"id":"phenazopyridine","textTemplate":"Treat with phenazopyridine alone","explanationTemplate":"Urinary analgesia does not eradicate bacterial infection or prevent maternal and fetal complications."} + ], + "explanationTemplate":"Fever, flank pain, costovertebral-angle tenderness, and pyuria indicate acute pyelonephritis. Pregnancy increases the risk of sepsis, ARDS, and preterm labor, so begin inpatient parenteral therapy such as ceftriaxone after cultures and tailor treatment to susceptibility results.", + "keyTakeaway":"Manage pyelonephritis in pregnancy initially in the hospital with renal-penetrating parenteral antibiotics.", + "usmlePearl":"Nitrofurantoin and fosfomycin may treat cystitis but should not be used for pyelonephritis because renal tissue levels are inadequate.", + "reviewFollowups":[{"promptTemplate":"Why is nitrofurantoin not appropriate in this patient?","choices":[{"id":"t","textTemplate":"It does not achieve therapeutic renal tissue levels"},{"id":"f","textTemplate":"It always causes fetal malformations"},{"id":"r","textTemplate":"All urinary pathogens are resistant"},{"id":"p","textTemplate":"It induces premature labor"}],"correctChoiceId":"t","teachingPointTemplate":"The key distinction is infection site: agents concentrated in urine can treat cystitis yet fail to treat renal parenchymal infection."}], + "caseFamilies":[ + {"id":"pyelo-classic","stemTemplate":"A {{age}}-year-old woman at {{weeks}} weeks' gestation presents with 24 hours of fever, chills, nausea, and right flank pain. Temperature is {{temp}}°C, blood pressure is {{sbp}}/{{dbp}} mm Hg, and pulse is {{pulse}}/min. The uterus is nontender, fetal heart rate is reassuring, and there is marked right costovertebral-angle tenderness. Urinalysis shows positive leukocyte esterase, nitrites, numerous leukocytes, and bacteriuria. Blood and urine cultures are obtained. Which management is most appropriate?","leadIn":"Which management is most appropriate?","variables":{"age":{"type":"integer","minimum":20,"maximum":36},"weeks":{"type":"integer","minimum":16,"maximum":30},"temp":{"type":"decimal","minimum":38.2,"maximum":39.4,"step":0.1,"precision":1},"sbp":{"type":"integer","minimum":96,"maximum":112},"dbp":{"type":"integer","minimum":56,"maximum":70},"pulse":{"type":"integer","minimum":104,"maximum":124}}}, + {"id":"pyelo-recurrent","stemTemplate":"A {{age}}-year-old pregnant woman at {{weeks}} weeks' gestation has fever, vomiting, dysuria, and left-sided back pain. She was treated for asymptomatic bacteriuria earlier in pregnancy. Temperature is {{temp}}°C, blood pressure is {{sbp}}/{{dbp}} mm Hg, and pulse is {{pulse}}/min. Examination shows dry mucous membranes and left costovertebral-angle tenderness; there are no contractions and the cervix is closed. Urinalysis reveals pyuria and white blood cell casts. What is the best next step?","leadIn":"What is the best next step?","variables":{"age":{"type":"integer","minimum":19,"maximum":38},"weeks":{"type":"integer","minimum":18,"maximum":33},"temp":{"type":"decimal","minimum":38.0,"maximum":39.1,"step":0.1,"precision":1},"sbp":{"type":"integer","minimum":92,"maximum":108},"dbp":{"type":"integer","minimum":54,"maximum":68},"pulse":{"type":"integer","minimum":102,"maximum":122}}}, + {"id":"pyelo-systemic","stemTemplate":"A {{age}}-year-old woman at {{weeks}} weeks' gestation presents with fever, rigors, urinary urgency, and severe flank pain. Temperature is {{temp}}°C, blood pressure is {{sbp}}/{{dbp}} mm Hg, pulse is {{pulse}}/min, and respirations are 24/min. She appears ill and has suprapubic and costovertebral-angle tenderness. Fetal monitoring is reactive. Urinalysis shows bacteriuria, pyuria, and positive nitrites; serum lactate is mildly elevated. After obtaining cultures, which treatment is most appropriate?","leadIn":"After obtaining cultures, which treatment is most appropriate?","variables":{"age":{"type":"integer","minimum":22,"maximum":39},"weeks":{"type":"integer","minimum":14,"maximum":28},"temp":{"type":"decimal","minimum":38.4,"maximum":39.6,"step":0.1,"precision":1},"sbp":{"type":"integer","minimum":88,"maximum":104},"dbp":{"type":"integer","minimum":50,"maximum":64},"pulse":{"type":"integer","minimum":112,"maximum":132}}} + ], + "references":[{"title":"Urinary Tract Infections in Pregnant Individuals","publisher":"ACOG","url":"https://www.acog.org/clinical/clinical-guidance/clinical-consensus/articles/2023/08/urinary-tract-infections-in-pregnant-individuals","supports":"Inpatient parenteral treatment of pyelonephritis"}], + "tags":["obstetrics","infectious-disease","gold"] + }, + { + "externalId":"dyn-gold-sah","version":2,"objectiveId":"neuro.subarachnoid-hemorrhage-initial-test", + "title":"Initial testing for subarachnoid hemorrhage", "system":"Neurologic", "discipline":"Medicine", + "performanceCategory":"Patient Care: Diagnosis", "physicianTask":"Diagnostic Studies", "difficulty":3, + "qualityStandardVersion":2,"correctConceptId":"sah.initial-noncontrast-head-ct","isPublished":true, + "correctAnswer":{"id":"correct","textTemplate":"Obtain an emergent noncontrast CT scan of the head","explanationTemplate":"Noncontrast head CT is the rapid initial study for suspected subarachnoid hemorrhage and can identify acute blood and complications."}, + "distractors":[ + {"id":"mri","textTemplate":"Obtain a routine brain MRI with contrast","explanationTemplate":"MRI is slower and is not the initial emergency study for a classic thunderclap headache suspicious for subarachnoid hemorrhage."}, + {"id":"lpfirst","textTemplate":"Perform lumbar puncture before neuroimaging","explanationTemplate":"Noncontrast CT is obtained first; further testing after a negative CT depends on timing, image quality, and residual suspicion."}, + {"id":"ctaonly","textTemplate":"Obtain CT angiography without a noncontrast study","explanationTemplate":"Angiography helps define an aneurysm, but the initial diagnostic study for acute subarachnoid blood is noncontrast CT."}, + {"id":"eeg","textTemplate":"Order electroencephalography","explanationTemplate":"EEG evaluates seizure activity and does not diagnose subarachnoid hemorrhage."}, + {"id":"migraine","textTemplate":"Treat as migraine and discharge without imaging","explanationTemplate":"A first sudden maximal-at-onset headache with meningismus or neurologic symptoms requires emergency evaluation for hemorrhage."}, + {"id":"carotid","textTemplate":"Order carotid duplex ultrasonography","explanationTemplate":"Carotid ultrasound does not evaluate the subarachnoid space or intracranial aneurysmal bleeding."} + ], + "explanationTemplate":"A headache that reaches maximal intensity immediately, especially with vomiting, meningismus, syncope, or a focal deficit, is subarachnoid hemorrhage until proven otherwise. Obtain emergent noncontrast head CT; if it is nondiagnostic and suspicion remains, proceed according to timing and local diagnostic pathways.", + "keyTakeaway":"The initial test for suspected acute subarachnoid hemorrhage is emergent noncontrast head CT.", + "usmlePearl":"Do not label a first thunderclap headache as migraine simply because the neurologic examination improves.", + "reviewFollowups":[{"promptTemplate":"If the initial CT is nondiagnostic but clinical suspicion remains high, what principle should guide the next step?","choices":[{"id":"f","textTemplate":"Pursue additional evaluation for subarachnoid blood or aneurysm"},{"id":"d","textTemplate":"Discharge because CT excludes all cases"},{"id":"e","textTemplate":"Order EEG only"},{"id":"s","textTemplate":"Schedule routine follow-up in 6 months"}],"correctChoiceId":"f","teachingPointTemplate":"CT sensitivity depends on factors including time from onset; persistent high suspicion requires additional evaluation rather than reassurance alone."}], + "caseFamilies":[ + {"id":"sah-exertion","stemTemplate":"A {{age}}-year-old man develops the worst headache of his life while lifting a heavy object. The pain reaches maximal intensity within seconds and is followed by vomiting and a brief loss of consciousness. On arrival, blood pressure is {{sbp}}/{{dbp}} mm Hg and pulse is 88/min. He is photophobic and has neck stiffness but no focal weakness. He has no prior history of similar headaches and takes no anticoagulants. Which diagnostic study should be obtained first?","leadIn":"Which diagnostic study should be obtained first?","variables":{"age":{"type":"integer","minimum":41,"maximum":68},"sbp":{"type":"integer","minimum":154,"maximum":194},"dbp":{"type":"integer","minimum":88,"maximum":112}}}, + {"id":"sah-sentinel","stemTemplate":"A {{age}}-year-old woman presents 90 minutes after sudden severe occipital pain that began during sexual activity. She says the headache was instantly maximal and unlike prior mild tension headaches. She vomited twice and now has painful neck flexion. Blood pressure is {{sbp}}/{{dbp}} mm Hg. She is alert, and cranial nerves, strength, and sensation are normal. Her mother died after a ruptured cerebral aneurysm. What is the most appropriate initial diagnostic test?","leadIn":"What is the most appropriate initial diagnostic test?","variables":{"age":{"type":"integer","minimum":36,"maximum":61},"sbp":{"type":"integer","minimum":146,"maximum":186},"dbp":{"type":"integer","minimum":84,"maximum":108}}}, + {"id":"sah-focal","stemTemplate":"A {{age}}-year-old man abruptly develops a severe diffuse headache while at work, falls to the floor, and is confused for several minutes. In the emergency department, blood pressure is {{sbp}}/{{dbp}} mm Hg. He has meningismus and mild left eyelid ptosis but otherwise normal strength and speech. Finger-stick glucose is normal, and there is no evidence of trauma. His headache remains intense despite an analgesic. Which study is the best first step in establishing the diagnosis?","leadIn":"Which study is the best first step in establishing the diagnosis?","variables":{"age":{"type":"integer","minimum":44,"maximum":72},"sbp":{"type":"integer","minimum":158,"maximum":198},"dbp":{"type":"integer","minimum":90,"maximum":116}}} + ], + "references":[{"title":"Guideline for the Management of Aneurysmal Subarachnoid Hemorrhage","publisher":"American Heart Association","url":"https://www.ahajournals.org/doi/10.1161/STR.0000000000000436","supports":"Emergency diagnostic evaluation"}], + "tags":["neurology","emergency","gold"] + }, + { + "externalId":"dyn-gold-arr","version":2,"objectiveId":"biostats.absolute-risk-reduction", + "title":"Absolute risk reduction", "system":"Biostatistics", "discipline":"Biostatistics", + "performanceCategory":"Applying Foundational Science Concepts", "physicianTask":"Interpretation of Medical Literature", "difficulty":3, + "qualityStandardVersion":2,"correctConceptId":"biostats.control-event-rate-minus-treatment-event-rate","isPublished":true, + "correctAnswer":{"id":"correct","textTemplate":"{{arr}} percentage points","explanationTemplate":"Absolute risk reduction equals the control event rate minus the treatment event rate: {{control}}% − {{treatment}}% = {{arr}} percentage points."}, + "distractors":[ + {"id":"control","textTemplate":"{{control}}% (the control event rate)","explanationTemplate":"This is the control event rate, not the difference attributable to treatment."}, + {"id":"treatment","textTemplate":"{{treatment}}% (the treatment event rate)","explanationTemplate":"This is the treatment event rate, not the absolute reduction compared with control."}, + {"id":"rrr","textTemplate":"{{rrr}}% (the relative risk reduction)","explanationTemplate":"This is the relative risk reduction: the absolute difference divided by the control event rate."}, + {"id":"nnt","textTemplate":"{{nnt}}","explanationTemplate":"This is the number needed to treat, the reciprocal of the absolute risk reduction expressed as a proportion."}, + {"id":"sum","textTemplate":"{{sum}} percentage points","explanationTemplate":"Adding the two event rates does not measure the treatment-associated reduction in risk."}, + {"id":"ratio","textTemplate":"{{ratio}}% (the relative risk)","explanationTemplate":"This represents the treatment-to-control risk ratio expressed as a percentage, not absolute risk reduction."} + ], + "explanationTemplate":"Use absolute—not relative—risk. In this trial, ARR = control event rate − treatment event rate = {{control}}% − {{treatment}}% = {{arr}} percentage points. Relative risk reduction and number needed to treat answer different questions and should not be substituted.", + "keyTakeaway":"Absolute risk reduction is the arithmetic difference between event rates in the control and treatment groups.", + "usmlePearl":"Report ARR in percentage points; convert it to a proportion only when calculating NNT.", + "reviewFollowups":[{"promptTemplate":"Which calculation gives the number needed to treat for this trial?","choices":[{"id":"n","textTemplate":"1 ÷ ({{arr}}/100)"},{"id":"r","textTemplate":"{{control}} ÷ {{treatment}}"},{"id":"a","textTemplate":"{{control}} + {{treatment}}"},{"id":"s","textTemplate":"1 ÷ {{control}}"}],"correctChoiceId":"n","teachingPointTemplate":"NNT is the reciprocal of ARR expressed as a decimal proportion."}], + "caseFamilies":[ + {"id":"arr-cardiovascular","stemTemplate":"Investigators randomly assign {{size}} adults with established cardiovascular disease to receive a new antiplatelet drug or placebo in addition to standard therapy. Baseline characteristics and follow-up duration are similar between groups, and analysis is by intention to treat. A major cardiovascular event occurs in {{treatment}}% of the treatment group and {{control}}% of the placebo group. Adverse-event discontinuation is similar. Based on these results, what is the absolute risk reduction associated with the new drug?","leadIn":"What is the absolute risk reduction associated with the new drug?","variables":{"size":{"type":"integer","minimum":1200,"maximum":3600,"step":100},"control":{"type":"integer","minimum":18,"maximum":24},"treatment":{"type":"integer","minimum":8,"maximum":12}},"derived":{"arr":{"operation":"subtract","left":"control","right":"treatment","precision":0},"sum":{"operation":"add","left":"control","right":"treatment","precision":0},"rrrFraction":{"operation":"divide","left":"arr","right":"control","precision":4},"rrr":{"operation":"multiply","left":"rrrFraction","right":"100","precision":0},"ratioFraction":{"operation":"divide","left":"treatment","right":"control","precision":4},"ratio":{"operation":"multiply","left":"ratioFraction","right":"100","precision":0},"nnt":{"operation":"divide","left":"100","right":"arr","precision":0}},"constraints":[{"field":"arr","operator":"gt","value":5}]}, + {"id":"arr-infection","stemTemplate":"A multicenter randomized trial evaluates a prophylactic medication in {{size}} immunocompromised adults. Participants, clinicians, and outcome assessors are blinded. During the prespecified follow-up period, invasive infection develops in {{control}}% of patients receiving placebo and {{treatment}}% receiving prophylaxis. Loss to follow-up is minimal and balanced between groups. The investigators report both relative and absolute effect measures. What is the absolute risk reduction produced by prophylaxis?","leadIn":"What is the absolute risk reduction produced by prophylaxis?","variables":{"size":{"type":"integer","minimum":800,"maximum":2400,"step":100},"control":{"type":"integer","minimum":20,"maximum":28},"treatment":{"type":"integer","minimum":9,"maximum":14}},"derived":{"arr":{"operation":"subtract","left":"control","right":"treatment","precision":0},"sum":{"operation":"add","left":"control","right":"treatment","precision":0},"rrrFraction":{"operation":"divide","left":"arr","right":"control","precision":4},"rrr":{"operation":"multiply","left":"rrrFraction","right":"100","precision":0},"ratioFraction":{"operation":"divide","left":"treatment","right":"control","precision":4},"ratio":{"operation":"multiply","left":"ratioFraction","right":"100","precision":0},"nnt":{"operation":"divide","left":"100","right":"arr","precision":0}},"constraints":[{"field":"arr","operator":"gt","value":5}]}, + {"id":"arr-readmission","stemTemplate":"A randomized controlled trial enrolls {{size}} patients discharged after an exacerbation of chronic heart failure. Patients receive either a structured transitional-care program or usual discharge planning. Groups have comparable disease severity, and all participants are analyzed in their assigned group. Hospital readmission within 30 days occurs in {{control}}% of the usual-care group and {{treatment}}% of the intervention group. Which value is the absolute risk reduction attributable to the program?","leadIn":"Which value is the absolute risk reduction attributable to the program?","variables":{"size":{"type":"integer","minimum":600,"maximum":1800,"step":100},"control":{"type":"integer","minimum":22,"maximum":30},"treatment":{"type":"integer","minimum":11,"maximum":16}},"derived":{"arr":{"operation":"subtract","left":"control","right":"treatment","precision":0},"sum":{"operation":"add","left":"control","right":"treatment","precision":0},"rrrFraction":{"operation":"divide","left":"arr","right":"control","precision":4},"rrr":{"operation":"multiply","left":"rrrFraction","right":"100","precision":0},"ratioFraction":{"operation":"divide","left":"treatment","right":"control","precision":4},"ratio":{"operation":"multiply","left":"ratioFraction","right":"100","precision":0},"nnt":{"operation":"divide","left":"100","right":"arr","precision":0}},"constraints":[{"field":"arr","operator":"gt","value":5}]} + ], + "references":[{"title":"Principles of Epidemiology: Analyzing and Interpreting Data","publisher":"CDC","url":"https://www.cdc.gov/field-epi-manual/php/chapters/analyzing-interpreting-data.html","supports":"Absolute measures of association"}], + "tags":["biostatistics","evidence-based-medicine","gold"] + } +] diff --git a/content/dynamic_questions/pilot.json b/content/dynamic_questions/pilot.json new file mode 100644 index 0000000..5d19f16 --- /dev/null +++ b/content/dynamic_questions/pilot.json @@ -0,0 +1,78 @@ +[ + { + "externalId":"dyn-biostats-arr","version":1,"objectiveId":"biostats.absolute-risk-reduction","title":"Absolute risk reduction","system":"Biostatistics","discipline":"Biostatistics","performanceCategory":"Applying Foundational Science Concepts","physicianTask":"Interpretation of Medical Literature","difficulty":2, + "stemTemplate":"A randomized trial follows {{size}} patients. The event rate is {{control}}% with placebo and {{treatment}}% with treatment.","leadIn":"What is the absolute risk reduction?","variables":{"size":{"type":"integer","minimum":400,"maximum":1200,"step":100},"control":{"type":"integer","minimum":18,"maximum":24},"treatment":{"type":"integer","minimum":8,"maximum":12}},"derived":{"arr":{"operation":"subtract","left":"control","right":"treatment","precision":0}},"constraints":[{"field":"arr","operator":"gt","value":0}], + "correctAnswer":{"id":"correct","textTemplate":"{{arr}} percentage points","explanationTemplate":"ARR is control event rate minus treatment event rate."},"distractors":[{"id":"a","textTemplate":"1 percentage point"},{"id":"b","textTemplate":"30 percentage points"},{"id":"c","textTemplate":"50 percentage points"},{"id":"d","textTemplate":"100 percentage points"}], + "explanationTemplate":"Absolute risk reduction is {{control}}% − {{treatment}}% = {{arr}} percentage points.","keyTakeaway":"ARR is the arithmetic difference between control and treatment event rates.","usmlePearl":"Keep percentages in percentage points when subtracting.","references":[{"title":"Principles of Epidemiology","publisher":"CDC","url":"https://www.cdc.gov/field-epi-manual/php/chapters/analyzing-interpreting-data.html"}],"tags":["biostatistics"] + }, + { + "externalId":"dyn-biostats-nnt","version":1,"objectiveId":"biostats.number-needed-to-treat","title":"Number needed to treat","system":"Biostatistics","discipline":"Biostatistics","performanceCategory":"Applying Foundational Science Concepts","physicianTask":"Interpretation of Medical Literature","difficulty":3, + "stemTemplate":"A therapy lowers the {{outcome}} rate from {{control}}% to {{treatment}}%.","leadIn":"What is the approximate number needed to treat?","variables":{"outcome":{"type":"enum","values":["stroke","hospitalization","recurrent infection"]},"control":{"type":"integer","minimum":20,"maximum":20},"treatment":{"type":"integer","minimum":10,"maximum":10}},"derived":{},"constraints":[], + "correctAnswer":{"id":"correct","textTemplate":"10","explanationTemplate":"NNT = 1/0.10 = 10."},"distractors":[{"id":"a","textTemplate":"2"},{"id":"b","textTemplate":"5"},{"id":"c","textTemplate":"20"},{"id":"d","textTemplate":"100"}],"explanationTemplate":"The ARR is 10 percentage points, or 0.10. NNT = 1/0.10 = 10.","keyTakeaway":"NNT is the reciprocal of ARR expressed as a proportion.","usmlePearl":"Convert percentage points to a decimal before taking the reciprocal.","references":[{"title":"Understanding absolute and relative risk","publisher":"NLM","url":"https://www.ncbi.nlm.nih.gov/books/NBK63647/"}] + }, + { + "externalId":"dyn-biostats-test","version":1,"objectiveId":"biostats.diagnostic-test","title":"Diagnostic test characteristics","system":"Biostatistics","discipline":"Biostatistics","performanceCategory":"Applying Foundational Science Concepts","physicianTask":"Interpretation of Medical Literature","difficulty":2, + "stemTemplate":"A screening test has very high {{metric}} in a population of {{size}} people.","leadIn":"Which statement best describes this test?","variables":{"metric":{"type":"enum","values":["sensitivity"]},"size":{"type":"integer","minimum":500,"maximum":2000,"step":100}},"derived":{},"constraints":[], + "correctAnswer":{"id":"correct","textTemplate":"A negative result helps rule out disease","explanationTemplate":"A highly sensitive test has few false negatives."},"distractors":[{"id":"a","textTemplate":"A positive result definitively rules in disease"},{"id":"b","textTemplate":"Disease prevalence must be high"},{"id":"c","textTemplate":"Positive predictive value is always 100%"},{"id":"d","textTemplate":"Specificity must equal sensitivity"}],"explanationTemplate":"High sensitivity means few affected patients test negative, so a negative result is useful for ruling out disease.","keyTakeaway":"Sensitive tests are useful to rule out when negative.","usmlePearl":"SnNout is a memory aid, not a substitute for the 2×2 table.","references":[{"title":"Evidence-Based Medicine Toolkit","publisher":"University of Alberta","url":"https://www.ualberta.ca/en/medicine/programs/lifelong-learning/evidence-based-medicine/resources/ebm-toolkit.html"}] + }, + { + "externalId":"dyn-cardio-af","version":1,"objectiveId":"cardio.unstable-atrial-fibrillation","title":"Unstable atrial fibrillation","system":"Cardiovascular","discipline":"Medicine","performanceCategory":"Patient Care: Management","physicianTask":"Emergency Management","difficulty":2, + "stemTemplate":"A {{age}}-year-old patient has atrial fibrillation with a ventricular rate of {{rate}}/min, hypotension, diaphoresis, and altered mental status.","leadIn":"What is the next best step?","variables":{"age":{"type":"integer","minimum":48,"maximum":82},"rate":{"type":"integer","minimum":150,"maximum":190}},"derived":{},"constraints":[], + "correctAnswer":{"id":"correct","textTemplate":"Immediate synchronized cardioversion","explanationTemplate":"Hemodynamic instability requires synchronized cardioversion."},"distractors":[{"id":"a","textTemplate":"Oral metoprolol"},{"id":"b","textTemplate":"IV adenosine"},{"id":"c","textTemplate":"Observe for spontaneous conversion"},{"id":"d","textTemplate":"Begin warfarin and reassess tomorrow"}],"explanationTemplate":"Hypotension and altered mental status indicate instability; perform immediate synchronized cardioversion.","keyTakeaway":"Unstable tachyarrhythmia requires synchronized cardioversion.","usmlePearl":"Do not delay electrical therapy for anticoagulation in an unstable patient.","references":[{"title":"Adult Tachycardia With a Pulse Algorithm","publisher":"American Heart Association","url":"https://cpr.heart.org/en/resuscitation-science/cpr-and-ecc-guidelines/algorithms"}] + }, + { + "externalId":"dyn-renal-hyperk","version":1,"objectiveId":"renal.hyperkalemia-ecg","title":"Hyperkalemia with ECG changes","system":"Renal","discipline":"Medicine","performanceCategory":"Patient Care: Management","physicianTask":"Emergency Management","difficulty":2, + "stemTemplate":"A patient with kidney failure has potassium {{potassium}} mEq/L and {{ecg}}.","leadIn":"Which treatment should be given first?","variables":{"potassium":{"type":"decimal","minimum":6.6,"maximum":7.4,"step":0.1,"precision":1},"ecg":{"type":"enum","values":["peaked T waves","QRS widening"]}},"derived":{},"constraints":[], + "correctAnswer":{"id":"correct","textTemplate":"IV calcium gluconate","explanationTemplate":"Calcium stabilizes the cardiac membrane."},"distractors":[{"id":"a","textTemplate":"Oral sodium polystyrene sulfonate only"},{"id":"b","textTemplate":"Fluid restriction"},{"id":"c","textTemplate":"IV furosemide only"},{"id":"d","textTemplate":"Repeat potassium in 6 hours"}],"explanationTemplate":"ECG toxicity from hyperkalemia requires immediate IV calcium to stabilize the myocardium, followed by potassium-shifting and removal therapies.","keyTakeaway":"Give IV calcium first when hyperkalemia causes ECG changes.","usmlePearl":"Calcium protects the heart but does not lower serum potassium.","references":[{"title":"Acute Hyperkalemia in Adults","publisher":"KDIGO","url":"https://kdigo.org/conferences/acute-hyperkalemia/"}] + }, + { + "externalId":"dyn-endo-dka","version":1,"objectiveId":"endocrine.dka-initial-management","title":"Initial DKA management","system":"Endocrine","discipline":"Medicine","performanceCategory":"Patient Care: Management","physicianTask":"Emergency Management","difficulty":2, + "stemTemplate":"A {{age}}-year-old with polyuria, abdominal pain, glucose {{glucose}} mg/dL, anion-gap acidosis, and dehydration is diagnosed with DKA.","leadIn":"What is the initial treatment?","variables":{"age":{"type":"integer","minimum":19,"maximum":54},"glucose":{"type":"integer","minimum":420,"maximum":680,"step":10}},"derived":{},"constraints":[], + "correctAnswer":{"id":"correct","textTemplate":"IV isotonic crystalloid","explanationTemplate":"Volume resuscitation is the first treatment."},"distractors":[{"id":"a","textTemplate":"IV bicarbonate in every patient"},{"id":"b","textTemplate":"Subcutaneous long-acting insulin only"},{"id":"c","textTemplate":"Fluid restriction"},{"id":"d","textTemplate":"Immediate hemodialysis"}],"explanationTemplate":"Begin DKA treatment with isotonic IV fluid resuscitation, then address potassium and insulin based on laboratory results.","keyTakeaway":"Restore intravascular volume first in DKA.","usmlePearl":"Check potassium before or alongside starting insulin.","references":[{"title":"Hyperglycemic Crises in Adults","publisher":"American Diabetes Association","url":"https://diabetesjournals.org/care/article/47/8/1257/156806/Hyperglycemic-Crises-in-Adults-With-Diabetes-A"}] + }, + { + "externalId":"dyn-pulm-asthma","version":1,"objectiveId":"pulmonary.severe-asthma-exacerbation","title":"Severe asthma exacerbation","system":"Pulmonary","discipline":"Medicine","performanceCategory":"Patient Care: Management","physicianTask":"Emergency Management","difficulty":2, + "stemTemplate":"A {{age}}-year-old with asthma has severe dyspnea, diffuse wheezing, and oxygen saturation {{sat}}%.","leadIn":"Which inhaled treatment is most appropriate immediately?","variables":{"age":{"type":"integer","minimum":18,"maximum":60},"sat":{"type":"integer","minimum":84,"maximum":91}},"derived":{},"constraints":[], + "correctAnswer":{"id":"correct","textTemplate":"Albuterol plus ipratropium","explanationTemplate":"Severe exacerbations benefit from repeated SABA plus inhaled anticholinergic."},"distractors":[{"id":"a","textTemplate":"Salmeterol alone"},{"id":"b","textTemplate":"Inhaled corticosteroid alone"},{"id":"c","textTemplate":"Montelukast alone"},{"id":"d","textTemplate":"No bronchodilator until spirometry"}],"explanationTemplate":"Give rapid inhaled bronchodilation with albuterol and ipratropium, oxygen, and systemic glucocorticoids.","keyTakeaway":"Use SABA plus ipratropium for a severe acute asthma exacerbation.","usmlePearl":"Long-acting bronchodilators do not replace rescue therapy.","references":[{"title":"Global Strategy for Asthma Management and Prevention","publisher":"GINA","url":"https://ginasthma.org/reports/"}] + }, + { + "externalId":"dyn-ob-pyelo","version":1,"objectiveId":"ob.pregnancy-pyelonephritis","title":"Pyelonephritis in pregnancy","system":"Pregnancy","discipline":"Obstetrics","performanceCategory":"Patient Care: Management","physicianTask":"Pharmacotherapy","difficulty":3, + "stemTemplate":"A patient at {{weeks}} weeks' gestation has fever, flank pain, costovertebral-angle tenderness, and pyuria.","leadIn":"What is the most appropriate management?","variables":{"weeks":{"type":"integer","minimum":14,"maximum":32}},"derived":{},"constraints":[], + "correctAnswer":{"id":"correct","textTemplate":"Hospitalize and give IV ceftriaxone","explanationTemplate":"Pyelonephritis in pregnancy is initially managed inpatient with parenteral antibiotics."},"distractors":[{"id":"a","textTemplate":"Oral nitrofurantoin as outpatient"},{"id":"b","textTemplate":"No antibiotics until culture returns"},{"id":"c","textTemplate":"Oral phenazopyridine only"},{"id":"d","textTemplate":"Immediate delivery"}],"explanationTemplate":"Pregnant patients with pyelonephritis should receive initial inpatient parenteral therapy such as ceftriaxone, adjusted to culture results.","keyTakeaway":"Treat pyelonephritis in pregnancy initially with inpatient IV antibiotics.","usmlePearl":"Nitrofurantoin does not achieve adequate renal tissue levels for pyelonephritis.","references":[{"title":"Urinary Tract Infections in Pregnant Individuals","publisher":"ACOG","url":"https://www.acog.org/clinical/clinical-guidance/clinical-consensus/articles/2023/08/urinary-tract-infections-in-pregnant-individuals"}] + }, + { + "externalId":"dyn-endo-aldo","version":1,"objectiveId":"endocrine.primary-aldosteronism-screen","title":"Primary aldosteronism screening","system":"Endocrine","discipline":"Medicine","performanceCategory":"Patient Care: Diagnosis","physicianTask":"Diagnostic Studies","difficulty":3, + "stemTemplate":"A {{age}}-year-old has resistant hypertension and spontaneous hypokalemia with potassium {{potassium}} mEq/L.","leadIn":"What is the best initial screening test?","variables":{"age":{"type":"integer","minimum":32,"maximum":65},"potassium":{"type":"decimal","minimum":2.7,"maximum":3.3,"step":0.1,"precision":1}},"derived":{},"constraints":[], + "correctAnswer":{"id":"correct","textTemplate":"Plasma aldosterone-to-renin ratio","explanationTemplate":"The aldosterone-to-renin ratio is the recommended screening test."},"distractors":[{"id":"a","textTemplate":"24-hour urine metanephrines"},{"id":"b","textTemplate":"Dexamethasone suppression test"},{"id":"c","textTemplate":"TSH concentration"},{"id":"d","textTemplate":"Renal biopsy"}],"explanationTemplate":"Resistant hypertension plus hypokalemia suggests primary aldosteronism; screen with a plasma aldosterone-to-renin ratio.","keyTakeaway":"Use the aldosterone-to-renin ratio to screen for primary aldosteronism.","usmlePearl":"Correct hypokalemia and consider medication interference when interpreting the ratio.","references":[{"title":"Primary Aldosteronism Guideline","publisher":"Endocrine Society","url":"https://www.endocrine.org/clinical-practice-guidelines/primary-aldosteronism"}] + }, + { + "externalId":"dyn-neuro-sah","version":1,"objectiveId":"neuro.subarachnoid-hemorrhage-initial-test","title":"Suspected subarachnoid hemorrhage","system":"Neurologic","discipline":"Medicine","performanceCategory":"Patient Care: Diagnosis","physicianTask":"Diagnostic Studies","difficulty":2, + "stemTemplate":"A {{age}}-year-old develops a sudden maximal-at-onset headache during {{activity}}, with vomiting and neck stiffness.","leadIn":"What is the best initial diagnostic study?","variables":{"age":{"type":"integer","minimum":38,"maximum":72},"activity":{"type":"enum","values":["exercise","sexual activity","lifting a heavy object"]}},"derived":{},"constraints":[], + "correctAnswer":{"id":"correct","textTemplate":"Noncontrast CT of the head","explanationTemplate":"Noncontrast head CT is the initial test for suspected SAH."},"distractors":[{"id":"a","textTemplate":"EEG"},{"id":"b","textTemplate":"Carotid ultrasonography"},{"id":"c","textTemplate":"Cervical spine radiograph"},{"id":"d","textTemplate":"Electromyography"}],"explanationTemplate":"A thunderclap headache suggests subarachnoid hemorrhage; obtain an urgent noncontrast head CT first.","keyTakeaway":"Start evaluation of suspected SAH with noncontrast head CT.","usmlePearl":"If suspicion remains after a negative CT, additional evaluation may be required based on timing and local protocols.","references":[{"title":"Guideline for Aneurysmal Subarachnoid Hemorrhage","publisher":"American Heart Association","url":"https://www.ahajournals.org/doi/10.1161/STR.0000000000000436"}] + }, + { + "externalId":"dyn-pulm-pe","version":1,"objectiveId":"pulmonary.stable-pe-imaging","title":"Stable pulmonary embolism imaging","system":"Pulmonary","discipline":"Medicine","performanceCategory":"Patient Care: Diagnosis","physicianTask":"Diagnostic Studies","difficulty":3, + "stemTemplate":"A hemodynamically stable {{age}}-year-old has acute pleuritic chest pain, tachycardia, hypoxemia, and high clinical probability of pulmonary embolism. Kidney function is normal.","leadIn":"What is the best diagnostic test?","variables":{"age":{"type":"integer","minimum":35,"maximum":74}},"derived":{},"constraints":[], + "correctAnswer":{"id":"correct","textTemplate":"CT pulmonary angiography","explanationTemplate":"CT pulmonary angiography is first-line imaging in a stable patient without contrast contraindication."},"distractors":[{"id":"a","textTemplate":"Exercise stress test"},{"id":"b","textTemplate":"Bronchoscopy"},{"id":"c","textTemplate":"Noncontrast chest CT"},{"id":"d","textTemplate":"Coronary angiography"}],"explanationTemplate":"In a stable patient with high PE probability and no contrast contraindication, proceed to CT pulmonary angiography.","keyTakeaway":"Use CT pulmonary angiography to diagnose PE when probability is high and contrast is acceptable.","usmlePearl":"A D-dimer is most useful when pretest probability is low or intermediate.","references":[{"title":"Acute Pulmonary Embolism Guideline","publisher":"European Society of Cardiology","url":"https://www.escardio.org/Guidelines/Clinical-Practice-Guidelines/Acute-Pulmonary-Embolism-Diagnosis-and-Management-of"}] + }, + { + "externalId":"dyn-id-meningitis","version":1,"objectiveId":"infectious.adult-bacterial-meningitis","title":"Empiric adult bacterial meningitis therapy","system":"Infectious Disease","discipline":"Medicine","performanceCategory":"Patient Care: Management","physicianTask":"Pharmacotherapy","difficulty":3, + "stemTemplate":"A {{age}}-year-old has fever, severe headache, neck stiffness, and altered mental status. Acute bacterial meningitis is suspected.","leadIn":"Which empiric antibiotic regimen is most appropriate?","variables":{"age":{"type":"integer","minimum":19,"maximum":45}},"derived":{},"constraints":[], + "correctAnswer":{"id":"correct","textTemplate":"Vancomycin plus ceftriaxone","explanationTemplate":"This combination covers resistant pneumococcus and common adult pathogens."},"distractors":[{"id":"a","textTemplate":"Azithromycin alone"},{"id":"b","textTemplate":"Metronidazole alone"},{"id":"c","textTemplate":"Nitrofurantoin alone"},{"id":"d","textTemplate":"Wait for CSF culture before antibiotics"}],"explanationTemplate":"For a nonelderly adult with suspected community-acquired bacterial meningitis, promptly give vancomycin plus a third-generation cephalosporin; add other agents when indicated by age or immune status.","keyTakeaway":"Do not delay empiric antibiotics in suspected bacterial meningitis.","usmlePearl":"Add ampicillin when Listeria coverage is indicated.","references":[{"title":"Practice Guidelines for Bacterial Meningitis","publisher":"IDSA","url":"https://academic.oup.com/cid/article/39/9/1267/402080"}] + }, + { + "externalId":"dyn-psych-suicide","version":1,"objectiveId":"psychiatry.imminent-suicide-risk","title":"Imminent suicide risk","system":"Behavioral Health","discipline":"Psychiatry","performanceCategory":"Patient Care: Management","physicianTask":"Disposition","difficulty":2, + "stemTemplate":"A {{age}}-year-old with major depression reports a plan to die by suicide tonight, has access to the means, and cannot agree to a safety plan.","leadIn":"What is the most appropriate next step?","variables":{"age":{"type":"integer","minimum":18,"maximum":64}},"derived":{},"constraints":[], + "correctAnswer":{"id":"correct","textTemplate":"Emergency psychiatric hospitalization","explanationTemplate":"Specific plan, access, and imminent intent require a protected setting."},"distractors":[{"id":"a","textTemplate":"Routine follow-up in one month"},{"id":"b","textTemplate":"Ask the patient to promise not to self-harm and discharge"},{"id":"c","textTemplate":"Start psychotherapy next week without further action"},{"id":"d","textTemplate":"Leave the patient alone while arranging transport"}],"explanationTemplate":"Imminent suicide risk requires continuous safety measures and emergency psychiatric evaluation with hospitalization, involuntarily if necessary under applicable law.","keyTakeaway":"Hospitalize patients with imminent suicide risk.","usmlePearl":"A no-suicide contract does not replace risk assessment and safety planning.","references":[{"title":"Suicide Prevention Resource Center","publisher":"SPRC","url":"https://sprc.org/effective-prevention/a-comprehensive-approach-to-suicide-prevention/"}] + }, + { + "externalId":"dyn-prevent-crc","version":1,"objectiveId":"preventive.colorectal-screening","title":"Colorectal cancer screening","system":"Preventive Medicine","discipline":"Family Medicine","performanceCategory":"Health Maintenance","physicianTask":"Preventive Care","difficulty":2, + "stemTemplate":"An asymptomatic {{age}}-year-old at average colorectal cancer risk has never been screened.","leadIn":"What is the best recommendation?","variables":{"age":{"type":"integer","minimum":45,"maximum":49}},"derived":{},"constraints":[], + "correctAnswer":{"id":"correct","textTemplate":"Begin colorectal cancer screening now","explanationTemplate":"Average-risk screening begins at age 45."},"distractors":[{"id":"a","textTemplate":"Wait until age 60"},{"id":"b","textTemplate":"Screen only if symptoms develop"},{"id":"c","textTemplate":"Order annual abdominal CT"},{"id":"d","textTemplate":"No screening is recommended at any age"}],"explanationTemplate":"Average-risk adults should begin colorectal cancer screening at age 45 using an accepted stool-based or direct-visualization strategy.","keyTakeaway":"Begin average-risk colorectal cancer screening at age 45.","usmlePearl":"Test choice and interval depend on the selected screening modality.","references":[{"title":"Colorectal Cancer Screening Recommendation","publisher":"USPSTF","url":"https://www.uspreventiveservicestaskforce.org/uspstf/recommendation/colorectal-cancer-screening"}] + }, + { + "externalId":"dyn-ethics-surrogate","version":1,"objectiveId":"ethics.surrogate-decision-making","title":"Surrogate decision-making","system":"Social Sciences","discipline":"Ethics","performanceCategory":"Communication and Interpersonal Skills","physicianTask":"Legal/Ethical Principles","difficulty":3, + "stemTemplate":"An incapacitated {{age}}-year-old has no advance directive. The legally authorized surrogate is asked to choose among treatments.","leadIn":"Which standard should primarily guide the surrogate?","variables":{"age":{"type":"integer","minimum":46,"maximum":88}},"derived":{},"constraints":[], + "correctAnswer":{"id":"correct","textTemplate":"The patient's known values and previously expressed preferences","explanationTemplate":"Use substituted judgment when the patient's preferences are known."},"distractors":[{"id":"a","textTemplate":"The surrogate's personal treatment preferences"},{"id":"b","textTemplate":"The least expensive option regardless of benefit"},{"id":"c","textTemplate":"Whatever treatment the physician would choose personally"},{"id":"d","textTemplate":"Always provide every available intervention"}],"explanationTemplate":"A surrogate should use substituted judgment based on the patient's known values and prior wishes; if unknown, use the patient's best interests.","keyTakeaway":"Surrogates represent the patient's preferences, not their own.","usmlePearl":"Use best-interest reasoning only when the patient's preferences cannot be inferred.","references":[{"title":"Decisions for Adult Patients Who Lack Capacity","publisher":"American Medical Association","url":"https://code-medical-ethics.ama-assn.org/ethics-opinions/decisions-adult-patients-who-lack-capacity"}] + } +] diff --git a/docs/CCS_CASE_AUTHORING.md b/docs/CCS_CASE_AUTHORING.md index 2fa2eff..f35ec76 100644 --- a/docs/CCS_CASE_AUTHORING.md +++ b/docs/CCS_CASE_AUTHORING.md @@ -6,16 +6,21 @@ Use `content/ccs/dka-emergency-001.json` as the canonical example. Set `validate ## Case structure -- `initialChart` defines the opening history, appearance, vitals, location, simulated start, and standing orders. +- `initialChart` defines the opening history, appearance, vitals, simulated start, and standing orders. Use ordered `historySections` (`code`, `title`, `text`) for the HPI, medical history, medications, allergies, family/social history, and review of systems. The legacy `history` field remains the fallback. - `allowedLocations` controls patient movement. - `exams` defines selectable examination components and their simulated duration. -- `orderResponses` maps reusable catalog codes to case-specific results, delays, and vital-sign changes. +- Catalog orders may define `displayRoute`, `displayFrequency`, and `resultSection`. These are presentation defaults, not user-entered dosing instructions. +- `orderResponses` maps reusable catalog codes to case-specific results, delays, vital-sign changes, and an optional result-section override. - `events` defines timed progression. `requiresOrderCodes` requires every listed order; `requiresAnyOrderCodes` supports equivalent alternatives. - `scoringCriteria` awards or subtracts points for timing, sequence, and harmful care. `targetCodes` supports clinically equivalent actions; `afterTargetCode` enforces sequence. - `references` stores the source title, publisher, HTTPS URL, supported claims, access date, and reviewer inside each case revision. Order codes and event/criterion IDs must be stable lowercase identifiers. Case content should not expose the diagnosis in the opening chart or rely on unstructured text matching for scoring. Add aliases to the order catalog so users can find an order using common clinical wording. -## Scoring and prediction +Valid result sections are `lab`, `imaging`, `other_test`, and `treatment`; progress notes, vital snapshots, and the order sheet are generated by the runtime. -Practice attempts are scored for feedback but do not affect prediction. Only the first completed timed attempt for each distinct case contributes to the CCS estimate. The combined provisional prediction uses the latest Day 1 simulation and a separately reported CCS estimate; it is not presented as an official USMLE scoring formula. +## Timing, scoring, and prediction + +New CCS attempts are timed. `realTimeMinutes` is the total wall-clock allowance: the runtime reserves its final two minutes for final orders, so a 20-minute case has 18 minutes of active management plus two minutes of case-end orders. Opening dialogs count against this limit and reconnecting does not pause it. + +Only the first completed timed attempt for each distinct case contributes to the CCS estimate. Legacy practice attempts remain reviewable but cannot be newly created. The combined provisional prediction uses the latest Day 1 simulation and a separately reported CCS estimate; it is not presented as an official USMLE scoring formula. diff --git a/docs/FLASHCARDS.md b/docs/FLASHCARDS.md new file mode 100644 index 0000000..466a0b4 --- /dev/null +++ b/docs/FLASHCARDS.md @@ -0,0 +1,113 @@ +# Flashcards + +A separate spaced-repetition system alongside the missed-question review queue. Users author +basic and cloze flashcards (or generate a basic card from a resolved question), organize them +in flat decks, and study them on an FSRS schedule. + +## Scheduling + +Reviews are scored by [FSRS.Core](https://www.nuget.org/packages/FSRS.Core) 1.0.7, wrapped +behind `IFlashcardScheduler` (`api/QuestionsApi/Services/Flashcards/FlashcardScheduler.cs`) so +the rest of the API never touches the library directly. Pinned, non-configurable defaults: + +- Desired retention: 90% +- Maximum interval: 36,500 days +- Interval fuzzing: disabled, so a review's outcome is a deterministic function of stored state + (needed for idempotent replay from the offline outbox, and for golden-vector testing) +- Algorithm/parameter version is recorded per card (`FlashcardSchedule.AlgorithmVersion`) so a + future FSRS upgrade or personalized-parameter feature can be introduced without losing the + ability to tell which cards were scheduled under which version + +There is no per-user parameter optimization yet; `FlashcardScheduleStore` stores complete +before/after snapshots on every `FlashcardReviewLog` entry specifically so that a future +optimizer can be trained from full history without a data migration. + +`api/QuestionsApi.Tests/FlashcardSchedulerTests.cs` validates the wrapper against the official +FSRS formulas (initial stability/difficulty per rating, and the interval-equals-stability +identity that holds at exactly 90% desired retention) computed from the library's own shipped +default parameters, rather than against hand-copied constants - this stays correct even if +FSRS.Core's defaults are retrained upstream. + +## Data model + +Six user-owned Mongo collections, all indexed on `(UserId, ...)`: + +- `FlashcardDecks` - flat (no nesting); every user has exactly one `IsDefault` deck, created + lazily on first use. +- `FlashcardNotes` - authored content (`Prompt`/`Answer`/`Extra`/`Tags`/`Images`). Cloze notes + store their `{{cN::answer::hint}}` markup in `Prompt`. +- `FlashcardSchedules` - one row per generated card, keyed by `(NoteId, Variant)`. Basic notes + always have variant `0`; cloze notes get one row per distinct cloze number found in the + prompt. Kept in a separate collection from notes so editing a note's content never discards + its review history. +- `FlashcardReviewLogs` - immutable, keyed by the client-generated review UUID for idempotent + submission; carries full before/after schedule snapshots. +- `FlashcardMediaAssets` - user-uploaded images (PNG/JPEG/WebP, 5 MB/4-per-note limits). +- `FlashcardSettings` - one row per user (global new/review daily limits). + +Attaching a question's own image to a flashcard does not copy bytes: `FlashcardImageReference` +has a `Kind` (`"question"` vs `"owned"`) so the client can resolve `"question"` references +against the existing `/api/questions/media/{id}` endpoint instead of duplicating the asset. + +Sibling cloze cards are buried (`FlashcardSchedule.BuriedUntilUtc`) until the next local study +day whenever any one sibling is reviewed. Daily new/review limits reset at the same boundary. +That boundary is supplied by the client as `studyDayStartUtc` on study/review requests (the +local calendar day at session start, expressed as a UTC instant) rather than computed +server-side, per the spec's client-local-day requirement. + +## API + +All endpoints live under `/api/flashcards` (`FlashcardsController.cs`) and require `USER`/`SUPER` +auth. Deck/note mutations use the same optimistic-concurrency (`ExpectedRevision` + 409 Conflict) +pattern as the rest of the API. Review submission is idempotent on `ClientReviewId`; undo only +allows reverting the single most recent review on a card (it refuses if the card's revision has +moved since). + +## Progress metrics + +`GET /decks` (`FlashcardDeckSummary`) and `GET /counts` (`FlashcardDashboardCounts`) expose a few +derived stats the client can't compute itself: + +- **Mastered** (per-deck, in `FlashcardDeckSummary.Mastered`) - a card counts once it's graduated + to `review` state *and* its FSRS stability has reached + `FlashcardScheduleStore.MasteryStabilityDaysThreshold` (21 days), the same rough "won't need + attention again soon" cutoff several SRS apps use. Deliberately stricter than the plain + `Review`-state count, which includes cards that just graduated and haven't proven stable yet. +- **CurrentStreakDays** (global, in `FlashcardDashboardCounts`) - consecutive UTC calendar days + with at least one non-undone review, computed in `FlashcardScheduling.ComputeStreakDays` from a + 400-day lookback of review timestamps. The streak counts through today even before today's + first review (it only breaks once *yesterday* has no review either), matching typical + habit-streak semantics rather than resetting to 0 at midnight. +- **DailyGoal** / **ReviewsToday** (global) - `DailyGoal` is simply the account's configured + `FlashcardSettings.ReviewsPerDay`; `ReviewsToday` is reviews logged since the client's + `studyDayStartUtc` for `/study/next` calls, but since `/counts` has no per-request client-day + concept it uses the server's own UTC calendar day instead - a day-boundary edge case is possible + right around midnight, unlike the study-session endpoints which always trust the client's local + day. + +## Import/export + +CSV is UTF-8, text-only, headers `stable_id,deck,type,prompt,answer,extra,tags, +source_question_external_id` (tags semicolon-separated); it omits images, schedule state, and +review history and reports those omissions via the `X-Flashcard-Export-Omissions` response +header. App JSON is full-fidelity (decks, settings, notes, schedules, review logs, base64 +images) and merges on import by the note's stable UUID. Both formats go through a +create/update/skip/error preview (`FlashcardImportPreviews`, TTL-expired after an hour) before +`import/commit` applies it; commit never silently drops a row - everything is created, updated, +skipped as an exact-content duplicate, or reported as a row-level error. Limits: 25 MB and +10,000 notes per import. + +The Godot client (`scripts/autoload/flashcard_service.gd`) picks a file, uploads it to the +matching preview endpoint as `multipart/form-data`, and shows the create/update/skip/error counts +before letting the user commit or cancel. Export streams the response body and saves it client +side. File selection and download both branch on `OS.has_feature("web")`: on web, a hidden +``/`FileReader` pair (bridged back into GDScript via +`JavaScriptBridge.create_callback`) and an anchor-click data-URL download stand in for a native +picker/save dialog; on desktop, a `FileDialog` and the OS downloads folder are used instead. + +## Deployment + +No new secrets. `FlashcardsController`/stores register the same way as the rest of the API +(`Program.cs`, `AddSingleton`). The Godot client resolves its API base URL from +`step3/flashcards_api_url` in `project.godot`, following the same same-origin-on-web resolution +as every other autoload service (`api_endpoint_config.gd`). diff --git a/docs/PROFILE_ACCOUNT_OPERATIONS.md b/docs/PROFILE_ACCOUNT_OPERATIONS.md index ca4beaf..2b92932 100644 --- a/docs/PROFILE_ACCOUNT_OPERATIONS.md +++ b/docs/PROFILE_ACCOUNT_OPERATIONS.md @@ -28,3 +28,7 @@ must initiate recovery with their exact username. Exports are versioned JSON files containing account metadata and user-owned study records. Password hashes, refresh tokens, global question content, and moderation data owned by other users are excluded. + +Flashcard decks, notes, generated cards, review logs, media, and settings are user-owned +collections (see [FLASHCARDS.md](FLASHCARDS.md)) and are included in export/purge the same way +as the rest of a learner's study data. diff --git a/docs/UAT_SMOKE_TESTS.md b/docs/UAT_SMOKE_TESTS.md new file mode 100644 index 0000000..a159a76 --- /dev/null +++ b/docs/UAT_SMOKE_TESTS.md @@ -0,0 +1,40 @@ +# UAT smoke tests + +The UAT smoke suite runs after each successful UAT deployment and can also be +started manually with the `Smoke Test STEP3 UAT` Gitea workflow. + +It checks: + +- public API health, readiness, environment, and deployed revision; +- legal metadata and configured Steam and Google authorization URLs; +- registration, login, refresh, authenticated profile, dashboard, ratings, + review queue, and randomized question retrieval; +- random CCS attempt creation and abandonment; +- flashcard deck/card creation, studying and grading a card, confirming it + drops out of the due queue, and a CSV export/import round trip; +- public web/legal assets, completion of the Godot loader, and a visible, + non-uniform canvas without browser or critical asset errors. + +Each run creates a unique temporary learner account. The test abandons its CCS +attempt and deletes the account even when an assertion fails. It does not +submit question answers, complete OAuth, or open Stripe Checkout, so it does +not alter question statistics, learner ratings, donations, or provider +accounts. + +Failure evidence is retained for 14 days under the workflow's +`uat-smoke--` artifact. It includes JUnit XML, an HTML report, the +rendered canvas image, and Playwright traces/screenshots for failed browser +tests. + +No secrets are required. The public UAT origins are supplied by the workflow. +The registration endpoint permits five registrations per source IP in 15 +minutes, so repeatedly dispatching the smoke workflow in a short interval may +receive HTTP 429 until that window resets. + +To run the same suite from a Linux host with Docker: + +```bash +UAT_API_ORIGIN=https://us3q.ranaze.com \ +UAT_WEB_ORIGIN=https://ustep3.ranaze.com \ +bash .gitea/scripts/run-uat-smoke.sh +``` diff --git a/docs/dynamic-question-quality.md b/docs/dynamic-question-quality.md new file mode 100644 index 0000000..160ccf3 --- /dev/null +++ b/docs/dynamic-question-quality.md @@ -0,0 +1,64 @@ +# Dynamic question quality standard + +Quality-standard-2 blueprints are eligible for learner runs. Older blueprints +remain stored only so signed questions and reports can be regenerated. + +Quality-standard-3 blueprints add Elo-adaptive difficulty. Each active objective +must provide at least two families at every tier: foundation (850), standard +(1050), advanced (1250), and expert (1450). The service targets an item about 70 +Elo below effective learner ability, approximately 60% expected correctness. +Effective ability begins as an equal blend of overall and category question-bank +Elo, then shifts toward dynamic objective Elo up to an 80% objective weight after +10 attempts. Aggregate outcomes calibrate each case family's Elo over time. + +Quality-standard-4 blueprints add Step 3 coverage and lifecycle metadata. They +must declare the organ system, physician task, encounter frame, site of care, +tested concepts, medical reviewer, review date, and review expiration. Every +distractor must identify the misconception it represents. An expired medical +review prevents publication. + +## Adaptive learning loop + +New questions are chosen using a 50/30/20 policy: + +- 50% scheduled concept reviews, when any are due. +- 30% underexposed blueprint areas, adjusted by `blueprintWeight`. +- 20% interleaved maintenance from the learner's weakest objectives. + +Incorrect answers enter concept review; skips and correct first attempts do not. +Reviews use a freshly generated variant at 1, 3, 7, 21, and 45 days. An +incorrect review resets the interval to one day. + +## Content requirements + +- At least three curated case families per objective. +- One invariant `correctConceptId` across every family. +- A complete clinical vignette, focused lead-in, and coherent correlated data. +- At least six plausible distractors with an explanation for every answer. +- A complete explanation, key takeaway, USMLE pearl, review follow-up, and + wrong-answer remediation. +- At least one authoritative reference. +- Quality-standard-4 content includes misconception IDs and current medical + review metadata. + +Promotion validates 10,000 seeds for every quality-standard-2 blueprint. Existing +blueprint versions are immutable; content changes require a version increment. + +## Blinded acceptance + +Create a balanced packet of three generated variants per gold objective and the +same number of authored questions: + +```text +dotnet QuestionsApi.dll export-dynamic-review \ + --source /app/content/dynamic_questions \ + --output /tmp/dynamic-review +``` + +Keep `answer-key.json` separate from `review-packet.json`. Two clinical reviewers +score realism, distractor quality, explanation quality, and Step 3 relevance from +1-5 and predict whether each item is authored or dynamic. + +The pilot passes when there are no critical medical or ambiguity findings, every +dimension averages at least 4/5, and source-identification accuracy is no greater +than 65%. Corrections are published as a new blueprint version and reviewed again. diff --git a/export_presets.cfg b/export_presets.cfg index ae0446b..af03f14 100644 --- a/export_presets.cfg +++ b/export_presets.cfg @@ -27,3 +27,72 @@ html/canvas_resize_policy=2 html/focus_canvas_on_start=true html/experimental_virtual_keyboard=false progressive_web_app/enabled=false + +[preset.1] + +name="Windows Desktop" +platform="Windows Desktop" +runnable=true +advanced_options=false +dedicated_server=false +custom_features="" +export_filter="all_resources" +include_filter="" +exclude_filter="" +export_path="build/windows/RanaZe.exe" +patches=PackedStringArray() +encryption_include_filters="" +encryption_exclude_filters="" +encrypt_pck=false +encrypt_directory=false +script_export_mode=2 + +[preset.1.options] + +custom_template/debug="" +custom_template/release="" +debug/export_console_wrapper=1 +binary_format/embed_pck=false +binary_format/architecture="x86_64" +texture_format/bptc=true +texture_format/s3tc=true +texture_format/etc=false +texture_format/etc2=false +codesign/enable=false +codesign/timestamp=true +codesign/timestamp_server_url="" +codesign/digest_algorithm=1 +codesign/description="" +codesign/custom_options=PackedStringArray() +application/modify_resources=true +application/icon="res://assets/branding/ranaze-app-icon.ico" +application/console_wrapper_icon="" +application/icon_interpolation=4 +application/file_version="0.1.0" +application/product_version="0.1.0" +application/company_name="RanaZe" +application/product_name="RanaZe: Step 3 Trainer" +application/file_description="RanaZe: Step 3 Trainer" +application/copyright="RanaZe" +application/trademarks="" +application/export_angle=0 +ssh_remote_deploy/enabled=false +ssh_remote_deploy/host="user@host_ip" +ssh_remote_deploy/port="22" +ssh_remote_deploy/extra_args_ssh="" +ssh_remote_deploy/extra_args_scp="" +ssh_remote_deploy/run_script="Expand-Archive -LiteralPath '{temp_dir}\\{archive_name}' -DestinationPath '{temp_dir}' +$action = New-ScheduledTaskAction -Execute '{temp_dir}\\{exe_name}' -Argument '{cmd_args}' +$trigger = New-ScheduledTaskTrigger -Once -At 00:00 +$settings = New-ScheduledTaskSettingsSet +$task = New-ScheduledTask -Action $action -Trigger $trigger -Settings $settings +Register-ScheduledTask godot_remote_debug -InputObject $task -Force:$true +Start-ScheduledTask -TaskName godot_remote_debug +while (Get-ScheduledTask -TaskName godot_remote_debug | ? State -eq running) { Start-Sleep -Milliseconds 100 } +Unregister-ScheduledTask -TaskName godot_remote_debug -Confirm:$false" +ssh_remote_deploy/cleanup_script="$tasks = Get-ScheduledTask godot_remote_debug -ErrorAction Ignore +if ($tasks) { +$tasks | Stop-ScheduledTask +$tasks | Unregister-ScheduledTask -Confirm:$false +} +Remove-Item -Recurse -Force '{temp_dir}'" diff --git a/project.godot b/project.godot index 1ae990d..531dbe0 100644 --- a/project.godot +++ b/project.godot @@ -17,6 +17,10 @@ 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" +boot_splash/bg_color=Color(0.019607844, 0.02745098, 0.05882353, 1) +boot_splash/image="res://web/ranaze-loader-icon-v1.png" +boot_splash/fullsize=false +boot_splash/use_filter=true [autoload] @@ -25,6 +29,7 @@ LegalService="*res://scripts/autoload/legal_service.gd" UIThemeService="*res://scripts/autoload/ui_theme_service.gd" UISoundService="*res://scripts/autoload/ui_sound_service.gd" QuestionBankService="*res://scripts/autoload/question_bank_service.gd" +DynamicQuestionService="*res://scripts/autoload/dynamic_question_service.gd" ProgressionService="*res://scripts/autoload/progression_service.gd" ProfileService="*res://scripts/autoload/profile_service.gd" BlockSessionService="*res://scripts/autoload/block_session_service.gd" @@ -35,6 +40,7 @@ NotesService="*res://scripts/autoload/notes_service.gd" BookmarkService="*res://scripts/autoload/bookmark_service.gd" ReviewQueueService="*res://scripts/autoload/review_queue_service.gd" DashboardService="*res://scripts/autoload/dashboard_service.gd" +FlashcardService="*res://scripts/autoload/flashcard_service.gd" AccountProfileService="*res://scripts/autoload/account_profile_service.gd" DonationService="*res://scripts/autoload/donation_service.gd" @@ -67,4 +73,5 @@ dashboard_api_url="https://us3q.ranaze.com/api/dashboard" profile_api_url="https://us3q.ranaze.com/api/profile" donations_api_url="https://us3q.ranaze.com/api/donations" review_queue_api_url="https://us3q.ranaze.com/api/review-queue" +flashcards_api_url="https://us3q.ranaze.com/api/flashcards" build_revision="development" diff --git a/scripts/autoload/account_profile_service.gd b/scripts/autoload/account_profile_service.gd index 4c9ff0c..6d27b9f 100644 --- a/scripts/autoload/account_profile_service.gd +++ b/scripts/autoload/account_profile_service.gd @@ -84,6 +84,20 @@ func update_preferences(target_exam_date: String, preparation_stage: String, wee _request_profile("update_preferences", HTTPClient.METHOD_PUT, "/me/preferences", preferences) +func update_text_scale(question_text_scale: Variant = null, ccs_text_scale: Variant = null) -> void: + var payload := {} + if question_text_scale != null: + payload["questionTextScale"] = question_text_scale + preferences["questionTextScale"] = question_text_scale + if ccs_text_scale != null: + payload["ccsTextScale"] = ccs_text_scale + preferences["ccsTextScale"] = ccs_text_scale + if payload.is_empty(): + return + _save_cache(preferences_pending) + _request_profile("update_text_scale", HTTPClient.METHOD_PUT, "/me/text-scale", payload) + + func change_password(current_password: String, new_password: String) -> void: _request_auth("change_password", HTTPClient.METHOD_POST, "/me/change-password", { "currentPassword": current_password, "newPassword": new_password @@ -192,7 +206,7 @@ func _on_profile_completed(result: int, response_code: int, _headers: PackedStri if typeof(parsed) != TYPE_DICTIONARY: request_failed.emit("The profile service returned invalid data.") return - if operation in ["preferences", "update_preferences"]: + if operation in ["preferences", "update_preferences", "update_text_scale"]: preferences = parsed preferences_pending = false _save_cache(false) @@ -304,6 +318,7 @@ func _on_auth_changed() -> void: else: account = {} preferences = _default_preferences() + profile_changed.emit() func _cache_path() -> String: @@ -317,6 +332,7 @@ func _load_cache() -> void: if typeof(parsed) == TYPE_DICTIONARY: preferences = parsed.get("preferences", _default_preferences()) preferences_pending = bool(parsed.get("pending", false)) + profile_changed.emit() func _save_cache(pending: bool) -> void: @@ -345,4 +361,7 @@ func _error_message(body: PackedByteArray, fallback: String) -> String: static func _default_preferences() -> Dictionary: - return {"targetExamDate": null, "preparationStage": "pre_dedicated", "weeklyGoalHours": 10, "preferredStudyMode": "mixed"} + return { + "targetExamDate": null, "preparationStage": "pre_dedicated", "weeklyGoalHours": 10, "preferredStudyMode": "mixed", + "questionTextScale": 1.0, "ccsTextScale": 1.0 + } diff --git a/scripts/autoload/auth_state.gd b/scripts/autoload/auth_state.gd index 0e99b6f..e4b12ad 100644 --- a/scripts/autoload/auth_state.gd +++ b/scripts/autoload/auth_state.gd @@ -12,6 +12,7 @@ const EndpointConfig := preload("res://scripts/autoload/api_endpoint_config.gd") const DEFAULT_AUTH_BASE_URL := "https://us3q.ranaze.com/api/Auth" const WARNING_SECONDS := 120 const REFRESH_TIMEOUT_SECONDS := 20.0 +const ACTIVE_RECENTLY_SECONDS := 60 var is_logged_in := false var username := "" @@ -32,6 +33,7 @@ var expiry_timer: Timer var refresh_request: HTTPRequest var session_revision := 0 var refresh_revision := -1 +var last_activity_unix := 0.0 func _ready() -> void: @@ -46,6 +48,12 @@ func _ready() -> void: refresh_request.timeout = REFRESH_TIMEOUT_SECONDS refresh_request.request_completed.connect(_on_refresh_completed) add_child(refresh_request) + last_activity_unix = Time.get_unix_time_from_system() + + +func _input(_event: InputEvent) -> void: + last_activity_unix = Time.get_unix_time_from_system() + func set_session(new_username: String, token: String, new_refresh_token: String = "", new_role: String = "", token_expiration: String = "", legal: Dictionary = {}) -> void: session_revision += 1 @@ -59,6 +67,7 @@ func set_session(new_username: String, token: String, new_refresh_token: String refresh_in_progress = false refresh_revision = -1 warning_active = false + last_activity_unix = Time.get_unix_time_from_system() set_legal_state(legal, false) if is_logged_in and expiration_unix > 0.0: expiry_timer.start() @@ -151,6 +160,10 @@ func _evaluate_expiry() -> void: expire_session("expired") return if remaining <= WARNING_SECONDS: + var seconds_since_activity := Time.get_unix_time_from_system() - last_activity_unix + if seconds_since_activity <= ACTIVE_RECENTLY_SECONDS and not refresh_in_progress: + request_session_refresh() + return if not warning_active: warning_active = true session_warning.emit(remaining) diff --git a/scripts/autoload/block_session_service.gd b/scripts/autoload/block_session_service.gd index 70df3b9..36b7451 100644 --- a/scripts/autoload/block_session_service.gd +++ b/scripts/autoload/block_session_service.gd @@ -28,10 +28,91 @@ var simulation_block_index := 0 var simulation_total_blocks := 12 var simulation_deadline_unix := 0.0 var review_first_submission_recorded := false +var core_block_size := DEFAULT_CORE_BLOCK_SIZE +var show_review_followups := true const CORE_MIN_TARGET_SECONDS := 45.0 const CORE_MAX_TARGET_SECONDS := 150.0 const CORE_RETRY_TARGET_SECONDS := 30.0 +const DEFAULT_CORE_BLOCK_SIZE := 10 +const CORE_BLOCK_SIZE_MIN := 5 +const CORE_BLOCK_SIZE_MAX := 40 +const SETTINGS_PATH := "user://settings.cfg" +const SETTINGS_SECTION := "session" +const SETTINGS_KEY_CORE_BLOCK_SIZE := "core_block_size" +const SETTINGS_KEY_SHOW_REVIEW_FOLLOWUPS := "show_review_followups" + + +func _ready() -> void: + _load_core_block_size() + _load_show_review_followups() + + +func set_core_block_size(value: int) -> void: + var clamped := clampi(value, CORE_BLOCK_SIZE_MIN, CORE_BLOCK_SIZE_MAX) + if clamped == core_block_size: + return + core_block_size = clamped + var config := ConfigFile.new() + config.load(SETTINGS_PATH) + config.set_value(SETTINGS_SECTION, SETTINGS_KEY_CORE_BLOCK_SIZE, core_block_size) + if config.save(SETTINGS_PATH) != OK: + push_warning("Unable to save core question block size preference.") + + +func set_show_review_followups(value: bool) -> void: + if show_review_followups == value: + return + show_review_followups = value + var config := ConfigFile.new() + config.load(SETTINGS_PATH) + config.set_value(SETTINGS_SECTION, SETTINGS_KEY_SHOW_REVIEW_FOLLOWUPS, show_review_followups) + if config.save(SETTINGS_PATH) != OK: + push_warning("Unable to save follow-up question preference.") + + +func _load_core_block_size() -> void: + var config := ConfigFile.new() + if config.load(SETTINGS_PATH) == OK: + core_block_size = clampi( + int(config.get_value(SETTINGS_SECTION, SETTINGS_KEY_CORE_BLOCK_SIZE, DEFAULT_CORE_BLOCK_SIZE)), + CORE_BLOCK_SIZE_MIN, CORE_BLOCK_SIZE_MAX) + + +func _load_show_review_followups() -> void: + var config := ConfigFile.new() + if config.load(SETTINGS_PATH) == OK: + show_review_followups = bool(config.get_value(SETTINGS_SECTION, SETTINGS_KEY_SHOW_REVIEW_FOLLOWUPS, true)) + + +func start_dynamic_question(question: Dictionary) -> bool: + if question.is_empty(): + return false + system_name = str(question.get("system", "All Systems")) + mode = "Tutor" + session_kind = "dynamic" + active_questions = [_prepare_question_choices(question)] + answers = [null] + current_index = 0 + seconds_remaining = 0.0 + session_initial_seconds = 90.0 + current_question_started_at = Time.get_ticks_msec() / 1000.0 + current_question_pause_started_at = 0.0 + block_started = true + session_changed.emit() + return true + + +func replace_dynamic_question(question: Dictionary) -> bool: + if session_kind != "dynamic" or question.is_empty(): + return false + active_questions = [_prepare_question_choices(question)] + answers = [null] + current_index = 0 + current_question_started_at = Time.get_ticks_msec() / 1000.0 + current_question_pause_started_at = 0.0 + session_changed.emit() + return true func start_block(selected_system: String, block_size: int, selected_mode: String) -> void: system_name = selected_system diff --git a/scripts/autoload/ccs_service.gd b/scripts/autoload/ccs_service.gd index ac446bf..e664191 100644 --- a/scripts/autoload/ccs_service.gd +++ b/scripts/autoload/ccs_service.gd @@ -13,6 +13,7 @@ signal attempt_abandoned signal report_submitted(report_id: String) signal report_failed(message: String) signal request_failed(message: String) +signal text_scale_changed const TEXT_SCALE_MIN := 0.85 const TEXT_SCALE_MAX := 1.35 @@ -41,14 +42,20 @@ func _ready() -> void: request.request_completed.connect(_on_request_completed) add_child(request) AuthState.auth_changed.connect(_on_auth_changed) + call_deferred("_connect_account_profile_service") + + +func _connect_account_profile_service() -> void: + text_scale = _synced_text_scale() + AccountProfileService.profile_changed.connect(_on_account_profile_changed) func load_cases() -> void: _send("cases", HTTPClient.METHOD_GET, "/cases") -func create_attempt(case_external_id: String, mode: String) -> void: - _send("attempt", HTTPClient.METHOD_POST, "/attempts", {"caseExternalId": case_external_id, "mode": mode}) +func create_attempt(case_external_id: String, _mode: String = "timed") -> void: + _send("attempt", HTTPClient.METHOD_POST, "/attempts", {"caseExternalId": case_external_id, "mode": "timed"}) func load_active() -> void: @@ -70,6 +77,15 @@ func send_command(type: String, code: String = "", minutes: int = 0) -> void: "minutes": minutes }) +func send_batch(commands: Array) -> void: + if attempt.is_empty() or commands.is_empty(): + return + _send("attempt", HTTPClient.METHOD_POST, "/attempts/%s/commands/batch" % str(attempt.get("id", "")).uri_encode(), { + "commandId": "%d-%d" % [Time.get_ticks_usec(), randi()], + "expectedRevision": int(attempt.get("revision", 0)), + "commands": commands + }) + func search_orders(query: String) -> void: if query.strip_edges().length() < 2: @@ -100,6 +116,20 @@ func submit_report(attempt_id: String, category: String, details: String) -> voi func adjust_text_scale(delta: float) -> void: text_scale = clampf(text_scale + delta, TEXT_SCALE_MIN, TEXT_SCALE_MAX) + AccountProfileService.update_text_scale(null, text_scale) + + +func _synced_text_scale() -> float: + var saved: float = AccountProfileService.preferences.get("ccsTextScale", 1.0) + return clampf(saved, TEXT_SCALE_MIN, TEXT_SCALE_MAX) + + +func _on_account_profile_changed() -> void: + var synced := _synced_text_scale() + if is_equal_approx(synced, text_scale): + return + text_scale = synced + text_scale_changed.emit() func toggle_highlight(attempt_id: String, region_id: String, start: int, end: int) -> void: @@ -148,7 +178,20 @@ func abandon() -> void: func deadline_unix() -> float: - var value := str(attempt.get("finalOrdersDeadlineUtc", attempt.get("deadlineUtc", ""))) + var raw: Variant = attempt.get("finalOrdersDeadlineUtc", null) + if raw == null or str(raw).is_empty() or str(raw) == "": + raw = attempt.get("deadlineUtc", "") + var value := str(raw) + return 0.0 if value.is_empty() or value == "" else float(Time.get_unix_time_from_datetime_string(value)) + + +func hard_deadline_unix() -> float: + var raw: Variant = attempt.get("hardDeadlineUtc", null) + if raw == null or str(raw).is_empty() or str(raw) == "": + raw = attempt.get("finalOrdersDeadlineUtc", null) + if raw == null or str(raw).is_empty() or str(raw) == "": + raw = attempt.get("deadlineUtc", "") + var value := str(raw) return 0.0 if value.is_empty() or value == "" else float(Time.get_unix_time_from_datetime_string(value)) diff --git a/scripts/autoload/dynamic_question_service.gd b/scripts/autoload/dynamic_question_service.gd new file mode 100644 index 0000000..ccaa126 --- /dev/null +++ b/scripts/autoload/dynamic_question_service.gd @@ -0,0 +1,108 @@ +extends Node + +const EndpointConfig := preload("res://scripts/autoload/api_endpoint_config.gd") +const DEFAULT_API_URL := "https://us3q.ranaze.com/api/dynamic-questions" + +signal status_loaded(status: Dictionary) +signal question_loaded(question: Dictionary) +signal attempt_completed(result: Dictionary) +signal report_submitted(question_id: String, report_id: String) +signal report_failed(question_id: String, message: String) +signal request_failed(message: String) + +var api_url := DEFAULT_API_URL +var run_token := "" +var generation_token := "" +var current_envelope := {} +var _request: HTTPRequest +var _operation := "" +var _report_question_id := "" + + +func _ready() -> void: + api_url = EndpointConfig.resolve( + str(ProjectSettings.get_setting("step3/dynamic_questions_api_url", DEFAULT_API_URL)), + "/api/dynamic-questions") + _request = HTTPRequest.new() + _request.timeout = 30.0 + _request.request_completed.connect(_on_completed) + add_child(_request) + + +func load_status() -> void: + _send("status", HTTPClient.METHOD_GET, "/status", {}) + + +func load_next() -> void: + _send("next", HTTPClient.METHOD_POST, "/next", {"runToken": run_token}) + + +func submit_attempt(choice_id: String, duration_seconds: int, is_skip := false) -> void: + _send("attempt", HTTPClient.METHOD_POST, "/attempt", { + "generationToken": generation_token, + "choiceId": choice_id, + "isSkip": is_skip, + "durationSeconds": duration_seconds + }) + + +func submit_report(question_id: String, category: String, details: String) -> void: + _report_question_id = question_id + _send("report", HTTPClient.METHOD_POST, "/report", { + "generationToken": generation_token, + "category": category, + "details": details + }) + + +func reset_run() -> void: + run_token = "" + generation_token = "" + current_envelope = {} + + +func _send(operation: String, method: int, suffix: String, payload: Dictionary) -> void: + if not AuthState.can_send_authenticated_request(): + request_failed.emit("Log in to use Dynamic Questions.") + return + if _request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED: + return + _operation = operation + var headers := PackedStringArray([ + "Content-Type: application/json", + "Authorization: Bearer %s" % AuthState.access_token + ]) + var body := "" if method == HTTPClient.METHOD_GET else JSON.stringify(payload) + var error := _request.request(api_url + suffix, headers, method, body) + if error != OK: + _operation = "" + request_failed.emit("Could not connect to Dynamic Questions.") + + +func _on_completed(_result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void: + var operation := _operation + _operation = "" + var parsed: Variant = JSON.parse_string(body.get_string_from_utf8()) + if response_code < 200 or response_code >= 300 or typeof(parsed) != TYPE_DICTIONARY: + var message := str(parsed) if parsed != null else "Dynamic Questions request failed." + if operation == "report": + report_failed.emit(_report_question_id, message) + else: + request_failed.emit(message) + return + var data: Dictionary = parsed + match operation: + "status": + status_loaded.emit(data) + "next": + run_token = str(data.get("runToken", "")) + generation_token = str(data.get("generationToken", "")) + current_envelope = data + var question := QuestionBankService.normalize_api_question(data.get("question", {})) + question["_dynamic_objective_id"] = str(data.get("objectiveId", "")) + question["_dynamic_objective_title"] = str(data.get("objectiveTitle", "")) + question_loaded.emit(question) + "attempt": + attempt_completed.emit(data) + "report": + report_submitted.emit(_report_question_id, str(data.get("id", ""))) diff --git a/scripts/autoload/dynamic_question_service.gd.uid b/scripts/autoload/dynamic_question_service.gd.uid new file mode 100644 index 0000000..93be453 --- /dev/null +++ b/scripts/autoload/dynamic_question_service.gd.uid @@ -0,0 +1 @@ +uid://b8u2b8km7k6t diff --git a/scripts/autoload/flashcard_service.gd b/scripts/autoload/flashcard_service.gd new file mode 100644 index 0000000..4686199 --- /dev/null +++ b/scripts/autoload/flashcard_service.gd @@ -0,0 +1,537 @@ +extends Node + +# Flashcard autoload: study-session networking (next card, review submission) plus best-effort +# CRUD for decks/notes/settings used by the library screens. +# +# Review submission is the one path that must survive a dropped connection mid-session: graded +# reviews are queued to a durable per-account outbox (mirrors ReviewQueueService) and retried +# until the server confirms them. Everything else (deck/note CRUD, counts) is a plain +# best-effort request - if it fails, the caller shows an error and the user retries manually. + +signal decks_changed(decks: Array) +signal settings_changed(settings: Dictionary) +signal counts_changed(counts: Dictionary) +signal next_card_loaded(card: Dictionary) +signal next_card_failed(message: String) +signal review_synced(card_id: String, updated_card: Dictionary) +signal review_paused(message: String) +signal review_resumed +signal notes_loaded(response: Dictionary) +signal note_saved(note: Dictionary) +signal request_failed(tag: String, message: String) +signal export_ready(location: String) +signal import_preview_ready(preview: Dictionary) +signal import_committed(result: Dictionary) + +const EndpointConfig := preload("res://scripts/autoload/api_endpoint_config.gd") +const DEFAULT_API_URL := "https://us3q.ranaze.com/api/flashcards" +const OUTBOX_PATH := "user://pending_flashcard_reviews.json" + +var api_url := DEFAULT_API_URL +var next_request: HTTPRequest +var review_request: HTTPRequest +var misc_request: HTTPRequest +var export_request: HTTPRequest +var import_request: HTTPRequest +var retry_timer: Timer +var loading_next := false +var submitting_review := false +var misc_busy := false +var misc_queue: Array = [] +var outbox: Array = [] +var active_account_key := "" +var is_paused := false +var export_busy := false +var export_pending: Dictionary = {} +var import_busy := false + + +func _ready() -> void: + api_url = EndpointConfig.resolve( + str(ProjectSettings.get_setting("step3/flashcards_api_url", DEFAULT_API_URL)), + "/api/flashcards") + next_request = _make_request(_on_next_completed) + review_request = _make_request(_on_review_completed) + misc_request = _make_request(_on_misc_completed) + export_request = _make_request(_on_export_completed) + import_request = _make_request(_on_import_preview_completed) + retry_timer = Timer.new() + retry_timer.one_shot = true + retry_timer.wait_time = 5.0 + retry_timer.timeout.connect(_process_outbox) + add_child(retry_timer) + _load_outbox() + AuthState.auth_changed.connect(_on_auth_changed) + _on_auth_changed() + + +func _make_request(callback: Callable) -> HTTPRequest: + var request := HTTPRequest.new() + request.timeout = 25.0 + request.request_completed.connect(callback) + add_child(request) + return request + + +func _headers() -> PackedStringArray: + return PackedStringArray([ + "Content-Type: application/json", + "Authorization: Bearer %s" % AuthState.access_token + ]) + + +func current_study_day_start_utc() -> String: + # The local calendar day at session start, expressed as a UTC instant, per the spec's + # "daily limits are based on the client's local study day recorded at session start." + var local := Time.get_datetime_dict_from_system(false) + var utc_offset_minutes: int = Time.get_time_zone_from_system().get("bias", 0) + var local_midnight_unix: int = Time.get_unix_time_from_datetime_dict({ + "year": local.year, "month": local.month, "day": local.day, + "hour": 0, "minute": 0, "second": 0 + }) + var utc_unix: int = local_midnight_unix + utc_offset_minutes * 60 + return "%sZ" % Time.get_datetime_string_from_unix_time(utc_unix, false) + + +# ---------- Next card / study session ---------- + +func request_next_card(deck_id: String, exclude_ids: Array, study_day_start_utc: String, shuffle: bool = false) -> void: + if loading_next or not AuthState.can_send_authenticated_request(): + next_card_failed.emit("Login is required to study flashcards.") + return + loading_next = true + var payload := { + "deckId": deck_id, + "excludeCardIds": exclude_ids, + "studyDayStartUtc": study_day_start_utc, + "shuffle": shuffle + } + var error := next_request.request( + "%s/study/next" % api_url, _headers(), HTTPClient.METHOD_POST, JSON.stringify(payload)) + if error != OK: + loading_next = false + next_card_failed.emit("The next flashcard could not be loaded. Check your connection and try again.") + + +func _on_next_completed(result: int, response_code: int, _headers_value: PackedStringArray, body: PackedByteArray) -> void: + loading_next = false + if response_code == 401: + AuthState.handle_unauthorized(AuthState.access_token) + return + if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300: + next_card_failed.emit("The next flashcard 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: + next_card_failed.emit("The flashcard service returned an invalid response.") + return + next_card_loaded.emit(parsed) + + +# ---------- Review submission (offline-safe outbox) ---------- + +func submit_review(card_id: String, rating: String, expected_revision: int, duration_seconds: int, study_day_start_utc: String) -> String: + var client_review_id := _new_uuid() + var payload := { + "clientReviewId": client_review_id, + "cardId": card_id, + "rating": rating, + "expectedRevision": expected_revision, + "reviewedUtc": "%sZ" % Time.get_datetime_string_from_system(true), + "durationSeconds": clampi(duration_seconds, 0, 86400), + "studyDayStartUtc": study_day_start_utc + } + outbox.append({"account": _account_key(), "payload": payload, "retry_count": 0}) + _save_outbox() + _process_outbox() + return client_review_id + + +func has_pending_reviews() -> bool: + return _next_account_item_index() >= 0 + + +func _process_outbox() -> void: + if submitting_review or not AuthState.can_send_authenticated_request(): + return + var index := _next_account_item_index() + if index < 0: + if is_paused: + is_paused = false + review_resumed.emit() + return + var item: Dictionary = outbox[index] + item["active"] = true + outbox[index] = item + submitting_review = true + var error := review_request.request( + "%s/reviews" % api_url, _headers(), HTTPClient.METHOD_POST, + JSON.stringify({"reviews": [item.get("payload", {})]})) + if error != OK: + submitting_review = false + item.erase("active") + item["retry_count"] = int(item.get("retry_count", 0)) + 1 + outbox[index] = item + _save_outbox() + _mark_paused() + retry_timer.start() + + +func _on_review_completed(result: int, response_code: int, _headers_value: PackedStringArray, body: PackedByteArray) -> void: + submitting_review = false + var index := _active_item_index() + if index < 0: + return + var item: Dictionary = outbox[index] + item.erase("active") + var card_id := str(item.get("payload", {}).get("cardId", "")) + if response_code == 401: + outbox[index] = item + _save_outbox() + AuthState.handle_unauthorized(AuthState.access_token) + return + if result == HTTPRequest.RESULT_SUCCESS and response_code >= 200 and response_code < 300: + outbox.remove_at(index) + _save_outbox() + var parsed: Variant = JSON.parse_string(body.get_string_from_utf8()) + if typeof(parsed) == TYPE_DICTIONARY: + var results: Array = parsed.get("results", []) + if results.size() > 0: + review_synced.emit(card_id, (results[0] as Dictionary).get("card", {})) + _process_outbox() + return + if response_code == 409 or (response_code >= 400 and response_code < 500 and response_code not in [408, 429]): + # Stale revision or otherwise rejected: don't retry forever with bad state. + outbox.remove_at(index) + _save_outbox() + _process_outbox() + return + item["retry_count"] = int(item.get("retry_count", 0)) + 1 + outbox[index] = item + _save_outbox() + _mark_paused() + retry_timer.start() + + +func _mark_paused() -> void: + if not is_paused: + is_paused = true + review_paused.emit("Reconnecting to sync your reviews. Your progress is saved.") + + +# ---------- Best-effort JSON CRUD (decks, notes, settings, counts, undo, cards) ---------- + +func fetch_decks() -> void: + _enqueue_json("GET", "/decks", "", func(parsed): decks_changed.emit(parsed if typeof(parsed) == TYPE_ARRAY else []), "decks") + + +func fetch_settings() -> void: + _enqueue_json("GET", "/settings", "", func(parsed): settings_changed.emit(parsed if typeof(parsed) == TYPE_DICTIONARY else {}), "settings") + + +func update_settings(payload: Dictionary) -> void: + _enqueue_json("PUT", "/settings", JSON.stringify(payload), func(parsed): settings_changed.emit(parsed if typeof(parsed) == TYPE_DICTIONARY else {}), "settings") + + +func fetch_counts() -> void: + _enqueue_json("GET", "/counts", "", func(parsed): counts_changed.emit(parsed if typeof(parsed) == TYPE_DICTIONARY else {}), "counts") + + +func create_deck(deck_name: String, description: String) -> void: + _enqueue_json("POST", "/decks", JSON.stringify({"name": deck_name, "description": description}), + func(_parsed): fetch_decks(), "decks") + + +func archive_deck(deck_id: String, archived: bool) -> void: + var action := "archive" if archived else "restore" + _enqueue_json("POST", "/decks/%s/%s" % [deck_id, action], "", func(_parsed): fetch_decks(), "decks") + + +func search_notes(query: Dictionary) -> void: + _enqueue_json("POST", "/notes/search", JSON.stringify(query), + func(parsed): notes_loaded.emit(parsed if typeof(parsed) == TYPE_DICTIONARY else {"items": [], "total": 0}), "notes") + + +func save_note(note_id: String, payload: Dictionary) -> void: + var path := "/notes/%s" % note_id if not note_id.is_empty() else "/notes" + var method := "PUT" if not note_id.is_empty() else "POST" + _enqueue_json(method, path, JSON.stringify(payload), + func(parsed): note_saved.emit(parsed if typeof(parsed) == TYPE_DICTIONARY else {}), "note_save") + + +func move_notes(note_ids: Array, target_deck_id: String) -> void: + _enqueue_json("POST", "/notes/move", JSON.stringify({"noteIds": note_ids, "targetDeckId": target_deck_id}), + func(_parsed): pass, "notes") + + +func archive_notes(note_ids: Array, archived: bool) -> void: + var action := "archive" if archived else "restore" + _enqueue_json("POST", "/notes/%s" % action, JSON.stringify({"noteIds": note_ids}), func(_parsed): pass, "notes") + + +func suspend_cards(card_ids: Array, suspended: bool) -> void: + _enqueue_json("POST", "/cards/suspend", JSON.stringify({"cardIds": card_ids, "suspended": suspended}), func(_parsed): pass, "cards") + + +func bury_cards(card_ids: Array, until_utc: String) -> void: + _enqueue_json("POST", "/cards/bury", JSON.stringify({"cardIds": card_ids, "untilUtc": until_utc}), func(_parsed): pass, "cards") + + +func undo_last_review(review_log_id: String, on_done: Callable) -> void: + _enqueue_json("POST", "/reviews/undo", JSON.stringify({"reviewLogId": review_log_id}), + func(parsed): on_done.call(parsed if typeof(parsed) == TYPE_DICTIONARY else {}), "undo") + + +# ---------- Export (browser download / desktop file save) ---------- + +func export_csv() -> void: + _download_export("csv", "text/csv", "flashcards.csv") + + +func export_json() -> void: + _download_export("json", "application/json", "flashcards.json") + + +func _download_export(format: String, mime: String, filename: String) -> void: + if not AuthState.can_send_authenticated_request(): + request_failed.emit("export", "Login is required to export flashcards.") + return + if export_busy: + request_failed.emit("export", "An export is already in progress.") + return + export_busy = true + export_pending = {"mime": mime, "filename": filename} + var error := export_request.request("%s/export/%s" % [api_url, format], _headers(), HTTPClient.METHOD_GET) + if error != OK: + export_busy = false + export_pending = {} + request_failed.emit("export", "The export could not be started. Check your connection and try again.") + + +func _on_export_completed(result: int, response_code: int, _headers_value: PackedStringArray, body: PackedByteArray) -> void: + export_busy = false + var pending := export_pending + export_pending = {} + if response_code == 401: + AuthState.handle_unauthorized(AuthState.access_token) + return + if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300: + request_failed.emit("export", "The export failed. Check your connection and try again.") + return + _save_export_file(body.get_string_from_utf8(), str(pending.get("filename", "flashcards.txt")), str(pending.get("mime", "text/plain"))) + + +func _save_export_file(content: String, filename: String, mime: String) -> void: + if OS.has_feature("web"): + var encoded := Marshalls.raw_to_base64(content.to_utf8_buffer()) + JavaScriptBridge.eval( + "(function(){const a=document.createElement('a');a.href='data:%s;base64,%s';a.download=%s;document.body.appendChild(a);a.click();a.remove();})();" % [mime, encoded, JSON.stringify(filename)], + true) + export_ready.emit(filename) + return + var directory := OS.get_system_dir(OS.SYSTEM_DIR_DOWNLOADS) + var path := directory.path_join(filename) + var file := FileAccess.open(path, FileAccess.WRITE) + if file == null: + request_failed.emit("export", "The export could not be saved.") + return + file.store_string(content) + export_ready.emit(path) + + +# ---------- Import (file picker -> preview -> commit) ---------- + +func pick_import_file(format: String) -> void: + if not AuthState.can_send_authenticated_request(): + request_failed.emit("import", "Login is required to import flashcards.") + return + if import_busy: + request_failed.emit("import", "An import is already in progress.") + return + var accept := ".csv" if format == "csv" else ".json" + if OS.has_feature("web"): + _pick_file_web(accept, format) + else: + _pick_file_native(accept, format) + + +func _pick_file_web(accept: String, format: String) -> void: + var callback := JavaScriptBridge.create_callback(_on_web_file_selected.bind(format)) + JavaScriptBridge.eval( + ("(function(cb){" + + "var input=document.createElement('input');" + + "input.type='file';input.accept=%s;input.style.display='none';" + + "document.body.appendChild(input);" + + "input.addEventListener('change',function(){" + + "var file=input.files&&input.files[0];" + + "document.body.removeChild(input);" + + "if(!file){cb('','');return;}" + + "var reader=new FileReader();" + + "reader.onload=function(){cb(reader.result,file.name);};" + + "reader.onerror=function(){cb('','');};" + + "reader.readAsText(file);" + + "});" + + "input.click();" + + "})(%s);") % [JSON.stringify(accept), callback], + true) + + +func _on_web_file_selected(text: String, filename: String, format: String) -> void: + if text.is_empty(): + return + _upload_import_preview(text, filename, format) + + +func _pick_file_native(accept: String, format: String) -> void: + var dialog := FileDialog.new() + dialog.access = FileDialog.ACCESS_FILESYSTEM + dialog.file_mode = FileDialog.FILE_MODE_OPEN_FILE + dialog.filters = PackedStringArray(["*%s ; %s files" % [accept, format.to_upper()]]) + add_child(dialog) + dialog.file_selected.connect(func(path: String): + var text := FileAccess.get_file_as_string(path) + dialog.queue_free() + _upload_import_preview(text, path.get_file(), format)) + dialog.canceled.connect(func(): dialog.queue_free()) + dialog.popup_centered_ratio(0.6) + + +func _upload_import_preview(text: String, filename: String, format: String) -> void: + import_busy = true + var boundary := "----GodotFormBoundary%d" % Time.get_ticks_usec() + var content_type := "text/csv" if format == "csv" else "application/json" + var body := "--%s\r\nContent-Disposition: form-data; name=\"file\"; filename=\"%s\"\r\nContent-Type: %s\r\n\r\n%s\r\n--%s--\r\n" % [ + boundary, filename, content_type, text, boundary] + var headers := PackedStringArray([ + "Content-Type: multipart/form-data; boundary=%s" % boundary, + "Authorization: Bearer %s" % AuthState.access_token + ]) + var error := import_request.request( + "%s/import/%s/preview" % [api_url, format], headers, HTTPClient.METHOD_POST, body) + if error != OK: + import_busy = false + request_failed.emit("import", "The file could not be uploaded. Check your connection and try again.") + + +func _on_import_preview_completed(result: int, response_code: int, _headers_value: PackedStringArray, body: PackedByteArray) -> void: + import_busy = false + if response_code == 401: + AuthState.handle_unauthorized(AuthState.access_token) + return + if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300: + request_failed.emit("import", "The file could not be previewed. Check that it matches the expected format and try again.") + return + var parsed: Variant = JSON.parse_string(body.get_string_from_utf8()) + if typeof(parsed) != TYPE_DICTIONARY: + request_failed.emit("import", "The import preview response was invalid.") + return + import_preview_ready.emit(parsed) + + +func commit_import(import_token: String) -> void: + _enqueue_json("POST", "/import/commit", JSON.stringify({"importToken": import_token}), + func(parsed): import_committed.emit(parsed if typeof(parsed) == TYPE_DICTIONARY else {}), "import_commit") + + +func _enqueue_json(method: String, path: String, body: String, on_success: Callable, tag: String) -> void: + misc_queue.append({"method": method, "path": path, "body": body, "on_success": on_success, "tag": tag}) + _pump_misc_queue() + + +func _pump_misc_queue() -> void: + if misc_busy or misc_queue.is_empty() or not AuthState.can_send_authenticated_request(): + return + var job: Dictionary = misc_queue.pop_front() + misc_busy = true + var method_map := { + "GET": HTTPClient.METHOD_GET, "POST": HTTPClient.METHOD_POST, + "PUT": HTTPClient.METHOD_PUT, "DELETE": HTTPClient.METHOD_DELETE + } + var error := misc_request.request( + "%s%s" % [api_url, str(job.get("path", ""))], + _headers(), + method_map.get(job.get("method", "GET"), HTTPClient.METHOD_GET), + str(job.get("body", ""))) + if error != OK: + misc_busy = false + request_failed.emit(str(job.get("tag", "")), "Request could not be sent. Check your connection.") + _pump_misc_queue() + return + misc_request.set_meta("current_job", job) + + +func _on_misc_completed(result: int, response_code: int, _headers_value: PackedStringArray, body: PackedByteArray) -> void: + misc_busy = false + var job: Dictionary = misc_request.get_meta("current_job", {}) + var tag := str(job.get("tag", "")) + if response_code == 401: + AuthState.handle_unauthorized(AuthState.access_token) + _pump_misc_queue() + return + if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300: + request_failed.emit(tag, "That request failed. Check your connection and try again.") + _pump_misc_queue() + return + var parsed: Variant = JSON.parse_string(body.get_string_from_utf8()) + var on_success: Callable = job.get("on_success", Callable()) + if on_success.is_valid(): + on_success.call(parsed) + _pump_misc_queue() + + +# ---------- Account lifecycle / outbox persistence ---------- + +func _on_auth_changed() -> void: + active_account_key = _account_key() + if AuthState.is_logged_in: + _process_outbox() + elif retry_timer != null: + retry_timer.stop() + + +func _account_key() -> String: + return AuthState.username.strip_edges().to_lower().sha256_text() if not AuthState.username.strip_edges().is_empty() else "" + + +func _next_account_item_index() -> int: + for index in range(outbox.size()): + if str(outbox[index].get("account", "")) == active_account_key: + return index + return -1 + + +func _active_item_index() -> int: + for index in range(outbox.size()): + if bool(outbox[index].get("active", false)): + return index + return -1 + + +func _load_outbox() -> void: + outbox = [] + if not FileAccess.file_exists(OUTBOX_PATH): + return + var parsed: Variant = JSON.parse_string(FileAccess.get_file_as_string(OUTBOX_PATH)) + if typeof(parsed) != TYPE_ARRAY: + return + for item in parsed: + if typeof(item) == TYPE_DICTIONARY and typeof(item.get("payload", {})) == TYPE_DICTIONARY: + item.erase("active") + outbox.append(item) + + +func _save_outbox() -> void: + var stored := outbox.duplicate(true) + for item in stored: + item.erase("active") + var file := FileAccess.open(OUTBOX_PATH, FileAccess.WRITE) + if file != null: + file.store_string(JSON.stringify(stored)) + + +func _new_uuid() -> String: + var bytes := Crypto.new().generate_random_bytes(16) + bytes[6] = (bytes[6] & 0x0f) | 0x40 + bytes[8] = (bytes[8] & 0x3f) | 0x80 + var value := bytes.hex_encode() + return "%s-%s-%s-%s-%s" % [value.substr(0, 8), value.substr(8, 4), value.substr(12, 4), value.substr(16, 4), value.substr(20, 12)] diff --git a/scripts/autoload/flashcard_service.gd.uid b/scripts/autoload/flashcard_service.gd.uid new file mode 100644 index 0000000..82e5b60 --- /dev/null +++ b/scripts/autoload/flashcard_service.gd.uid @@ -0,0 +1 @@ +uid://46eks1fy86s4 diff --git a/scripts/autoload/ui_sound_service.gd b/scripts/autoload/ui_sound_service.gd index 49dca3c..21b2ce9 100644 --- a/scripts/autoload/ui_sound_service.gd +++ b/scripts/autoload/ui_sound_service.gd @@ -88,7 +88,8 @@ func _next_available_player() -> AudioStreamPlayer: func _on_node_added(node: Node) -> void: - call_deferred("_wire_node", node) + if node is Button: + _wire_button(node) func _wire_existing_buttons() -> void: diff --git a/scripts/ui/calculator_panel.gd b/scripts/ui/calculator_panel.gd new file mode 100644 index 0000000..ece38f6 --- /dev/null +++ b/scripts/ui/calculator_panel.gd @@ -0,0 +1,448 @@ +extends PanelContainer + +const Style := preload("res://scripts/ui/ui_style.gd") +const Brand := preload("res://scripts/ui/medmind_style.gd") +const MAX_ENTRY_LENGTH := 16 + +var display_label: Label +var operation_label: Label +var keypad_buttons: Array[Button] = [] +var entry := "0" +var accumulator := 0.0 +var pending_operator := "" +var waiting_for_operand := true +var last_operator := "" +var last_operand := 0.0 +var has_last_operation := false +var error_state := false +var drag_active := false +var drag_offset := Vector2.ZERO +var has_been_positioned := false + + +func _ready() -> void: + name = "CalculatorPanel" + visible = false + top_level = true + mouse_filter = Control.MOUSE_FILTER_STOP + custom_minimum_size = Vector2(310, 390) + size = custom_minimum_size + _build_ui() + if not get_viewport().size_changed.is_connected(_on_viewport_size_changed): + get_viewport().size_changed.connect(_on_viewport_size_changed) + _on_viewport_size_changed() + refresh_theme_controls() + _refresh_display() + + +func open_panel() -> void: + visible = true + move_to_front() + if not has_been_positioned: + var viewport_size := get_viewport_rect().size + position = Vector2(maxf(12.0, viewport_size.x - size.x - 24.0), 72.0) + has_been_positioned = true + _clamp_to_viewport() + + +func close_panel() -> void: + visible = false + drag_active = false + + +func toggle_panel() -> void: + if visible: + close_panel() + else: + open_panel() + + +func reset_calculation() -> void: + entry = "0" + accumulator = 0.0 + pending_operator = "" + waiting_for_operand = true + last_operator = "" + last_operand = 0.0 + has_last_operation = false + error_state = false + _refresh_display() + + +func press_token(token: String) -> void: + if token.length() == 1 and token.is_valid_int(): + _enter_digit(token) + return + match token: + ".": + _enter_decimal() + "+", "−", "×", "÷": + _set_operator(token) + "=": + _equals() + "%": + _apply_unary("percent") + "√": + _apply_unary("sqrt") + "±": + _apply_unary("sign") + "⌫": + _backspace() + "AC": + reset_calculation() + + +func get_display_text() -> String: + return entry + + +func refresh_theme_controls() -> void: + if display_label == null: + return + var dark := UIThemeService.is_dark_mode() + add_theme_stylebox_override("panel", Brand.content_card_box(dark, true, Brand.ROYAL_BLUE)) + display_label.add_theme_color_override("font_color", Brand.text_color(dark)) + operation_label.add_theme_color_override("font_color", Brand.muted_color(dark)) + for button in keypad_buttons: + var accent := Brand.ROYAL_BLUE if str(button.get_meta("calculator_token", "")) in ["+", "−", "×", "÷", "="] else Brand.SLATE + button.add_theme_stylebox_override("normal", Brand.badge_box(dark, accent)) + button.add_theme_stylebox_override("hover", Brand.badge_box(dark, Brand.TEAL)) + button.add_theme_stylebox_override("pressed", Brand.badge_box(dark, Brand.PURPLE)) + button.add_theme_color_override("font_color", Brand.text_color(dark)) + + +func _build_ui() -> void: + var margin := MarginContainer.new() + margin.add_theme_constant_override("margin_left", 10) + margin.add_theme_constant_override("margin_right", 10) + margin.add_theme_constant_override("margin_top", 12) + margin.add_theme_constant_override("margin_bottom", 14) + add_child(margin) + + var content := VBoxContainer.new() + content.add_theme_constant_override("separation", 10) + margin.add_child(content) + + var title_bar := HBoxContainer.new() + title_bar.name = "CalculatorTitleBar" + title_bar.mouse_filter = Control.MOUSE_FILTER_STOP + title_bar.custom_minimum_size.y = 34 + title_bar.gui_input.connect(_on_title_bar_gui_input) + content.add_child(title_bar) + + var title := Label.new() + title.text = "Calculator" + title.size_flags_horizontal = Control.SIZE_EXPAND_FILL + title.mouse_filter = Control.MOUSE_FILTER_IGNORE + Brand.apply_font(title) + title.add_theme_font_size_override("font_size", 18) + title_bar.add_child(title) + + var close := Button.new() + close.name = "CalculatorClose" + close.text = "×" + close.tooltip_text = "Close calculator" + close.custom_minimum_size = Vector2(36, 32) + close.focus_mode = Control.FOCUS_NONE + close.pressed.connect(close_panel) + title_bar.add_child(close) + + var display_panel := PanelContainer.new() + display_panel.custom_minimum_size.y = 78 + display_panel.add_theme_stylebox_override("panel", Style.panel_box(Color("101b31"), Style.CYAN)) + content.add_child(display_panel) + + var display_margin := MarginContainer.new() + display_margin.add_theme_constant_override("margin_left", 12) + display_margin.add_theme_constant_override("margin_right", 12) + display_margin.add_theme_constant_override("margin_top", 8) + display_margin.add_theme_constant_override("margin_bottom", 8) + display_panel.add_child(display_margin) + + var display_stack := VBoxContainer.new() + display_margin.add_child(display_stack) + operation_label = Label.new() + operation_label.name = "CalculatorOperation" + operation_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT + operation_label.add_theme_font_size_override("font_size", 13) + Brand.apply_font(operation_label) + display_stack.add_child(operation_label) + display_label = Label.new() + display_label.name = "CalculatorDisplay" + display_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT + display_label.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS + display_label.add_theme_font_size_override("font_size", 28) + Brand.apply_font(display_label) + display_stack.add_child(display_label) + + var keypad := GridContainer.new() + keypad.name = "CalculatorKeypad" + keypad.columns = 4 + keypad.add_theme_constant_override("h_separation", 6) + keypad.add_theme_constant_override("v_separation", 8) + content.add_child(keypad) + + var tokens := [ + "AC", "⌫", "±", "√", + "7", "8", "9", "÷", + "4", "5", "6", "×", + "1", "2", "3", "−", + "%", "0", ".", "+", + "", "", "", "=" + ] + for token in tokens: + var button := Button.new() + button.custom_minimum_size = Vector2(44, 45) + button.size_flags_horizontal = Control.SIZE_EXPAND_FILL + button.focus_mode = Control.FOCUS_NONE + if token == "": + button.visible = false + keypad.add_child(button) + continue + button.text = token + button.set_meta("calculator_token", token) + button.tooltip_text = _token_tooltip(token) + button.add_theme_font_size_override("font_size", 18) + Brand.apply_font(button) + UISoundService.set_button_sound(button, "toggle") + button.pressed.connect(press_token.bind(token)) + keypad.add_child(button) + keypad_buttons.append(button) + + +func _input(event: InputEvent) -> void: + if not visible or not (event is InputEventKey): + return + var key_event := event as InputEventKey + if not key_event.pressed or key_event.echo: + return + var token := "" + if key_event.keycode == KEY_ESCAPE: + close_panel() + get_viewport().set_input_as_handled() + return + if key_event.keycode in [KEY_ENTER, KEY_KP_ENTER]: + token = "=" + elif key_event.keycode == KEY_BACKSPACE: + token = "⌫" + elif key_event.keycode == KEY_DELETE: + token = "AC" + elif key_event.unicode > 0: + var character := String.chr(key_event.unicode) + match character: + "0", "1", "2", "3", "4", "5", "6", "7", "8", "9", ".", "%", "=": + token = character + "+": token = "+" + "-": token = "−" + "*", "x", "X": token = "×" + "/": token = "÷" + "c", "C": token = "AC" + if token == "": + return + press_token(token) + get_viewport().set_input_as_handled() + + +func _enter_digit(digit: String) -> void: + if error_state: + reset_calculation() + if waiting_for_operand: + entry = digit + waiting_for_operand = false + elif entry == "0": + entry = digit + elif entry.length() < MAX_ENTRY_LENGTH: + entry += digit + _refresh_display() + + +func _enter_decimal() -> void: + if error_state: + reset_calculation() + if waiting_for_operand: + entry = "0." + waiting_for_operand = false + elif not "." in entry and entry.length() < MAX_ENTRY_LENGTH: + entry += "." + _refresh_display() + + +func _set_operator(operator: String) -> void: + if error_state: + return + var current := _entry_value() + if pending_operator != "" and not waiting_for_operand: + if not _perform_operation(pending_operator, current): + return + else: + accumulator = current + pending_operator = operator + waiting_for_operand = true + has_last_operation = false + _refresh_display() + + +func _equals() -> void: + if error_state: + return + if pending_operator != "": + var operand := accumulator if waiting_for_operand else _entry_value() + var operator := pending_operator + if not _perform_operation(operator, operand): + return + last_operator = operator + last_operand = operand + has_last_operation = true + pending_operator = "" + elif has_last_operation: + if not _perform_operation(last_operator, last_operand): + return + else: + return + waiting_for_operand = true + _refresh_display() + + +func _apply_unary(operation: String) -> void: + if error_state: + return + var value := _entry_value() + match operation: + "percent": + value /= 100.0 + "sqrt": + if value < 0.0: + _set_error() + return + value = sqrt(value) + "sign": + value = -value + entry = _format_number(value) + if pending_operator == "": + accumulator = value + waiting_for_operand = false + _refresh_display() + + +func _backspace() -> void: + if error_state: + reset_calculation() + return + if waiting_for_operand: + return + entry = entry.left(entry.length() - 1) + if entry in ["", "-"]: + entry = "0" + waiting_for_operand = true + _refresh_display() + + +func _perform_operation(operator: String, operand: float) -> bool: + var result := accumulator + match operator: + "+": result += operand + "−": result -= operand + "×": result *= operand + "÷": + if is_zero_approx(operand): + _set_error() + return false + result /= operand + if is_nan(result) or is_inf(result): + _set_error() + return false + accumulator = result + entry = _format_number(result) + return true + + +func _entry_value() -> float: + return entry.to_float() + + +func _format_number(value: float) -> String: + if is_zero_approx(value): + return "0" + var formatted := String.num(value, 12) + if "." in formatted: + while formatted.ends_with("0"): + formatted = formatted.left(formatted.length() - 1) + if formatted.ends_with("."): + formatted = formatted.left(formatted.length() - 1) + if formatted.length() > MAX_ENTRY_LENGTH: + formatted = String.num_scientific(value).replace("e+", "e") + return formatted + + +func _set_error() -> void: + entry = "Error" + error_state = true + pending_operator = "" + waiting_for_operand = true + has_last_operation = false + _refresh_display() + + +func _refresh_display() -> void: + if display_label == null: + return + display_label.text = entry + operation_label.text = ("%s %s" % [_format_number(accumulator), pending_operator]) if pending_operator != "" else "" + + +func _on_title_bar_gui_input(event: InputEvent) -> void: + var touch_event := event as InputEventScreenTouch + if touch_event != null: + drag_active = touch_event.pressed + if drag_active: + drag_offset = touch_event.position + get_viewport().set_input_as_handled() + return + var touch_drag := event as InputEventScreenDrag + if touch_drag != null and drag_active: + global_position += touch_drag.relative + _clamp_to_viewport() + get_viewport().set_input_as_handled() + return + var mouse_event := event as InputEventMouseButton + if mouse_event != null and mouse_event.button_index == MOUSE_BUTTON_LEFT: + drag_active = mouse_event.pressed + if drag_active: + drag_offset = mouse_event.global_position - global_position + get_viewport().set_input_as_handled() + return + var motion := event as InputEventMouseMotion + if motion != null and drag_active: + global_position = motion.global_position - drag_offset + _clamp_to_viewport() + get_viewport().set_input_as_handled() + + +func _on_viewport_size_changed() -> void: + var viewport_size := get_viewport_rect().size + custom_minimum_size.x = minf(310.0, maxf(216.0, viewport_size.x - 24.0)) + size.x = custom_minimum_size.x + _clamp_to_viewport() + + +func _clamp_to_viewport() -> void: + if not is_inside_tree(): + return + var viewport_size := get_viewport_rect().size + position.x = clampf(position.x, 12.0, maxf(12.0, viewport_size.x - size.x - 12.0)) + position.y = clampf(position.y, 12.0, maxf(12.0, viewport_size.y - size.y - 12.0)) + + +func _token_tooltip(token: String) -> String: + return { + "AC": "Clear calculation", + "⌫": "Backspace", + "±": "Change sign", + "√": "Square root", + "%": "Convert to percent", + "÷": "Divide", + "×": "Multiply", + "−": "Subtract", + "+": "Add", + "=": "Equals" + }.get(token, token) diff --git a/scripts/ui/calculator_panel.gd.uid b/scripts/ui/calculator_panel.gd.uid new file mode 100644 index 0000000..cb25d08 --- /dev/null +++ b/scripts/ui/calculator_panel.gd.uid @@ -0,0 +1 @@ +uid://dssyep5a2sime diff --git a/scripts/ui/ccs_hub.gd b/scripts/ui/ccs_hub.gd index e791a04..7a540a2 100644 --- a/scripts/ui/ccs_hub.gd +++ b/scripts/ui/ccs_hub.gd @@ -398,7 +398,7 @@ func _case_cell(item: Dictionary, dark: bool) -> Control: title.add_theme_color_override("font_color", Brand.text_color(dark)) column.add_child(title) var mode := Label.new() - mode.text = "Timed case" + mode.text = "Legacy practice case" if str(item.get("mode", "timed")) == "practice" else "Timed case" _style_muted_label(mode, dark, 12) column.add_child(mode) return column diff --git a/scripts/ui/ccs_player.gd b/scripts/ui/ccs_player.gd index b272363..7855bff 100644 --- a/scripts/ui/ccs_player.gd +++ b/scripts/ui/ccs_player.gd @@ -1,63 +1,105 @@ extends VBoxContainer -const Style := preload("res://scripts/ui/ui_style.gd") const Brand := preload("res://scripts/ui/medmind_style.gd") -const ICON_ACTIVITY := preload("res://assets/icons/lucide/activity.svg") -const ICON_HEART := preload("res://assets/icons/lucide/heart-pulse.svg") -const ICON_LUNGS := preload("res://assets/icons/lucide/lungs.svg") -const ICON_SEARCH := preload("res://assets/icons/lucide/search.svg") const ICON_STETHOSCOPE := preload("res://assets/icons/lucide/stethoscope.svg") -const ICON_THERMOMETER := preload("res://assets/icons/lucide/thermometer.svg") +const ICON_CLIPBOARD := preload("res://assets/icons/lucide/clipboard-plus.svg") +const ICON_CLOCK := preload("res://assets/icons/lucide/clock-3.svg") +const ICON_LOCATION := preload("res://assets/icons/lucide/house.svg") +const ICON_SEARCH := preload("res://assets/icons/lucide/search.svg") const RESPONSIVE_BREAKPOINT := 1050.0 +const CHART_TABS := [ + ["Order Sheet", "orders"], + ["Progress Notes", "progress"], + ["Vital Signs", "vitals"], + ["Lab Reports", "lab"], + ["Imaging", "imaging"], + ["Other Tests", "other_test"], + ["Treatment Record", "treatment"] +] var main: Control var header_panel: PanelContainer var header_layout: BoxContainer -var header_icon_panel: PanelContainer var heading: Label +var allotted_label: Label var toolbar: HFlowContainer -var workspace: BoxContainer +var theme_button: Button +var exit_button: Button +var abandon_button: Button +var action_grid: GridContainer +var action_buttons: Array[Button] = [] +var workspace: PanelContainer var chart_panel: PanelContainer var clerk_panel: PanelContainer -var summary: RichTextLabel -var history: RichTextLabel -var appearance: RichTextLabel -var chart: RichTextLabel -var vitals_flow: HFlowContainer -var orders: VBoxContainer +var tabs: TabContainer +var chart_views := {} +var order_sheet: VBoxContainer +var active_orders: VBoxContainer var search: LineEdit var search_button: Button -var search_results_panel: PanelContainer var search_results: VBoxContainer -var actions: VBoxContainer -var locations: HFlowContainer -var time_row: GridContainer -var status: Label +var staged_orders: VBoxContainer +var commit_orders_button: Button +var clear_orders_button: Button var final_orders_notice: Label -var clock: Label +var simulated_clock: Label +var elapsed_clock: Label +var remaining_clock: Label +var status: Label var timer: Timer -var end_button: Button var report_dialog: Control -var report_button: Button -var app_feedback_button: Button -var leave_button: Button + +var intro_dialog: AcceptDialog +var intro_title: Label +var intro_body: RichTextLabel +var opening_step := 0 +var opening_active := false + +var exam_dialog: ConfirmationDialog +var interval_history_check: CheckBox +var exam_checks := {} +var exam_summary_dialog: ConfirmationDialog +var exam_summary: RichTextLabel +var staged_exam_codes: Array[String] = [] +var staged_interval_history := false +var awaiting_exam_result := false +var findings_dialog: AcceptDialog +var findings_body: RichTextLabel + +var time_dialog: ConfirmationDialog +var time_preset: OptionButton +var custom_target: LineEdit +var time_preview: Label +var location_dialog: ConfirmationDialog +var location_option: OptionButton +var end_dialog: ConfirmationDialog +var final_instructions: AcceptDialog +var abandon_dialog: ConfirmationDialog + +var staged_order_codes: Array[String] = [] +var staged_order_names := {} +var pending_findings_before := 0 var leaving := false var theme_service -var theme_button: Button -var section_labels: Array[Label] = [] var toolbar_buttons: Array[Button] = [] +var clinical_labels: Array[RichTextLabel] = [] func _ready() -> void: theme_service = get_node("/root/UIThemeService") set_anchors_preset(Control.PRESET_FULL_RECT) - add_theme_constant_override("separation", 10) + add_theme_constant_override("separation", 8) _build_header() + _build_actions() _build_workspace() + _build_footer() + _build_dialogs() _build_runtime_services() resized.connect(_update_responsive_layout) if not theme_service.theme_changed.is_connected(_on_theme_changed): theme_service.theme_changed.connect(_on_theme_changed) + if not CCSService.text_scale_changed.is_connected(_apply_text_scale): + CCSService.text_scale_changed.connect(_apply_text_scale) _apply_theme() call_deferred("_update_responsive_layout") @@ -66,252 +108,308 @@ func _build_header() -> void: header_panel = PanelContainer.new() add_child(header_panel) var margin := MarginContainer.new() - margin.add_theme_constant_override("margin_left", 12) - margin.add_theme_constant_override("margin_right", 12) + for side in ["left", "right"]: + margin.add_theme_constant_override("margin_%s" % side, 14) margin.add_theme_constant_override("margin_top", 8) margin.add_theme_constant_override("margin_bottom", 8) header_panel.add_child(margin) header_layout = BoxContainer.new() - header_layout.vertical = false header_layout.add_theme_constant_override("separation", 12) margin.add_child(header_layout) - - var title_row := HBoxContainer.new() - title_row.size_flags_horizontal = Control.SIZE_EXPAND_FILL - title_row.add_theme_constant_override("separation", 10) - header_layout.add_child(title_row) - header_icon_panel = PanelContainer.new() - header_icon_panel.custom_minimum_size = Vector2(38, 38) - title_row.add_child(header_icon_panel) - var header_icon := TextureRect.new() - header_icon.texture = ICON_STETHOSCOPE - header_icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE - header_icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED - header_icon.custom_minimum_size = Vector2(22, 22) - header_icon.modulate = Brand.SKY_BLUE - header_icon_panel.add_child(header_icon) heading = Label.new() heading.text = "CCS Case" heading.size_flags_horizontal = Control.SIZE_EXPAND_FILL - _set_label(heading, 18, Brand.text_color(true)) - title_row.add_child(heading) - + heading.add_theme_font_size_override("font_size", 18) + Brand.apply_font(heading) + header_layout.add_child(heading) + allotted_label = Label.new() + allotted_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + allotted_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL + allotted_label.add_theme_font_size_override("font_size", 17) + Brand.apply_font(allotted_label) + header_layout.add_child(allotted_label) toolbar = HFlowContainer.new() toolbar.size_flags_horizontal = Control.SIZE_SHRINK_END - toolbar.custom_minimum_size.x = 440 - toolbar.add_theme_constant_override("h_separation", 8) - toolbar.add_theme_constant_override("v_separation", 8) + toolbar.add_theme_constant_override("h_separation", 6) header_layout.add_child(toolbar) - var smaller := _toolbar_button("A-", "Decrease case text size") + var smaller := _toolbar_button("A−", "Decrease clinical text size") smaller.pressed.connect(_zoom.bind(-CCSService.TEXT_SCALE_STEP)) - var larger := _toolbar_button("A+", "Increase case text size") + var larger := _toolbar_button("A+", "Increase clinical text size") larger.pressed.connect(_zoom.bind(CCSService.TEXT_SCALE_STEP)) - var clear_highlights := _toolbar_button("Clear", "Clear case highlights") - clear_highlights.pressed.connect(_clear_highlights) - report_button = _toolbar_button("Report", "Report this CCS case") - report_button.pressed.connect(_report) - app_feedback_button = _toolbar_button("Feedback", "Send general feedback or report a bug") - app_feedback_button.pressed.connect(func(): main.open_feedback_dialog()) + var report := _toolbar_button("Report", "Report this CCS case") + report.pressed.connect(_report) theme_button = _toolbar_button("", "Switch color theme") - theme_button.custom_minimum_size = Vector2(44, 38) - UISoundService.set_button_sound(theme_button, "toggle") + theme_button.custom_minimum_size.x = 42 theme_button.pressed.connect(theme_service.toggle_theme) + exit_button = _toolbar_button("Exit Case", "Enter final orders") + exit_button.pressed.connect(_request_end_case) + abandon_button = _toolbar_button("Abandon", "Abandon this attempt without a score") + abandon_button.pressed.connect(func(): abandon_dialog.popup_centered(Vector2i(520, 220))) func _toolbar_button(text_value: String, tooltip: String) -> Button: var button := Button.new() button.text = text_value button.tooltip_text = tooltip - button.custom_minimum_size = Vector2(54, 38) - button.add_theme_font_override("font", Brand.font) - button.add_theme_font_size_override("font_size", 14) + button.custom_minimum_size = Vector2(58, 38) toolbar.add_child(button) toolbar_buttons.append(button) return button +func _build_actions() -> void: + action_grid = GridContainer.new() + action_grid.columns = 4 + action_grid.add_theme_constant_override("h_separation", 8) + action_grid.add_theme_constant_override("v_separation", 8) + add_child(action_grid) + _add_action("History & Physical", "Interval history and physical exam", ICON_STETHOSCOPE, _open_exam) + _add_action("Write Orders / Review Chart", "Place orders and review the patient chart", ICON_CLIPBOARD, _open_order_sheet) + _add_action("Obtain Results / See Patient Later", "Advance simulated case time", ICON_CLOCK, _open_time_dialog) + _add_action("Change Location", "Move the patient to another care setting", ICON_LOCATION, _open_location_dialog) + + +func _add_action(title: String, subtitle: String, icon: Texture2D, callback: Callable) -> void: + var button := Button.new() + button.text = "%s\n%s" % [title, subtitle] + button.icon = icon + button.expand_icon = true + button.add_theme_constant_override("icon_max_width", 38) + button.alignment = HORIZONTAL_ALIGNMENT_LEFT + button.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + button.custom_minimum_size = Vector2(220, 86) + button.size_flags_horizontal = Control.SIZE_EXPAND_FILL + button.pressed.connect(callback) + action_grid.add_child(button) + action_buttons.append(button) + + func _build_workspace() -> void: - var page_scroll := ScrollContainer.new() - page_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL - page_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED - add_child(page_scroll) - workspace = BoxContainer.new() - workspace.size_flags_horizontal = Control.SIZE_EXPAND_FILL + workspace = PanelContainer.new() workspace.size_flags_vertical = Control.SIZE_EXPAND_FILL - workspace.add_theme_constant_override("separation", 10) - page_scroll.add_child(workspace) - _build_chart_panel() - _build_order_clerk() - - -func _build_chart_panel() -> void: - chart_panel = PanelContainer.new() - chart_panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL - chart_panel.size_flags_vertical = Control.SIZE_EXPAND_FILL - workspace.add_child(chart_panel) - var margin := MarginContainer.new() - margin.add_theme_constant_override("margin_left", 18) - margin.add_theme_constant_override("margin_right", 18) - margin.add_theme_constant_override("margin_top", 16) - margin.add_theme_constant_override("margin_bottom", 18) - chart_panel.add_child(margin) - var content := VBoxContainer.new() - content.add_theme_constant_override("separation", 14) - margin.add_child(content) - - summary = _clinical_text("summary", 17) - content.add_child(summary) - vitals_flow = HFlowContainer.new() - vitals_flow.add_theme_constant_override("h_separation", 10) - vitals_flow.add_theme_constant_override("v_separation", 8) - content.add_child(vitals_flow) - content.add_child(HSeparator.new()) - _add_chart_section(content, "History", "history") - history = _clinical_text("history", 16) - content.add_child(history) - _add_chart_section(content, "Appearance", "appearance") - appearance = _clinical_text("appearance", 16) - content.add_child(appearance) - _add_chart_section(content, "Chart", "chart") - chart = _clinical_text("chart", 16) - content.add_child(chart) - - -func _add_chart_section(parent: VBoxContainer, text_value: String, region: String) -> void: - var label := Label.new() - label.name = "%sHeading" % region.capitalize() - label.text = text_value - _set_label(label, 15, Brand.PURPLE) - parent.add_child(label) - section_labels.append(label) - - -func _clinical_text(region: String, font_size: int) -> RichTextLabel: - var label := RichTextLabel.new() - label.bbcode_enabled = true - label.selection_enabled = true - label.fit_content = true - label.scroll_active = false - label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART - label.add_theme_font_override("normal_font", Brand.font) - label.add_theme_font_override("bold_font", Brand.font) - label.add_theme_font_size_override("normal_font_size", font_size) - label.add_theme_font_size_override("bold_font_size", font_size) - label.set_meta("ccs_base_font_size", font_size) - label.set_meta("ccs_region", region) - label.gui_input.connect(_text_input.bind(label)) - return label - - -func _build_order_clerk() -> void: - clerk_panel = PanelContainer.new() - clerk_panel.custom_minimum_size = Vector2(370, 0) - clerk_panel.size_flags_vertical = Control.SIZE_EXPAND_FILL - workspace.add_child(clerk_panel) + add_child(workspace) + chart_panel = workspace + clerk_panel = workspace var margin := MarginContainer.new() margin.add_theme_constant_override("margin_left", 14) margin.add_theme_constant_override("margin_right", 14) - margin.add_theme_constant_override("margin_top", 12) - margin.add_theme_constant_override("margin_bottom", 14) - clerk_panel.add_child(margin) - var clerk := VBoxContainer.new() - clerk.add_theme_constant_override("separation", 8) - margin.add_child(clerk) - var order_title := Label.new() - order_title.text = "Order Clerk" - _set_label(order_title, 18, Brand.PURPLE) - clerk.add_child(order_title) - section_labels.append(order_title) - var search_help := Label.new() - search_help.text = "Search tests, treatments, and procedures" - _set_label(search_help, 12, Brand.muted_color(true)) - search_help.set_meta("ccs_muted_label", true) - clerk.add_child(search_help) + margin.add_theme_constant_override("margin_top", 10) + margin.add_theme_constant_override("margin_bottom", 12) + workspace.add_child(margin) + tabs = TabContainer.new() + tabs.size_flags_vertical = Control.SIZE_EXPAND_FILL + tabs.tab_changed.connect(func(_index: int): _render_chart()) + margin.add_child(tabs) + for definition in CHART_TABS: + var page := VBoxContainer.new() + page.name = str(definition[0]) + page.add_theme_constant_override("separation", 8) + tabs.add_child(page) + if str(definition[1]) == "orders": + _build_order_sheet(page) + else: + var scroll := ScrollContainer.new() + scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL + page.add_child(scroll) + var label := _clinical_text("chart_%s" % definition[1]) + label.size_flags_horizontal = Control.SIZE_EXPAND_FILL + scroll.add_child(label) + chart_views[definition[1]] = label + +func _build_order_sheet(page: VBoxContainer) -> void: + order_sheet = page + final_orders_notice = Label.new() + final_orders_notice.visible = false + final_orders_notice.text = "FINAL ORDERS — Add or cancel orders for the patient’s current condition, then submit." + final_orders_notice.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + page.add_child(final_orders_notice) var search_row := HBoxContainer.new() search_row.add_theme_constant_override("separation", 6) - clerk.add_child(search_row) + page.add_child(search_row) search = LineEdit.new() - search.placeholder_text = "Search..." + search.placeholder_text = "Search tests, treatments, medications, and procedures" search.size_flags_horizontal = Control.SIZE_EXPAND_FILL search.text_submitted.connect(func(_value: String): _submit_search()) search_row.add_child(search) search_button = Button.new() search_button.icon = ICON_SEARCH - search_button.tooltip_text = "Search order catalog" - search_button.custom_minimum_size = Vector2(42, 42) + search_button.tooltip_text = "Search the order catalog" + search_button.custom_minimum_size = Vector2(44, 42) search_button.pressed.connect(_submit_search) search_row.add_child(search_button) - - search_results_panel = PanelContainer.new() - search_results_panel.visible = false - clerk.add_child(search_results_panel) - var results_scroll := ScrollContainer.new() - results_scroll.custom_minimum_size.y = 150 - search_results_panel.add_child(results_scroll) search_results = VBoxContainer.new() - search_results.size_flags_horizontal = Control.SIZE_EXPAND_FILL - search_results.add_theme_constant_override("separation", 5) - results_scroll.add_child(search_results) + search_results.add_theme_constant_override("separation", 4) + page.add_child(search_results) + var staged_title := Label.new() + staged_title.text = "Orders awaiting confirmation" + Brand.apply_font(staged_title) + page.add_child(staged_title) + staged_orders = VBoxContainer.new() + page.add_child(staged_orders) + var staged_actions := HBoxContainer.new() + page.add_child(staged_actions) + commit_orders_button = Button.new() + commit_orders_button.text = "Confirm Orders" + commit_orders_button.pressed.connect(_commit_orders) + staged_actions.add_child(commit_orders_button) + clear_orders_button = Button.new() + clear_orders_button.text = "Clear" + clear_orders_button.pressed.connect(_clear_staged_orders) + staged_actions.add_child(clear_orders_button) + page.add_child(HSeparator.new()) + var active_title := Label.new() + active_title.text = "Order sheet" + active_title.add_theme_font_size_override("font_size", 18) + Brand.apply_font(active_title) + page.add_child(active_title) + var header := HBoxContainer.new() + for item in [["Order", 2.4], ["Route", 0.8], ["Frequency", 1.0], ["Order Time", 1.0], ["Report Time", 1.0], ["", 0.8]]: + var label := Label.new() + label.text = item[0] + label.size_flags_horizontal = Control.SIZE_EXPAND_FILL + label.size_flags_stretch_ratio = item[1] + Brand.apply_font(label) + header.add_child(label) + page.add_child(header) + var active_scroll := ScrollContainer.new() + active_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL + page.add_child(active_scroll) + active_orders = VBoxContainer.new() + active_orders.size_flags_horizontal = Control.SIZE_EXPAND_FILL + active_scroll.add_child(active_orders) - var active_title := _clerk_heading("Active orders") - clerk.add_child(active_title) - orders = VBoxContainer.new() - orders.add_theme_constant_override("separation", 5) - clerk.add_child(orders) - var exam_title := _clerk_heading("Physical examination") - clerk.add_child(exam_title) - actions = VBoxContainer.new() - actions.add_theme_constant_override("separation", 5) - clerk.add_child(actions) - locations = HFlowContainer.new() - locations.add_theme_constant_override("h_separation", 5) - locations.add_theme_constant_override("v_separation", 5) - clerk.add_child(locations) - time_row = GridContainer.new() - time_row.columns = 4 - time_row.add_theme_constant_override("h_separation", 5) - time_row.add_theme_constant_override("v_separation", 5) - clerk.add_child(time_row) - for minutes in [1, 10, 60, 1440]: - var button := Button.new() - button.text = "+%s" % ("1 day" if minutes == 1440 else "%d min" % minutes) - button.size_flags_horizontal = Control.SIZE_EXPAND_FILL - button.pressed.connect(CCSService.send_command.bind("advance", "", minutes)) - time_row.add_child(button) - var reassess := Button.new() - reassess.text = "Interval history" - reassess.pressed.connect(CCSService.send_command.bind("interval_history", "", 0)) - clerk.add_child(reassess) - end_button = Button.new() - end_button.text = "End case" - end_button.pressed.connect(_end_case) - clerk.add_child(end_button) - leave_button = Button.new() - leave_button.text = "Leave case" - leave_button.pressed.connect(_leave) - clerk.add_child(leave_button) - final_orders_notice = Label.new() - final_orders_notice.visible = false - final_orders_notice.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART - clerk.add_child(final_orders_notice) - clock = Label.new() - clock.visible = false - clerk.add_child(clock) + +func _build_footer() -> void: + var footer := HBoxContainer.new() + footer.add_theme_constant_override("separation", 12) + add_child(footer) + simulated_clock = _footer_label(HORIZONTAL_ALIGNMENT_LEFT) + footer.add_child(simulated_clock) + elapsed_clock = _footer_label(HORIZONTAL_ALIGNMENT_CENTER) + footer.add_child(elapsed_clock) + remaining_clock = _footer_label(HORIZONTAL_ALIGNMENT_RIGHT) + footer.add_child(remaining_clock) status = Label.new() status.visible = false status.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART - clerk.add_child(status) + add_child(status) -func _clerk_heading(text_value: String) -> Label: +func _footer_label(h_align: HorizontalAlignment) -> Label: var label := Label.new() - label.text = text_value - _set_label(label, 13, Brand.PURPLE) - section_labels.append(label) + label.horizontal_alignment = h_align + label.size_flags_horizontal = Control.SIZE_EXPAND_FILL + label.add_theme_font_size_override("font_size", 14) + Brand.apply_font(label) return label +func _build_dialogs() -> void: + intro_dialog = AcceptDialog.new() + intro_dialog.close_requested.connect(_accept_opening_step) + intro_dialog.confirmed.connect(_accept_opening_step) + add_child(intro_dialog) + var intro_box := VBoxContainer.new() + intro_box.custom_minimum_size = Vector2(760, 480) + intro_dialog.add_child(intro_box) + intro_title = Label.new() + intro_title.add_theme_font_size_override("font_size", 22) + Brand.apply_font(intro_title) + intro_box.add_child(intro_title) + intro_body = _clinical_text("opening") + intro_body.size_flags_vertical = Control.SIZE_EXPAND_FILL + intro_body.fit_content = false + intro_body.scroll_active = true + intro_box.add_child(intro_body) + + exam_dialog = ConfirmationDialog.new() + exam_dialog.title = "History & Physical" + exam_dialog.ok_button_text = "Review selection" + exam_dialog.confirmed.connect(_review_exam_selection) + add_child(exam_dialog) + var exam_scroll := ScrollContainer.new() + exam_scroll.custom_minimum_size = Vector2(680, 480) + exam_dialog.add_child(exam_scroll) + var exam_box := VBoxContainer.new() + exam_box.size_flags_horizontal = Control.SIZE_EXPAND_FILL + exam_scroll.add_child(exam_box) + var history_help := Label.new() + history_help.text = "Select physical examination components. Findings become available after the confirmed time advance." + history_help.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + exam_box.add_child(history_help) + exam_box.set_meta("exam_container", true) + + exam_summary_dialog = ConfirmationDialog.new() + exam_summary_dialog.title = "Confirm History & Physical" + exam_summary_dialog.confirmed.connect(_commit_exams) + add_child(exam_summary_dialog) + exam_summary = _clinical_text("exam_confirmation") + exam_summary.custom_minimum_size = Vector2(620, 300) + exam_summary_dialog.add_child(exam_summary) + findings_dialog = AcceptDialog.new() + findings_dialog.title = "History and Physical" + add_child(findings_dialog) + findings_body = _clinical_text("exam_findings") + findings_body.custom_minimum_size = Vector2(720, 440) + findings_body.fit_content = false + findings_body.scroll_active = true + findings_dialog.add_child(findings_body) + + time_dialog = ConfirmationDialog.new() + time_dialog.title = "Obtain Results / See Patient Later" + time_dialog.confirmed.connect(_commit_time) + add_child(time_dialog) + var time_box := VBoxContainer.new() + time_box.custom_minimum_size = Vector2(540, 260) + time_dialog.add_child(time_box) + time_preset = OptionButton.new() + for item in [["10 minutes", 10], ["1 hour", 60], ["4 hours", 240], ["12 hours", 720], ["1 day", 1440], ["Custom target", -1]]: + time_preset.add_item(item[0]) + time_preset.set_item_metadata(time_preset.item_count - 1, item[1]) + time_preset.item_selected.connect(_update_time_preview) + time_box.add_child(time_preset) + custom_target = LineEdit.new() + custom_target.placeholder_text = "YYYY-MM-DD HH:MM (case time)" + custom_target.visible = false + custom_target.text_changed.connect(func(_value: String): _update_time_preview(time_preset.selected)) + time_box.add_child(custom_target) + time_preview = Label.new() + time_preview.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + time_box.add_child(time_preview) + + location_dialog = ConfirmationDialog.new() + location_dialog.title = "Change Location" + location_dialog.confirmed.connect(_commit_location) + add_child(location_dialog) + var location_box := VBoxContainer.new() + location_box.custom_minimum_size = Vector2(480, 180) + location_dialog.add_child(location_box) + var location_help := Label.new() + location_help.text = "Select the patient’s destination:" + location_box.add_child(location_help) + location_option = OptionButton.new() + location_box.add_child(location_option) + + end_dialog = ConfirmationDialog.new() + end_dialog.title = "Enter Final Orders?" + end_dialog.dialog_text = "This ends active case management. You will have up to two minutes to finalize or cancel patient-care orders." + end_dialog.ok_button_text = "Enter Final Orders" + end_dialog.confirmed.connect(func(): CCSService.send_command("end_case")) + add_child(end_dialog) + final_instructions = AcceptDialog.new() + final_instructions.title = "Case-end Instructions" + final_instructions.dialog_text = "Finalize care of the patient’s current condition.\n\n• Cancel orders that should stop now.\n• Add orders that should be done now.\n• Add relevant future-care orders.\n\nThen submit final orders before time expires." + final_instructions.confirmed.connect(_open_order_sheet) + add_child(final_instructions) + abandon_dialog = ConfirmationDialog.new() + abandon_dialog.title = "Abandon CCS case?" + abandon_dialog.dialog_text = "This attempt will end without a score and cannot be resumed." + abandon_dialog.ok_button_text = "Abandon Case" + abandon_dialog.confirmed.connect(_confirm_abandon) + add_child(abandon_dialog) + + func _build_runtime_services() -> void: timer = Timer.new() timer.wait_time = 1 @@ -329,7 +427,6 @@ func _build_runtime_services() -> void: func on_show(payload: Variant = null) -> void: _set_status("") leaving = false - leave_button.disabled = false if typeof(payload) == TYPE_STRING and not str(payload).is_empty(): CCSService.load_attempt(str(payload)) _render() @@ -347,46 +444,493 @@ func _render() -> void: if str(attempt.get("state", "")) == "completed": main.show_screen("ccs_review", attempt) return - heading.text = "CCS Case | %s" % _humanize_code(str(attempt.get("location", ""))) - _render_study_text() - _render_vitals() - _render_orders() - _render_commands() + var was_final := exit_button.text == "Submit Final Orders" var final_orders := str(attempt.get("state", "")) == "final_orders" - end_button.text = "Submit final orders" if final_orders else "End case" - time_row.visible = not final_orders + for code in staged_order_codes.duplicate(): + if code in attempt.get("activeOrderCodes", []): + staged_order_codes.erase(code) + staged_order_names.erase(code) + heading.text = "CCS Case | %s" % _humanize_code(str(attempt.get("location", ""))) + var total := int(attempt.get("realTimeMinutes", 20)) + allotted_label.text = ( + "Legacy untimed CCS case" + if str(attempt.get("mode", "timed")) == "practice" + else "Maximum allotted real time: %d minutes + 2 minutes for final orders" % maxi(1, total - 2) + ) + exit_button.text = "Submit Final Orders" if final_orders else "Exit Case" + exit_button.tooltip_text = "Submit final orders and complete the case" if final_orders else "Enter final orders" final_orders_notice.visible = final_orders - final_orders_notice.text = "Finalize or cancel patient-care orders, then submit." + for button in action_buttons: + button.disabled = final_orders + action_buttons[1].disabled = false + _render_active_orders() + _render_staged_orders() + _render_chart() _tick() _apply_text_scale() + if not bool(attempt.get("openingCompleted", true)) and not opening_active: + _start_opening() + if final_orders and not was_final: + final_instructions.popup_centered(Vector2i(820, 500)) + _open_order_sheet() + if awaiting_exam_result and not CCSService.is_loading: + awaiting_exam_result = false + _show_new_findings() -func _render_vitals() -> void: - _clear(vitals_flow) - var vitals: Dictionary = CCSService.attempt.get("vitals", {}) - for key in _ordered_vital_keys(vitals): - var chip := PanelContainer.new() - chip.add_theme_stylebox_override("panel", _vital_chip_box()) - vitals_flow.add_child(chip) +func _start_opening() -> void: + opening_active = true + opening_step = 0 + _show_opening_step() + + +func _show_opening_step() -> void: + var attempt: Dictionary = CCSService.attempt + match opening_step: + 0: + intro_dialog.title = "Case Introduction" + intro_title.text = _simulated_time_text(0) + intro_body.text = "[center][b]%s[/b]\n\n%s[/center]" % [ + _escape_bbcode(_humanize_code(str(attempt.get("location", "")))), + _escape_bbcode(str(attempt.get("reasonForEncounter", ""))) + ] + 1: + intro_dialog.title = "Initial Vital Signs" + intro_title.text = _simulated_time_text(0) + intro_body.text = _vitals_bbcode(attempt.get("vitals", {})) + 2: + intro_dialog.title = "Initial History" + intro_title.text = str(attempt.get("patientSummary", "")) + intro_body.text = _history_bbcode() + intro_dialog.popup_centered(Vector2i(820, 560)) + + +func _accept_opening_step() -> void: + if not opening_active: + return + opening_step += 1 + if opening_step < 3: + call_deferred("_show_opening_step") + else: + opening_active = false + CCSService.send_command("acknowledge_opening") + + +func _open_exam() -> void: + if _is_final_orders(): + return + var container: VBoxContainer + for node in exam_dialog.find_children("*", "VBoxContainer", true, false): + if node.has_meta("exam_container"): + container = node + break + if container == null: + return + for child in container.get_children(): + if child is CheckBox: + child.queue_free() + exam_checks.clear() + interval_history_check = CheckBox.new() + interval_history_check.text = "Interval / follow-up history (1 min)" + container.add_child(interval_history_check) + for raw_exam in CCSService.attempt.get("availableExams", []): + var exam: Dictionary = raw_exam + var check := CheckBox.new() + var minutes := maxi(1, int(exam.get("durationMinutes", 1))) + check.text = "%s (%d min)" % [str(exam.get("name", _humanize_code(str(exam.get("code", ""))))), minutes] + check.set_meta("exam_code", str(exam.get("code", ""))) + check.set_meta("exam_minutes", minutes) + container.add_child(check) + exam_checks[str(exam.get("code", ""))] = check + exam_dialog.popup_centered(Vector2i(740, 600)) + + +func _review_exam_selection() -> void: + staged_exam_codes.clear() + staged_interval_history = interval_history_check != null and interval_history_check.button_pressed + var total := 0 + var lines: Array[String] = [] + if staged_interval_history: + total += 1 + lines.append("• Interval / follow-up history — 1 min") + for code in exam_checks: + var check: CheckBox = exam_checks[code] + if check.button_pressed: + staged_exam_codes.append(code) + var minutes := int(check.get_meta("exam_minutes", 1)) + total += minutes + lines.append("• %s — %d min" % [check.text.get_slice(" (", 0), minutes]) + if staged_exam_codes.is_empty() and not staged_interval_history: + _set_status("Select interval history or at least one examination component.") + return + exam_summary.text = "%s\n\n[b]Total simulated time: %d minutes[/b]\nThis will advance the clock to [b]%s[/b]." % [ + "\n".join(lines), total, _simulated_time_text(int(CCSService.attempt.get("simulatedMinute", 0)) + total) + ] + exam_summary_dialog.popup_centered(Vector2i(680, 420)) + + +func _commit_exams() -> void: + var commands: Array = [] + if staged_interval_history: + commands.append({"type": "interval_history", "code": "", "minutes": 0}) + for code in staged_exam_codes: + commands.append({"type": "exam", "code": code, "minutes": 0}) + pending_findings_before = _chart_entries("progress").size() + awaiting_exam_result = true + CCSService.send_batch(commands) + + +func _show_new_findings() -> void: + var entries := _chart_entries("progress") + var start := mini(pending_findings_before, entries.size()) + var text := "[center][b]%s[/b][/center]\n\n" % _simulated_time_text(int(CCSService.attempt.get("simulatedMinute", 0))) + for index in range(start, entries.size()): + var entry: Dictionary = entries[index] + if str(entry.get("code", "")).is_empty(): + continue + text += "[b]%s[/b]\n%s\n\n" % [_escape_bbcode(str(entry.get("title", ""))), _escape_bbcode(str(entry.get("text", "")))] + findings_body.text = text + findings_dialog.popup_centered(Vector2i(780, 540)) + + +func _open_order_sheet() -> void: + tabs.current_tab = 0 + search.grab_focus() + + +func _submit_search() -> void: + if search.text.strip_edges().length() < 2: + _set_status("Enter at least two characters to search.") + return + _set_status("") + CCSService.search_orders(search.text) + + +func _render_search() -> void: + _clear(search_results) + if CCSService.orders.is_empty(): + _set_status( + "No matching orders found. Try a clinical name or category." + if search.text.strip_edges().length() >= 2 + else "") + return + _set_status("") + for raw_order in CCSService.orders: + var order: Dictionary = raw_order + var code := str(order.get("code", "")) + var button := Button.new() + button.text = "%s · %s" % [str(order.get("canonicalName", code)), _humanize_code(str(order.get("category", "")))] + button.alignment = HORIZONTAL_ALIGNMENT_LEFT + button.disabled = code in staged_order_codes or code in CCSService.attempt.get("activeOrderCodes", []) + button.pressed.connect(_stage_order.bind(code)) + search_results.add_child(button) + + +func _stage_order(code: String) -> void: + if code.is_empty() or code in staged_order_codes: + return + staged_order_names[code] = _catalog_name(code) + staged_order_codes.append(code) + search.text = "" + CCSService.orders = [] + _render_search() + _render_staged_orders() + + +func _render_staged_orders() -> void: + _clear(staged_orders) + for code in staged_order_codes: var row := HBoxContainer.new() - row.add_theme_constant_override("separation", 7) - chip.add_child(row) - var icon := TextureRect.new() - icon.texture = _vital_icon(key) - icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE - icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED - icon.custom_minimum_size = Vector2(18, 18) - icon.modulate = _vital_color(key) - row.add_child(icon) + staged_orders.add_child(row) var label := Label.new() - label.text = "%s: %s" % [_vital_label(key), str(vitals[key])] - _set_label(label, 13, Brand.text_color(_is_dark())) + label.text = _catalog_name(code) + label.size_flags_horizontal = Control.SIZE_EXPAND_FILL row.add_child(label) + var remove := Button.new() + remove.text = "Remove" + remove.pressed.connect(_unstage_order.bind(code)) + row.add_child(remove) + commit_orders_button.disabled = staged_order_codes.is_empty() + clear_orders_button.disabled = staged_order_codes.is_empty() + + +func _unstage_order(code: String) -> void: + staged_order_codes.erase(code) + staged_order_names.erase(code) + _render_staged_orders() + + +func _clear_staged_orders() -> void: + staged_order_codes.clear() + staged_order_names.clear() + _render_staged_orders() + + +func _commit_orders() -> void: + if staged_order_codes.is_empty(): + return + var commands: Array = [] + for code in staged_order_codes: + commands.append({"type": "order", "code": code, "minutes": 0}) + CCSService.orders = [] + _render_search() + _render_staged_orders() + CCSService.send_batch(commands) + + +func _render_active_orders() -> void: + _clear(active_orders) + var rows: Array = CCSService.attempt.get("activeOrders", []) + if rows.is_empty(): + var empty := Label.new() + empty.text = "No active orders." + active_orders.add_child(empty) + return + for raw_order in rows: + var order: Dictionary = raw_order + var row := HBoxContainer.new() + row.custom_minimum_size.y = 44 + active_orders.add_child(row) + _add_order_cell(row, str(order.get("name", _humanize_code(str(order.get("code", ""))))), 2.4) + _add_order_cell(row, str(order.get("route", "—")) if not str(order.get("route", "")).is_empty() else "—", 0.8) + _add_order_cell(row, str(order.get("frequency", "—")) if not str(order.get("frequency", "")).is_empty() else "—", 1.0) + _add_order_cell(row, _simulated_time_text(int(order.get("orderedMinute", 0)), false), 1.0) + var report_value: Variant = order.get("reportMinute", null) + var report_text := "—" + if bool(order.get("isPending", false)): + report_text = "Pending" + elif bool(order.get("hasResult", report_value != null)) and report_value != null: + report_text = _simulated_time_text(int(report_value), false) + _add_order_cell(row, report_text, 1.0) + var cancel := Button.new() + cancel.text = "Cancel" + cancel.size_flags_horizontal = Control.SIZE_EXPAND_FILL + cancel.size_flags_stretch_ratio = 0.8 + cancel.pressed.connect(_cancel_order.bind(str(order.get("code", "")))) + row.add_child(cancel) + + +func _add_order_cell(row: HBoxContainer, value: String, ratio: float) -> void: + var label := Label.new() + label.text = value + label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + label.size_flags_horizontal = Control.SIZE_EXPAND_FILL + label.size_flags_stretch_ratio = ratio + row.add_child(label) + + +func _cancel_order(code: String) -> void: + CCSService.send_batch([{"type": "cancel_order", "code": code, "minutes": 0}]) + + +func _open_time_dialog() -> void: + if _is_final_orders(): + return + time_preset.select(0) + custom_target.visible = false + _update_time_preview(0) + time_dialog.popup_centered(Vector2i(600, 350)) + + +func _update_time_preview(index: int) -> void: + var minutes := int(time_preset.get_item_metadata(index)) + custom_target.visible = minutes < 0 + if minutes < 0: + minutes = _custom_target_minutes() + time_preview.text = "Clock will advance to %s." % _simulated_time_text(int(CCSService.attempt.get("simulatedMinute", 0)) + maxi(0, minutes)) + time_dialog.get_ok_button().disabled = minutes <= 0 + + +func _custom_target_minutes() -> int: + var value := custom_target.text.strip_edges().replace(" ", "T") + if value.length() == 16: + value += ":00" + var target := Time.get_unix_time_from_datetime_string(value) + if target <= 0: + return 0 + var start_text := str(CCSService.attempt.get("simulatedStartUtc", "")).trim_suffix("Z") + var start_unix := Time.get_unix_time_from_datetime_string(start_text) + var current_unix := start_unix + int(CCSService.attempt.get("simulatedMinute", 0)) * 60 + return maxi(0, int((target - current_unix) / 60.0)) + + +func _commit_time() -> void: + var minutes := int(time_preset.get_item_metadata(time_preset.selected)) + if minutes < 0: + minutes = _custom_target_minutes() + if minutes > 0: + CCSService.send_command("advance", "", minutes) + + +func _open_location_dialog() -> void: + if _is_final_orders(): + return + location_option.clear() + for raw_location in CCSService.attempt.get("allowedLocations", []): + var location := str(raw_location) + location_option.add_item(_humanize_code(location)) + location_option.set_item_metadata(location_option.item_count - 1, location) + location_dialog.get_ok_button().disabled = location_option.item_count == 0 + location_dialog.popup_centered(Vector2i(540, 280)) + + +func _commit_location() -> void: + if location_option.item_count > 0: + CCSService.send_command("location", str(location_option.get_item_metadata(location_option.selected))) + + +func _request_end_case() -> void: + if _is_final_orders(): + CCSService.send_command("exit_case") + else: + end_dialog.popup_centered(Vector2i(560, 260)) + + +func leave_case() -> void: + abandon_dialog.popup_centered(Vector2i(520, 220)) + + +func _confirm_abandon() -> void: + if leaving: + return + leaving = true + CCSService.abandon() + + +func _on_abandoned() -> void: + if leaving: + leaving = false + main.show_screen("ccs_hub") + + +func _render_chart() -> void: + for definition in CHART_TABS: + var category := str(definition[1]) + if category == "orders": + continue + var label: RichTextLabel = chart_views.get(category) + if label == null: + continue + label.text = _chart_category_bbcode(category) + + +func _chart_category_bbcode(category: String) -> String: + if category == "vitals": + return _vital_history_bbcode() + var entries := _chart_entries(category) + if entries.is_empty(): + return "[center][color=#8490a5]No %s available.[/color][/center]" % str(_tab_title(category)).to_lower() + var text := "" + for raw_entry in entries: + var entry: Dictionary = raw_entry + text += "[b]%s[/b] [color=#8490a5]%s[/color]\n%s\n\n" % [ + _escape_bbcode(str(entry.get("title", _tab_title(category)))), + _escape_bbcode(_simulated_time_text(int(entry.get("simulatedMinute", 0)), false)), + _escape_bbcode(str(entry.get("text", ""))) + ] + return text + + +func _chart_entries(category: String) -> Array: + var result: Array = [] + for raw_entry in CCSService.attempt.get("chart", []): + var entry: Dictionary = raw_entry + var actual := str(entry.get("category", "progress")) + if actual == "exam": + actual = "progress" + elif actual == "results": + actual = "other_test" + if actual == category: + result.append(entry) + return result + + +func _vital_history_bbcode() -> String: + var snapshots: Array = CCSService.attempt.get("vitalHistory", []) + if snapshots.is_empty(): + return _vitals_bbcode(CCSService.attempt.get("vitals", {})) + var text := "" + for raw_snapshot in snapshots: + var snapshot: Dictionary = raw_snapshot + text += "[b]%s[/b]\n%s\n\n" % [ + _escape_bbcode(_simulated_time_text(int(snapshot.get("simulatedMinute", 0)), false)), + _vitals_bbcode(snapshot.get("values", {})) + ] + return text + + +func _vitals_bbcode(vitals: Dictionary) -> String: + var lines: Array[String] = [] + for key in _ordered_vital_keys(vitals): + lines.append("[b]%s:[/b] %s" % [_escape_bbcode(_vital_label(key)), _escape_bbcode(str(vitals[key]))]) + return "\n".join(lines) + + +func _history_bbcode() -> String: + var sections: Array = CCSService.attempt.get("historySections", []) + if sections.is_empty(): + return "[b]History of present illness[/b]\n%s\n\n[b]Initial appearance[/b]\n%s" % [ + _escape_bbcode(str(CCSService.attempt.get("history", ""))), + _escape_bbcode(str(CCSService.attempt.get("appearance", ""))) + ] + var text := "" + for raw_section in sections: + var section: Dictionary = raw_section + text += "[b]%s[/b]\n%s\n\n" % [_escape_bbcode(str(section.get("title", ""))), _escape_bbcode(str(section.get("text", "")))] + return text + + +func _tick() -> void: + if CCSService.attempt.is_empty(): + return + var now := Time.get_unix_time_from_system() + var created_text := str(CCSService.attempt.get("createdUtc", "")).trim_suffix("Z") + var created := Time.get_unix_time_from_datetime_string(created_text) + var elapsed := maxi(0, int(now - created)) + var phase_deadline := CCSService.deadline_unix() + var hard_deadline := CCSService.hard_deadline_unix() + var display_deadline := phase_deadline if _is_final_orders() else hard_deadline + var remaining := maxi(0, int(ceil(display_deadline - now))) + var phase_remaining := maxi(0, int(ceil(phase_deadline - now))) + simulated_clock.text = "Elapsed SIMULATED case time: %s" % _elapsed_simulated_text(int(CCSService.attempt.get("simulatedMinute", 0))) + elapsed_clock.text = "Elapsed REAL time: %s" % _duration_text(elapsed) + remaining_clock.text = ( + "Legacy untimed case" + if hard_deadline <= 0 + else "%s REAL time: %s" % ["Final-order" if _is_final_orders() else "Remaining", _duration_text(remaining)] + ) + if phase_remaining == 0 and phase_deadline > 0 and not CCSService.is_loading: + CCSService.load_attempt(str(CCSService.attempt.get("id", ""))) + + +func _simulated_time_text(minute: int, include_day: bool = true) -> String: + var start_text := str(CCSService.attempt.get("simulatedStartUtc", "")).trim_suffix("Z") + var unix := Time.get_unix_time_from_datetime_string(start_text) + if unix <= 0: + return "Minute %d" % minute + var value := Time.get_datetime_dict_from_unix_time(unix + minute * 60) + var hour := int(value.hour) + var suffix := "AM" if hour < 12 else "PM" + var display_hour := hour % 12 + if display_hour == 0: + display_hour = 12 + var day_number := 1 + int(floor(float(minute) / 1440.0)) + return "%s%d:%02d %s" % ["Day %d @ " % day_number if include_day else "", display_hour, int(value.minute), suffix] + + +func _elapsed_simulated_text(minutes: int) -> String: + @warning_ignore("integer_division") + return "%d Days %02d Hrs %02d Mins" % [minutes / 1440, (minutes % 1440) / 60, minutes % 60] + + +func _duration_text(seconds: int) -> String: + @warning_ignore("integer_division") + return "%02d:%02d" % [seconds / 60, seconds % 60] func _ordered_vital_keys(vitals: Dictionary) -> Array[String]: var ordered: Array[String] = [] - for key in ["temperature", "bloodPressure", "pulse", "respirations", "oxygenSaturation"]: + for key in ["temperature", "bloodPressure", "pulse", "respirations", "oxygenSaturation", "height", "weight", "bmi"]: if vitals.has(key): ordered.append(key) var remaining: Array[String] = [] @@ -403,201 +947,39 @@ func _vital_label(key: String) -> String: match key: "bloodPressure": return "Blood Pressure" "oxygenSaturation": return "Oxygen Saturation" + "bmi": return "Body Mass Index" _: return _humanize_code(key) -func _vital_icon(key: String) -> Texture2D: - match key: - "temperature": return ICON_THERMOMETER - "bloodPressure", "pulse": return ICON_HEART - "respirations": return ICON_LUNGS - _: return ICON_ACTIVITY - - -func _vital_color(key: String) -> Color: - match key: - "temperature", "oxygenSaturation": return Brand.SKY_BLUE - "bloodPressure", "pulse": return Color("fb7185") - _: return Brand.PURPLE - - -func _render_orders() -> void: - _clear(orders) - var active_codes: Array = CCSService.attempt.get("activeOrderCodes", []) - if active_codes.is_empty(): - var empty := Label.new() - empty.text = "No active orders" - _set_label(empty, 12, Brand.muted_color(_is_dark())) - orders.add_child(empty) - return - for raw_code in active_codes: - var code := str(raw_code) - var row := HBoxContainer.new() - row.add_theme_constant_override("separation", 6) - orders.add_child(row) - var label := Label.new() - label.text = _humanize_code(code) - label.size_flags_horizontal = Control.SIZE_EXPAND_FILL - label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART - _set_label(label, 13, Brand.text_color(_is_dark())) - row.add_child(label) - var cancel := Button.new() - cancel.text = "Cancel" - cancel.pressed.connect(CCSService.send_command.bind("cancel_order", code, 0)) - _style_secondary_button(cancel) - row.add_child(cancel) - - -func _render_commands() -> void: - _clear(actions) - for exam in CCSService.attempt.get("availableExams", []): - if typeof(exam) != TYPE_DICTIONARY: - continue - var button := Button.new() - button.text = str(exam.get("name", _humanize_code(str(exam.get("code", "Exam"))))) - button.pressed.connect(CCSService.send_command.bind("exam", str(exam.get("code", "")), 0)) - _style_secondary_button(button) - actions.add_child(button) - _clear(locations) - for raw_location in CCSService.attempt.get("allowedLocations", []): - var location := str(raw_location) - var button := Button.new() - button.text = "Move to %s" % _humanize_code(location) - button.custom_minimum_size.x = 150 - button.size_flags_horizontal = Control.SIZE_EXPAND_FILL - button.pressed.connect(CCSService.send_command.bind("location", location, 0)) - _style_secondary_button(button) - locations.add_child(button) - _style_command_buttons() - - -func _submit_search() -> void: - if search.text.strip_edges().length() < 2: - _set_status("Enter at least two characters to search.") - return - _set_status("") - CCSService.search_orders(search.text) - - -func _render_search() -> void: - _clear(search_results) - search_results_panel.visible = not CCSService.orders.is_empty() - for order in CCSService.orders: - if typeof(order) != TYPE_DICTIONARY: - continue - var button := Button.new() - button.text = "%s (%s)" % [order.get("canonicalName", ""), _humanize_code(str(order.get("category", "")))] - button.alignment = HORIZONTAL_ALIGNMENT_LEFT - button.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART - button.pressed.connect(_place_order.bind(str(order.get("code", "")))) - _style_secondary_button(button) - search_results.add_child(button) - - -func _place_order(code: String) -> void: - CCSService.send_command("order", code) - search.text = "" - CCSService.orders = [] - _render_search() - - -func _tick() -> void: - var deadline := CCSService.deadline_unix() - var final_orders := str(CCSService.attempt.get("state", "")) == "final_orders" - clock.visible = final_orders and deadline > 0 - if deadline <= 0: - clock.text = "" - return - var remaining := maxi(0, int(ceil(deadline - Time.get_unix_time_from_system()))) - if final_orders: - clock.text = "Final orders: %02d:%02d remaining" % [floori(float(remaining) / 60.0), remaining % 60] - if remaining == 0 and not CCSService.is_loading: - CCSService.load_attempt(str(CCSService.attempt.get("id", ""))) - - -func leave_case() -> void: - _leave() - - -func _leave() -> void: - if leaving or CCSService.attempt.is_empty(): - return - leaving = true - leave_button.disabled = true - _set_status("Leaving case...", false) - CCSService.abandon() - - -func _on_abandoned() -> void: - if not leaving: - return - leaving = false - main.show_screen("ccs_hub") - - -func _request_failed(message: String) -> void: - if not visible: - return - _set_status(message) - if leaving: - leaving = false - leave_button.disabled = false - - -func _end_case() -> void: - CCSService.send_command("exit_case" if str(CCSService.attempt.get("state", "")) == "final_orders" else "end_case") - - -func _clear(parent: Node) -> void: - for child in parent.get_children(): - parent.remove_child(child) - child.queue_free() - - -func _chart_text(entries: Array) -> String: - var lines: Array[String] = [] - for entry in entries: - if typeof(entry) != TYPE_DICTIONARY: - continue - var category := str(entry.get("category", "note")) - var category_label := "In Progress" if category == "progress" else _humanize_code(category) - lines.append("T+%d (%s): %s" % [entry.get("simulatedMinute", 0), category_label, entry.get("text", "")]) - return "\n\n".join(lines) - - -func _summary_plain() -> String: - var attempt := CCSService.attempt - return "%s\n%s" % [attempt.get("patientSummary", ""), attempt.get("reasonForEncounter", "")] - - -func _render_study_text() -> void: - var attempt_id := str(CCSService.attempt.get("id", "")) - summary.text = CCSService.highlighted_text(attempt_id, "summary", _summary_plain()) - history.text = CCSService.highlighted_text(attempt_id, "history", str(CCSService.attempt.get("history", ""))) - appearance.text = CCSService.highlighted_text(attempt_id, "appearance", str(CCSService.attempt.get("appearance", ""))) - chart.text = CCSService.highlighted_text(attempt_id, "chart", _chart_text(CCSService.attempt.get("chart", []))) +func _clinical_text(region: String) -> RichTextLabel: + var label := RichTextLabel.new() + label.bbcode_enabled = true + label.selection_enabled = true + label.fit_content = true + label.scroll_active = false + label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + label.add_theme_font_override("normal_font", Brand.font) + label.add_theme_font_override("bold_font", Brand.font) + label.set_meta("ccs_base_font_size", 16) + label.set_meta("ccs_region", region) + label.gui_input.connect(_text_input.bind(label)) + clinical_labels.append(label) + return label func _text_input(event: InputEvent, label: RichTextLabel) -> void: - var mouse := event as InputEventMouseButton - if mouse != null and mouse.button_index == MOUSE_BUTTON_LEFT and not mouse.pressed: + if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT and not event.pressed: call_deferred("_commit_selection", label) func _commit_selection(label: RichTextLabel) -> void: - var selected := label.get_selected_text().strip_edges() - if selected.is_empty(): + var text := label.get_selected_text() + if text.is_empty(): return - var start := label.get_selection_from() - var end := label.get_selection_to() - CCSService.toggle_highlight( - str(CCSService.attempt.get("id", "")), - str(label.get_meta("ccs_region", "")), - mini(start, end), - maxi(start, end) - ) - label.deselect() - _render_study_text() + var source := label.get_parsed_text() + var start := source.find(text) + if start >= 0: + CCSService.toggle_highlight(str(CCSService.attempt.get("id", "")), str(label.get_meta("ccs_region", "")), start, start + text.length()) func _zoom(delta: float) -> void: @@ -606,183 +988,102 @@ func _zoom(delta: float) -> void: func _apply_text_scale() -> void: - for label in [summary, history, appearance, chart]: - var base_size := int(label.get_meta("ccs_base_font_size", 16)) - var font_size := int(round(float(base_size) * CCSService.text_scale)) + for label in clinical_labels: + var font_size := maxi(12, int(round(float(label.get_meta("ccs_base_font_size", 16)) * CCSService.text_scale))) label.add_theme_font_size_override("normal_font_size", font_size) label.add_theme_font_size_override("bold_font_size", font_size) -func _clear_highlights() -> void: - CCSService.clear_highlights(str(CCSService.attempt.get("id", ""))) - _render_study_text() - - func _report() -> void: - report_dialog.show_for(str(CCSService.attempt.get("id", ""))) + if report_dialog != null: + report_dialog.open(CCSService.attempt) func _report_submitted(_id: String) -> void: - report_button.tooltip_text = "Report submitted; report another issue" - _style_outline_button(report_button, Brand.SUCCESS) + _set_status("Case report submitted.", false) + + +func _request_failed(message: String) -> void: + if visible: + awaiting_exam_result = false + _set_status(message) func _set_status(message: String, is_error: bool = true) -> void: - if status == null: - return status.text = message status.visible = not message.is_empty() - status.add_theme_color_override("font_color", Color("fb7185") if is_error else Brand.SKY_BLUE) + status.add_theme_color_override("font_color", Color("ff6b7f") if is_error else Brand.SUCCESS) + + +func _catalog_name(code: String) -> String: + if staged_order_names.has(code): + return str(staged_order_names[code]) + for raw_order in CCSService.orders: + var order: Dictionary = raw_order + if str(order.get("code", "")) == code: + return str(order.get("canonicalName", _humanize_code(code))) + return _humanize_code(code) + + +func _tab_title(category: String) -> String: + for definition in CHART_TABS: + if str(definition[1]) == category: + return str(definition[0]) + return _humanize_code(category) func _humanize_code(value: String) -> String: - return value.replace("_", " ").replace("-", " ").capitalize() + return value.replace("_", " ").capitalize() + + +func _escape_bbcode(value: String) -> String: + return value.replace("[", "[lb]").replace("]", "[rb]") + + +func _is_final_orders() -> bool: + return str(CCSService.attempt.get("state", "")) == "final_orders" + + +func _clear(parent: Node) -> void: + for child in parent.get_children(): + child.queue_free() func _update_responsive_layout() -> void: - if workspace == null or header_layout == null: + if action_grid == null: return var narrow := size.x > 0 and size.x < RESPONSIVE_BREAKPOINT - workspace.vertical = narrow header_layout.vertical = narrow - toolbar.custom_minimum_size.x = 0 if narrow else 440 + action_grid.columns = 2 if narrow else 4 toolbar.size_flags_horizontal = Control.SIZE_EXPAND_FILL if narrow else Control.SIZE_SHRINK_END - chart_panel.custom_minimum_size.x = 0 if narrow else 600 - clerk_panel.custom_minimum_size.x = 0 if narrow else 370 - clerk_panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL if narrow else Control.SIZE_SHRINK_END + for button in action_buttons: + button.custom_minimum_size.x = 0 if narrow else 220 queue_sort() -func _on_theme_changed(_is_dark_mode: bool) -> void: - call_deferred("_finish_theme_change") - - -func _finish_theme_change() -> void: - _apply_theme() - if visible and not CCSService.attempt.is_empty(): - _render() +func _on_theme_changed(_dark: bool) -> void: + call_deferred("_apply_theme") func _apply_theme() -> void: - var dark := _is_dark() if header_panel == null: return + var dark: bool = theme_service != null and theme_service.is_dark_mode() header_panel.add_theme_stylebox_override("panel", Brand.content_card_box(dark, false)) - chart_panel.add_theme_stylebox_override("panel", Brand.content_card_box(dark, false)) - clerk_panel.add_theme_stylebox_override("panel", Brand.content_card_box(dark, false)) - header_icon_panel.add_theme_stylebox_override("panel", Brand.icon_circle_box(dark, Brand.PURPLE, 6, true)) + workspace.add_theme_stylebox_override("panel", Brand.content_card_box(dark, false)) heading.add_theme_color_override("font_color", Brand.text_color(dark)) - for label in section_labels: - label.add_theme_color_override("font_color", Brand.PURPLE if dark else Brand.ROYAL_BLUE) - for node in find_children("*", "Label", true, false): - var label := node as Label - if label.has_meta("ccs_muted_label"): - label.add_theme_color_override("font_color", Brand.muted_color(dark)) - for label in [summary, history, appearance, chart]: + allotted_label.add_theme_color_override("font_color", Brand.ROYAL_BLUE if not dark else Brand.SKY_BLUE) + for label in [simulated_clock, elapsed_clock, remaining_clock]: + label.add_theme_color_override("font_color", Brand.text_color(dark)) + final_orders_notice.add_theme_color_override("font_color", Brand.AMBER) + for button in toolbar_buttons + action_buttons: + button.add_theme_font_override("font", Brand.font) + button.add_theme_color_override("font_color", Brand.text_color(dark)) + button.add_theme_color_override("font_hover_color", Brand.text_color(dark)) + button.add_theme_stylebox_override("normal", Brand.content_card_box(dark, false)) + button.add_theme_stylebox_override("hover", Brand.content_card_box(dark, true)) + for label in clinical_labels: label.add_theme_color_override("default_color", Brand.text_color(dark)) - search.add_theme_font_override("font", Brand.font) - search.add_theme_color_override("font_color", Brand.text_color(dark)) - search.add_theme_color_override("font_placeholder_color", Brand.muted_color(dark)) - search.add_theme_stylebox_override("normal", _compact_field_box(dark)) - search.add_theme_stylebox_override("focus", _compact_field_box(dark, true)) - search_results_panel.add_theme_stylebox_override("panel", Brand.content_card_box(dark, false)) - for button in toolbar_buttons: - _style_secondary_button(button) - _style_icon_button(search_button) - _style_outline_button(end_button, Brand.AMBER) - _style_outline_button(leave_button, Color("fb7185")) - for button in time_row.get_children(): - if button is Button: - _style_secondary_button(button) Brand.set_theme_toggle_icon(theme_button, dark) theme_button.tooltip_text = "Switch to light mode" if dark else "Switch to dark mode" - final_orders_notice.add_theme_color_override("font_color", Brand.AMBER) - clock.add_theme_color_override("font_color", Brand.AMBER) - _render_vitals() - - -func _style_command_buttons() -> void: - for container in [actions, locations]: - for child in container.get_children(): - if child is Button: - _style_secondary_button(child) - var reassess := time_row.get_parent().get_child(time_row.get_index() + 1) as Button - if reassess != null: - _style_secondary_button(reassess) - - -func _style_secondary_button(button: Button) -> void: - var dark := _is_dark() - button.add_theme_font_override("font", Brand.font) - button.add_theme_font_size_override("font_size", 13) - button.add_theme_color_override("font_color", Brand.text_color(dark)) - button.add_theme_color_override("font_hover_color", Brand.text_color(dark)) - button.add_theme_stylebox_override("normal", _compact_button_box(dark)) - button.add_theme_stylebox_override("hover", _compact_button_box(dark, true)) - button.add_theme_stylebox_override("pressed", _compact_button_box(dark, true)) - - -func _style_icon_button(button: Button) -> void: - _style_secondary_button(button) - button.add_theme_constant_override("icon_max_width", 20) - var color := Brand.SKY_BLUE if _is_dark() else Brand.ROYAL_BLUE - for color_name in ["icon_normal_color", "icon_hover_color", "icon_pressed_color", "icon_focus_color"]: - button.add_theme_color_override(color_name, color) - - -func _style_outline_button(button: Button, accent: Color) -> void: - var dark := _is_dark() - var normal := _compact_button_box(dark) - normal.border_color = accent - var hover := normal.duplicate() as StyleBoxFlat - hover.bg_color = Color(accent, 0.1) - button.add_theme_font_override("font", Brand.font) - button.add_theme_font_size_override("font_size", 13) - button.add_theme_color_override("font_color", accent) - button.add_theme_color_override("font_hover_color", accent) - button.add_theme_stylebox_override("normal", normal) - button.add_theme_stylebox_override("hover", hover) - button.add_theme_stylebox_override("pressed", hover) - - -func _compact_field_box(dark: bool, focused: bool = false) -> StyleBoxFlat: - var box := StyleBoxFlat.new() - box.bg_color = Color("0d172a") if dark else Brand.LIGHT_SURFACE - box.border_color = Brand.ROYAL_BLUE if focused else (Color("33415d") if dark else Brand.LIGHT_BORDER) - box.set_border_width_all(1) - box.set_corner_radius_all(6) - box.content_margin_left = 12 - box.content_margin_right = 12 - box.content_margin_top = 8 - box.content_margin_bottom = 8 - return box - - -func _compact_button_box(dark: bool, hover: bool = false) -> StyleBoxFlat: - var box := _compact_field_box(dark, hover) - box.bg_color = Color("111b30") if dark else Brand.LIGHT_SURFACE - return box - - -func _vital_chip_box() -> StyleBoxFlat: - var dark := _is_dark() - var box := StyleBoxFlat.new() - box.bg_color = Color("111b30") if dark else Color("f8faff") - box.border_color = Color("33415d") if dark else Brand.LIGHT_BORDER - box.set_border_width_all(1) - box.set_corner_radius_all(6) - box.content_margin_left = 10 - box.content_margin_right = 10 - box.content_margin_top = 8 - box.content_margin_bottom = 8 - return box - - -func _set_label(label: Label, font_size: int, color: Color) -> void: - label.add_theme_font_override("font", Brand.font) - label.add_theme_font_size_override("font_size", font_size) - label.add_theme_color_override("font_color", color) - - -func _is_dark() -> bool: - return theme_service != null and theme_service.is_dark_mode() + _apply_text_scale() diff --git a/scripts/ui/ccs_review.gd b/scripts/ui/ccs_review.gd index ce78164..449ab1c 100644 --- a/scripts/ui/ccs_review.gd +++ b/scripts/ui/ccs_review.gd @@ -40,6 +40,7 @@ func _ready() -> void: status = Label.new(); Style.set_label(status, 14, Style.RED); row.add_child(status) CCSService.request_failed.connect(_request_failed) if not theme_service.theme_changed.is_connected(_on_theme_changed): theme_service.theme_changed.connect(_on_theme_changed) + if not CCSService.text_scale_changed.is_connected(_apply_text_scale): CCSService.text_scale_changed.connect(_apply_text_scale) func _study_label(region: String) -> RichTextLabel: var label := RichTextLabel.new(); label.bbcode_enabled = true; label.fit_content = true; label.selection_enabled = true; label.set_meta("ccs_region", region); label.gui_input.connect(_text_input.bind(label)); return label diff --git a/scripts/ui/circular_progress.gd b/scripts/ui/circular_progress.gd new file mode 100644 index 0000000..2eeb700 --- /dev/null +++ b/scripts/ui/circular_progress.gd @@ -0,0 +1,50 @@ +extends Control + +const Brand := preload("res://scripts/ui/medmind_style.gd") + +var ring_value := 0.0 +var track_color := Color("26324a") +var fill_color := Color("2563eb") +var ring_width := 8.0 +var value_label: Label +var caption_label: Label + + +func _init() -> void: + custom_minimum_size = Vector2(112, 112) + var center := CenterContainer.new() + center.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + center.mouse_filter = Control.MOUSE_FILTER_IGNORE + add_child(center) + var stack := VBoxContainer.new() + stack.add_theme_constant_override("separation", 0) + center.add_child(stack) + value_label = Label.new() + value_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + value_label.add_theme_font_size_override("font_size", 24) + stack.add_child(value_label) + caption_label = Label.new() + caption_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + caption_label.add_theme_font_size_override("font_size", 12) + stack.add_child(caption_label) + + +func configure(fill_fraction: float, value_text: String, caption_text: String, accent: Color, dark: bool) -> void: + ring_value = clampf(fill_fraction, 0.0, 1.0) + track_color = Color("26324a") if dark else Color("e2e8f0") + fill_color = accent + value_label.text = value_text + value_label.add_theme_color_override("font_color", Brand.text_color(dark)) + caption_label.text = caption_text + caption_label.add_theme_color_override("font_color", Brand.muted_color(dark)) + queue_redraw() + + +func _draw() -> void: + var radius := minf(size.x, size.y) / 2.0 - ring_width / 2.0 + if radius <= 0.0: + return + var center := size / 2.0 + draw_arc(center, radius, 0.0, TAU, 64, track_color, ring_width, true) + if ring_value > 0.0: + draw_arc(center, radius, -PI / 2.0, -PI / 2.0 + TAU * ring_value, 64, fill_color, ring_width, true) diff --git a/scripts/ui/circular_progress.gd.uid b/scripts/ui/circular_progress.gd.uid new file mode 100644 index 0000000..00b6e52 --- /dev/null +++ b/scripts/ui/circular_progress.gd.uid @@ -0,0 +1 @@ +uid://co8k1cl8jr45x diff --git a/scripts/ui/dashboard_screen.gd b/scripts/ui/dashboard_screen.gd index 56d7c8b..20d688f 100644 --- a/scripts/ui/dashboard_screen.gd +++ b/scripts/ui/dashboard_screen.gd @@ -244,6 +244,15 @@ func _render_review(data: Dictionary) -> void: missed_action.disabled = missed_count <= 0 or pending_review_launch missed_action.pressed.connect(_start_review_queue) review_list.add_child(missed_action) + if Brand.is_uat_environment(): + var dynamic_due := int(data.get("dynamicReviewsDue", 0)) + review_list.add_child(_review_action( + "%d dynamic concepts due" % dynamic_due if dynamic_due > 0 else "Dynamic concepts up to date", + "activity", Brand.PURPLE if dynamic_due > 0 else Brand.ROYAL_BLUE, "dynamic_questions")) + var flashcards_due := int(data.get("flashcardsDue", 0)) + review_list.add_child(_review_action( + "%d flashcards due" % flashcards_due if flashcards_due > 0 else "Flashcards up to date", + "clipboard-plus", Brand.TEAL if flashcards_due > 0 else Brand.ROYAL_BLUE, "flashcards")) review_list.add_child(_review_action("%d bookmarks" % int(data.get("bookmarkCount", 0)), "bookmark", Brand.ROYAL_BLUE, "bookmarks")) review_list.add_child(_review_action("%d notes" % int(data.get("noteCount", 0)), "notebook-pen", Brand.AMBER, "notes")) diff --git a/scripts/ui/dynamic_questions_screen.gd b/scripts/ui/dynamic_questions_screen.gd new file mode 100644 index 0000000..8f890fe --- /dev/null +++ b/scripts/ui/dynamic_questions_screen.gd @@ -0,0 +1,83 @@ +extends Control + +const Brand := preload("res://scripts/ui/medmind_style.gd") + +var main +var status_label: Label +var start_button: Button + + +func _ready() -> void: + set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + var center := CenterContainer.new() + center.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + add_child(center) + var card := VBoxContainer.new() + card.custom_minimum_size = Vector2(620, 0) + card.add_theme_constant_override("separation", 18) + center.add_child(card) + var title := Label.new() + title.text = "Dynamic Questions" + title.add_theme_font_override("font", Brand.font) + title.add_theme_font_size_override("font_size", 34) + title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + card.add_child(title) + var description := Label.new() + description.text = "Endless, data-driven Step 3 practice. Clinical values and distractors vary while each tested objective stays medically invariant." + description.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + description.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + card.add_child(description) + status_label = Label.new() + status_label.text = "Checking available objectives..." + status_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + card.add_child(status_label) + start_button = Button.new() + start_button.text = "Start Dynamic Questions" + start_button.custom_minimum_size = Vector2(0, 52) + start_button.disabled = true + start_button.pressed.connect(_start) + card.add_child(start_button) + DynamicQuestionService.status_loaded.connect(_on_status) + DynamicQuestionService.question_loaded.connect(_on_question) + DynamicQuestionService.request_failed.connect(_on_failed) + + +func on_show(_payload: Variant = null) -> void: + start_button.disabled = true + status_label.text = "Checking available objectives..." + DynamicQuestionService.load_status() + + +func _on_status(status: Dictionary) -> void: + var available := bool(status.get("available", false)) + var count := int(status.get("publishedObjectives", 0)) + var attempts := int(status.get("totalAttempts", 0)) + var due := int(status.get("reviewsDue", 0)) + var coverage := 0 + if count > 0: + coverage = int(round(float(status.get("attemptedObjectives", 0)) * 100.0 / float(count))) + status_label.text = "%d objectives | %d%% explored | %d reviews due | %d attempts" % [ + count, coverage, due, attempts] + start_button.disabled = not available + if not available: + status_label.text = "Dynamic question content has not been published yet." + + +func _start() -> void: + start_button.disabled = true + start_button.text = "Generating..." + DynamicQuestionService.reset_run() + DynamicQuestionService.load_next() + + +func _on_question(question: Dictionary) -> void: + start_button.text = "Start Dynamic Questions" + start_button.disabled = false + if BlockSessionService.start_dynamic_question(question): + main.show_screen("player") + + +func _on_failed(message: String) -> void: + start_button.text = "Try Again" + start_button.disabled = false + status_label.text = message diff --git a/scripts/ui/dynamic_questions_screen.gd.uid b/scripts/ui/dynamic_questions_screen.gd.uid new file mode 100644 index 0000000..3fd0abf --- /dev/null +++ b/scripts/ui/dynamic_questions_screen.gd.uid @@ -0,0 +1 @@ +uid://53fdgx3f3am1 diff --git a/scripts/ui/flashcard_cloze.gd b/scripts/ui/flashcard_cloze.gd new file mode 100644 index 0000000..c38e0a4 --- /dev/null +++ b/scripts/ui/flashcard_cloze.gd @@ -0,0 +1,41 @@ +extends RefCounted + +# Mirrors api/QuestionsApi/Services/Flashcards/FlashcardClozeParser.cs so the study screen can +# render a cloze note's front/back without a round trip to the server. + +static func _regex() -> RegEx: + var regex := RegEx.new() + regex.compile("(?s)\\{\\{c(\\d+)::((?:(?!\\}\\}).)*)\\}\\}") + return regex + + +static func render_front(text: String, cloze_number: int) -> String: + var regex := _regex() + var result := "" + var last_end := 0 + for match in regex.search_all(text): + result += text.substr(last_end, match.get_start() - last_end) + var number := int(match.get_string(1)) + var inner := match.get_string(2) + var parts := inner.split("::", true, 1) + if number == cloze_number: + var hint := parts[1].strip_edges() if parts.size() > 1 else "" + result += ("[%s]" % hint) if not hint.is_empty() else "[...]" + else: + result += parts[0].strip_edges() + last_end = match.get_end() + result += text.substr(last_end) + return result + + +static func render_back(text: String) -> String: + var regex := _regex() + var result := "" + var last_end := 0 + for match in regex.search_all(text): + result += text.substr(last_end, match.get_start() - last_end) + var inner := match.get_string(2) + result += inner.split("::", true, 1)[0].strip_edges() + last_end = match.get_end() + result += text.substr(last_end) + return result diff --git a/scripts/ui/flashcard_cloze.gd.uid b/scripts/ui/flashcard_cloze.gd.uid new file mode 100644 index 0000000..2e937ee --- /dev/null +++ b/scripts/ui/flashcard_cloze.gd.uid @@ -0,0 +1 @@ +uid://d0v44e6aprd1a diff --git a/scripts/ui/flashcard_prefill_dialog.gd b/scripts/ui/flashcard_prefill_dialog.gd new file mode 100644 index 0000000..c873bbb --- /dev/null +++ b/scripts/ui/flashcard_prefill_dialog.gd @@ -0,0 +1,178 @@ +extends Control + +# Prefilled basic-card editor opened from the question toolbar once a question's answer has +# been resolved. Saving creates a new flashcard note in the user's default deck; the user can +# move/retag it from the flashcards library afterward. + +const Brand := preload("res://scripts/ui/medmind_style.gd") +const Style := preload("res://scripts/ui/ui_style.gd") + +signal saved + +var front_input: TextEdit +var back_input: TextEdit +var tags_input: LineEdit +var image_checkbox: CheckBox +var error_label: Label +var save_button: Button +var pending_image_asset_id := "" +var source_question_external_id := "" + + +func _ready() -> void: + visible = false + top_level = true + mouse_filter = Control.MOUSE_FILTER_STOP + set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + var dim := ColorRect.new() + dim.color = Color(0, 0, 0, 0.72) + dim.set_anchors_preset(Control.PRESET_FULL_RECT) + dim.gui_input.connect(func(event: InputEvent): + if event is InputEventMouseButton and event.pressed: + hide_dialog()) + add_child(dim) + var center := CenterContainer.new() + center.set_anchors_preset(Control.PRESET_FULL_RECT) + add_child(center) + var panel := PanelContainer.new() + panel.custom_minimum_size = Vector2(600, 520) + panel.add_theme_stylebox_override("panel", Brand.panel_box(UIThemeService.is_dark_mode())) + center.add_child(panel) + var content := VBoxContainer.new() + content.add_theme_constant_override("separation", 10) + panel.add_child(content) + + var header := HBoxContainer.new() + content.add_child(header) + var title := Label.new() + title.text = "Save as flashcard" + title.size_flags_horizontal = Control.SIZE_EXPAND_FILL + title.add_theme_font_size_override("font_size", 22) + header.add_child(title) + var close_button := Button.new() + close_button.icon = preload("res://assets/icons/lucide/x.svg") + close_button.custom_minimum_size = Vector2(40, 40) + close_button.pressed.connect(hide_dialog) + header.add_child(close_button) + + content.add_child(_field_label("Front")) + front_input = TextEdit.new() + front_input.custom_minimum_size = Vector2(0, 110) + front_input.wrap_mode = TextEdit.LINE_WRAPPING_BOUNDARY + content.add_child(front_input) + + content.add_child(_field_label("Back")) + back_input = TextEdit.new() + back_input.custom_minimum_size = Vector2(0, 140) + back_input.wrap_mode = TextEdit.LINE_WRAPPING_BOUNDARY + content.add_child(back_input) + + content.add_child(_field_label("Tags (comma separated)")) + tags_input = LineEdit.new() + content.add_child(tags_input) + + image_checkbox = CheckBox.new() + image_checkbox.text = "Attach question image" + image_checkbox.visible = false + content.add_child(image_checkbox) + + error_label = Label.new() + error_label.add_theme_color_override("font_color", Style.RED) + error_label.visible = false + content.add_child(error_label) + + save_button = Button.new() + save_button.text = "Save Flashcard" + save_button.custom_minimum_size = Vector2(0, 48) + Style.style_primary_button(save_button) + save_button.pressed.connect(_save) + content.add_child(save_button) + + FlashcardService.note_saved.connect(_on_saved) + FlashcardService.request_failed.connect(_on_failed) + + +func _field_label(text: String) -> Label: + var label := Label.new() + label.text = text + label.add_theme_font_size_override("font_size", 13) + return label + + +func open_for_question(question: Dictionary, _current_answer: Dictionary) -> void: + var stem := str(question.get("stem", "")).strip_edges() + var lead_in := str(question.get("lead_in", "")).strip_edges() + front_input.text = "%s\n\n%s" % [stem, lead_in] if not lead_in.is_empty() else stem + + var choices: Array = question.get("choices", []) + var correct_id := str(question.get("correct_choice_id", "")) + var correct_text := "" + for choice in choices: + if str(choice.get("id", "")) == correct_id: + correct_text = str(choice.get("text", "")) + break + var takeaway := str(question.get("key_takeaway", "")).strip_edges() + if takeaway.is_empty(): + takeaway = str(question.get("explanation", "")).strip_edges() + back_input.text = "%s\n\n%s" % [correct_text, takeaway] if not takeaway.is_empty() else correct_text + + var tags: Array = [] + var system := str(question.get("system", "")).strip_edges() + if not system.is_empty(): + tags.append(system) + for tag in question.get("tags", []): + var trimmed := str(tag).strip_edges() + if not trimmed.is_empty() and not tags.has(trimmed): + tags.append(trimmed) + tags_input.text = ", ".join(PackedStringArray(tags)) + + var media: Array = question.get("media", []) + pending_image_asset_id = str(media[0]) if media.size() > 0 else "" + image_checkbox.visible = not pending_image_asset_id.is_empty() + image_checkbox.button_pressed = false + source_question_external_id = str(question.get("id", "")) + + error_label.visible = false + visible = true + + +func hide_dialog() -> void: + visible = false + + +func _save() -> void: + var tags: Array = [] + for tag in tags_input.text.split(","): + var trimmed := tag.strip_edges() + if not trimmed.is_empty(): + tags.append(trimmed) + var images: Array = [] + if image_checkbox.button_pressed and not pending_image_asset_id.is_empty(): + images.append({"kind": "question", "assetId": pending_image_asset_id}) + var payload := { + "deckId": "", + "stableId": "", + "type": "basic", + "prompt": front_input.text, + "answer": back_input.text, + "extra": "", + "tags": tags, + "images": images, + "sourceQuestionExternalId": source_question_external_id, + "expectedRevision": null + } + FlashcardService.save_note("", payload) + + +func _on_saved(_note: Dictionary) -> void: + if not visible: + return + hide_dialog() + saved.emit() + + +func _on_failed(_tag: String, message: String) -> void: + if not visible: + return + error_label.text = message + error_label.visible = true diff --git a/scripts/ui/flashcard_prefill_dialog.gd.uid b/scripts/ui/flashcard_prefill_dialog.gd.uid new file mode 100644 index 0000000..7d190cf --- /dev/null +++ b/scripts/ui/flashcard_prefill_dialog.gd.uid @@ -0,0 +1 @@ +uid://cy1cu55mkv0wd diff --git a/scripts/ui/flashcards_hub.gd b/scripts/ui/flashcards_hub.gd new file mode 100644 index 0000000..b018da9 --- /dev/null +++ b/scripts/ui/flashcards_hub.gd @@ -0,0 +1,845 @@ +extends VBoxContainer + +const Brand := preload("res://scripts/ui/medmind_style.gd") +const Style := preload("res://scripts/ui/ui_style.gd") +const Cloze := preload("res://scripts/ui/flashcard_cloze.gd") + +var main + +var stats_row: HFlowContainer +var stat_due_value: Label +var stat_new_value: Label +var stat_decks_value: Label +var list_card: PanelContainer +var list_section_title: Label + +var deck_selector: OptionButton +var deck_ids: Array = [] +var new_deck_button: Button +var study_button: Button +var import_button: Button +var search_input: LineEdit +var type_filter: OptionButton +var state_filter: OptionButton +var new_card_button: Button +var status_label: Label +var list_scroll: ScrollContainer +var list_container: VBoxContainer +var debounce_timer: Timer + +var editor_overlay: Control +var editor_panel: PanelContainer +var editor_deck_label: Label +var editor_note_id := "" +var editor_target_deck_id := "" +var editor_expected_revision: Variant = null +var editor_type_selector: OptionButton +var editor_front_input: TextEdit +var editor_back_input: TextEdit +var editor_extra_input: TextEdit +var editor_tags_input: LineEdit +var editor_hint_label: Label +var editor_error_label: Label + +var import_menu: PanelContainer +var dropdown_backdrop: Control + +var import_overlay: Control +var import_panel: PanelContainer +var import_summary_label: Label +var import_rows_container: VBoxContainer +var import_commit_button: Button +var import_pending_token := "" + +var compact_layout := false + +var decks: Array = [] +var notes: Array = [] + + +func _ready() -> void: + size_flags_horizontal = Control.SIZE_EXPAND_FILL + size_flags_vertical = Control.SIZE_EXPAND_FILL + add_theme_constant_override("separation", 14) + _build_dropdown_backdrop() + _build_stats() + _build_toolbar() + _build_filters() + status_label = Label.new() + status_label.add_theme_font_override("font", Brand.font) + add_child(status_label) + + list_card = PanelContainer.new() + list_card.size_flags_vertical = Control.SIZE_EXPAND_FILL + list_card.add_theme_stylebox_override("panel", _list_card_box(UIThemeService.is_dark_mode())) + add_child(list_card) + var list_card_content := VBoxContainer.new() + list_card_content.add_theme_constant_override("separation", 10) + list_card.add_child(list_card_content) + list_section_title = _section_title("Your flashcards") + list_card_content.add_child(list_section_title) + list_scroll = ScrollContainer.new() + list_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL + list_card_content.add_child(list_scroll) + list_container = VBoxContainer.new() + list_container.size_flags_horizontal = Control.SIZE_EXPAND_FILL + list_container.add_theme_constant_override("separation", 14) + list_scroll.add_child(list_container) + debounce_timer = Timer.new() + debounce_timer.one_shot = true + debounce_timer.wait_time = 0.35 + debounce_timer.timeout.connect(_reload_notes) + add_child(debounce_timer) + _build_editor_overlay() + _build_import_overlay() + resized.connect(_update_responsive_layout) + call_deferred("_update_responsive_layout") + + FlashcardService.decks_changed.connect(_on_decks_changed) + FlashcardService.notes_loaded.connect(_on_notes_loaded) + FlashcardService.note_saved.connect(_on_note_saved) + FlashcardService.request_failed.connect(_on_request_failed) + FlashcardService.import_preview_ready.connect(_on_import_preview_ready) + FlashcardService.import_committed.connect(_on_import_committed) + UIThemeService.theme_changed.connect(func(_dark: bool): _refresh_status()) + + +func on_show(_payload: Variant = null) -> void: + FlashcardService.fetch_decks() + _reload_notes() + + +func _build_stats() -> void: + var previous_due := stat_due_value.text if stat_due_value != null else "--" + var previous_new := stat_new_value.text if stat_new_value != null else "--" + var previous_decks := stat_decks_value.text if stat_decks_value != null else "--" + if stats_row == null: + stats_row = HFlowContainer.new() + stats_row.add_theme_constant_override("separation", 24) + add_child(stats_row) + move_child(stats_row, 0) + for child in stats_row.get_children(): + child.queue_free() + stat_due_value = _add_stat(stats_row, "Due today", "clock-3", Brand.AMBER) + stat_new_value = _add_stat(stats_row, "New available", "clipboard-plus", Brand.ROYAL_BLUE) + stat_decks_value = _add_stat(stats_row, "Decks", "clipboard-list", Brand.PURPLE) + stat_due_value.text = previous_due + stat_new_value.text = previous_new + stat_decks_value.text = previous_decks + + +func _add_stat(row: HFlowContainer, title: String, icon_name: String, accent: Color) -> Label: + var dark := UIThemeService.is_dark_mode() + var cell := HBoxContainer.new() + cell.custom_minimum_size = Vector2(200, 56) + cell.add_theme_constant_override("separation", 12) + row.add_child(cell) + var icon_panel := PanelContainer.new() + icon_panel.custom_minimum_size = Vector2(48, 48) + icon_panel.size_flags_vertical = Control.SIZE_SHRINK_CENTER + icon_panel.add_theme_stylebox_override("panel", Brand.icon_circle_box(dark, accent, 24)) + var icon := TextureRect.new() + icon.texture = load("res://assets/icons/lucide/%s.svg" % icon_name) + icon.custom_minimum_size = Vector2(22, 22) + icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE + icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED + icon_panel.add_child(icon) + cell.add_child(icon_panel) + var copy := VBoxContainer.new() + copy.size_flags_vertical = Control.SIZE_SHRINK_CENTER + var caption := Label.new() + caption.text = title + caption.add_theme_font_size_override("font_size", 13) + caption.add_theme_color_override("font_color", Brand.muted_color(dark)) + copy.add_child(caption) + var value := Label.new() + value.text = "--" + value.add_theme_font_size_override("font_size", 24) + value.add_theme_color_override("font_color", Brand.text_color(dark)) + copy.add_child(value) + cell.add_child(copy) + return value + + +func _list_card_box(dark: bool) -> StyleBoxFlat: + var box := StyleBoxFlat.new() + box.bg_color = Color("081426") if dark else Color.WHITE + box.border_color = Color("26344f") if dark else Brand.LIGHT_BORDER + box.set_border_width_all(1) + box.set_corner_radius_all(8) + box.content_margin_left = 18 + box.content_margin_right = 18 + box.content_margin_top = 16 + box.content_margin_bottom = 16 + return box + + +func _section_title(text: String) -> Label: + var label := Label.new() + label.text = text + label.add_theme_font_size_override("font_size", 16) + label.add_theme_color_override("font_color", Brand.text_color(UIThemeService.is_dark_mode())) + return label + + +func _build_toolbar() -> void: + var row := HFlowContainer.new() + row.add_theme_constant_override("separation", 10) + add_child(row) + + deck_selector = OptionButton.new() + deck_selector.add_item("All decks") + deck_selector.custom_minimum_size = Vector2(200, 44) + deck_selector.item_selected.connect(func(_index: int): _reload_notes()) + row.add_child(deck_selector) + + new_deck_button = Button.new() + new_deck_button.text = "New Deck" + new_deck_button.custom_minimum_size = Vector2(0, 44) + new_deck_button.pressed.connect(_prompt_new_deck) + row.add_child(new_deck_button) + + study_button = Button.new() + study_button.text = "Study" + study_button.custom_minimum_size = Vector2(110, 44) + _style_primary_button(study_button) + study_button.pressed.connect(_start_study) + row.add_child(study_button) + + new_card_button = Button.new() + new_card_button.text = "New Card" + new_card_button.custom_minimum_size = Vector2(0, 44) + new_card_button.pressed.connect(func(): _open_editor("", {})) + row.add_child(new_card_button) + + import_button = Button.new() + import_button.text = "Import" + import_button.custom_minimum_size = Vector2(0, 44) + import_button.pressed.connect(func(): _toggle_dropdown(import_menu, import_button)) + row.add_child(import_button) + import_menu = _build_dropdown([ + ["Import CSV...", func(): + status_label.text = "Choose a file to import..." + FlashcardService.pick_import_file("csv")], + ["Import JSON...", func(): + status_label.text = "Choose a file to import..." + FlashcardService.pick_import_file("json")], + ]) + + +func _style_primary_button(button: Button) -> void: + var normal := StyleBoxFlat.new() + normal.bg_color = Brand.ROYAL_BLUE + normal.set_corner_radius_all(6) + var hover := normal.duplicate() as StyleBoxFlat + hover.bg_color = Color("3474ee") + button.add_theme_stylebox_override("normal", normal) + button.add_theme_stylebox_override("hover", hover) + button.add_theme_stylebox_override("pressed", normal) + button.add_theme_color_override("font_color", Color.WHITE) + button.add_theme_color_override("font_hover_color", Color.WHITE) + + +func _build_dropdown_backdrop() -> void: + dropdown_backdrop = Control.new() + dropdown_backdrop.visible = false + dropdown_backdrop.top_level = true + dropdown_backdrop.mouse_filter = Control.MOUSE_FILTER_STOP + dropdown_backdrop.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + dropdown_backdrop.gui_input.connect(func(event: InputEvent): + if event is InputEventMouseButton and event.pressed: + _close_dropdowns()) + add_child(dropdown_backdrop) + + +func _build_dropdown(items: Array) -> PanelContainer: + var menu := PanelContainer.new() + menu.visible = false + menu.top_level = true + menu.add_theme_stylebox_override("panel", Brand.panel_box(UIThemeService.is_dark_mode())) + add_child(menu) + var list := VBoxContainer.new() + list.add_theme_constant_override("separation", 2) + menu.add_child(list) + for item in items: + var button := Button.new() + button.text = str(item[0]) + button.custom_minimum_size = Vector2(180, 40) + button.alignment = HORIZONTAL_ALIGNMENT_LEFT + var action: Callable = item[1] + button.pressed.connect(func(): + _close_dropdowns() + action.call()) + list.add_child(button) + return menu + + +func _toggle_dropdown(menu: PanelContainer, anchor: Control) -> void: + var was_visible := menu.visible + _close_dropdowns() + if was_visible: + return + menu.visible = true + dropdown_backdrop.visible = true + menu.position = anchor.get_global_position() + Vector2(0, anchor.size.y + 4) + + +func _close_dropdowns() -> void: + if import_menu != null: + import_menu.visible = false + if dropdown_backdrop != null: + dropdown_backdrop.visible = false + + +func _unhandled_input(event: InputEvent) -> void: + if event.is_action_pressed("ui_cancel") and dropdown_backdrop != null and dropdown_backdrop.visible: + _close_dropdowns() + get_viewport().set_input_as_handled() + + +func _build_filters() -> void: + var row := HFlowContainer.new() + row.add_theme_constant_override("separation", 10) + add_child(row) + search_input = LineEdit.new() + search_input.placeholder_text = "Search prompt or answer" + search_input.custom_minimum_size = Vector2(260, 44) + search_input.size_flags_horizontal = Control.SIZE_EXPAND_FILL + search_input.text_changed.connect(func(_value: String): debounce_timer.start()) + row.add_child(search_input) + type_filter = OptionButton.new() + type_filter.add_item("All types") + type_filter.add_item("Basic") + type_filter.add_item("Cloze") + type_filter.custom_minimum_size = Vector2(130, 44) + type_filter.item_selected.connect(func(_index: int): _reload_notes()) + row.add_child(type_filter) + state_filter = OptionButton.new() + state_filter.add_item("All states") + state_filter.add_item("New") + state_filter.add_item("Learning") + state_filter.add_item("Review") + state_filter.add_item("Relearning") + state_filter.add_item("Archived") + state_filter.custom_minimum_size = Vector2(130, 44) + state_filter.item_selected.connect(func(_index: int): _reload_notes()) + row.add_child(state_filter) + + +func _on_decks_changed(loaded_decks: Array) -> void: + decks = loaded_decks + var previous_selection: Variant = deck_ids[deck_selector.selected] if deck_selector.selected > 0 and deck_selector.selected < deck_ids.size() else null + deck_selector.clear() + deck_selector.add_item("All decks") + deck_ids = [""] + var total_due := 0 + var total_new := 0 + var my_decks := decks.filter(func(s): return not bool(s.get("deck", {}).get("isSystem", false))) + var official_decks := decks.filter(func(s): return bool(s.get("deck", {}).get("isSystem", false))) + if my_decks.size() > 0: + deck_selector.add_separator("My Decks") + deck_ids.append("") + for summary in my_decks: + var deck: Dictionary = summary.get("deck", {}) + deck_selector.add_item(str(deck.get("name", "Deck"))) + deck_ids.append(str(deck.get("id", ""))) + if official_decks.size() > 0: + deck_selector.add_separator("Official Decks") + deck_ids.append("") + for summary in official_decks: + var deck: Dictionary = summary.get("deck", {}) + deck_selector.add_item(str(deck.get("name", "Deck"))) + deck_ids.append(str(deck.get("id", ""))) + for summary in decks: + total_due += int(summary.get("due", 0)) + total_new += int(summary.get("new", 0)) + if previous_selection != null: + var restored := deck_ids.find(previous_selection) + if restored >= 0: + deck_selector.select(restored) + if stat_due_value != null: + stat_due_value.text = str(total_due) + stat_new_value.text = str(total_new) + stat_decks_value.text = str(decks.size()) + + +func _selected_deck_id() -> String: + if deck_selector.selected <= 0 or deck_selector.selected >= deck_ids.size(): + return "" + return str(deck_ids[deck_selector.selected]) + + +func _is_system_deck(id: String) -> bool: + if id.is_empty(): + return false + for summary in decks: + var deck: Dictionary = summary.get("deck", {}) + if str(deck.get("id", "")) == id: + return bool(deck.get("isSystem", false)) + return false + + +func _reload_notes() -> void: + status_label.text = "Loading flashcards..." + var query := { + "deckId": _selected_deck_id(), + "search": search_input.text, + "type": ["", "basic", "cloze"][type_filter.selected], + "state": ["", "new", "learning", "review", "relearning", "archived"][state_filter.selected], + "page": 1, + "pageSize": 100 + } + FlashcardService.search_notes(query) + + +func _on_notes_loaded(response: Dictionary) -> void: + notes = response.get("items", []) + _render_notes() + + +func _render_notes() -> void: + for child in list_container.get_children(): + child.queue_free() + var dark := UIThemeService.is_dark_mode() + if list_section_title != null: + list_section_title.text = "Your flashcards (%d)" % notes.size() + status_label.text = "" + if notes.is_empty(): + var empty_label := Label.new() + empty_label.text = "No flashcards match these filters yet." + empty_label.add_theme_color_override("font_color", Brand.muted_color(dark)) + list_container.add_child(empty_label) + return + for index in notes.size(): + if index > 0: + list_container.add_child(_row_separator(dark)) + list_container.add_child(_note_row(notes[index])) + + +func _note_row(note: Dictionary) -> Control: + var dark := UIThemeService.is_dark_mode() + var type_str := str(note.get("type", "basic")) + var accent := Brand.TEAL if type_str == "cloze" else Brand.ROYAL_BLUE + + var row := BoxContainer.new() + row.vertical = compact_layout + row.add_theme_constant_override("separation", 14) + + var icon_panel := PanelContainer.new() + icon_panel.custom_minimum_size = Vector2(40, 40) + icon_panel.size_flags_vertical = Control.SIZE_SHRINK_CENTER + icon_panel.add_theme_stylebox_override("panel", Brand.icon_circle_box(dark, accent, 20)) + var icon := TextureRect.new() + icon.texture = load("res://assets/icons/lucide/%s.svg" % ("notebook-pen" if type_str == "cloze" else "clipboard-list")) + icon.custom_minimum_size = Vector2(18, 18) + icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE + icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED + icon_panel.add_child(icon) + row.add_child(icon_panel) + + var content := VBoxContainer.new() + content.size_flags_horizontal = Control.SIZE_EXPAND_FILL + content.add_theme_constant_override("separation", 4) + row.add_child(content) + + var header_row := HBoxContainer.new() + header_row.add_theme_constant_override("separation", 8) + content.add_child(header_row) + var type_label := Label.new() + type_label.text = type_str.capitalize() + type_label.add_theme_font_size_override("font_size", 12) + type_label.add_theme_color_override("font_color", accent) + header_row.add_child(type_label) + var is_system := bool(note.get("isSystem", false)) + if is_system: + var official_label := Label.new() + official_label.text = "Official" + official_label.add_theme_font_size_override("font_size", 12) + official_label.add_theme_color_override("font_color", Brand.PURPLE) + header_row.add_child(official_label) + var tags: Array = note.get("tags", []) + if tags.size() > 0: + var tags_label := Label.new() + tags_label.text = ", ".join(PackedStringArray(tags)) + tags_label.add_theme_font_size_override("font_size", 12) + tags_label.add_theme_color_override("font_color", Brand.muted_color(dark)) + header_row.add_child(tags_label) + + var prompt_text := str(note.get("prompt", "")) + var preview := Cloze.render_back(prompt_text) if type_str == "cloze" else prompt_text + var prompt_label := Label.new() + prompt_label.text = preview + prompt_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + prompt_label.add_theme_font_override("font", Brand.font) + content.add_child(prompt_label) + + var actions := HBoxContainer.new() + actions.add_theme_constant_override("separation", 8) + row.add_child(actions) + + if not is_system: + var edit_button := Button.new() + edit_button.text = "Edit" + edit_button.custom_minimum_size = Vector2(70, 40) + edit_button.add_theme_stylebox_override("normal", Brand.secondary_button_box(dark)) + edit_button.add_theme_stylebox_override("hover", Brand.secondary_button_box(dark, true)) + edit_button.pressed.connect(_open_editor.bind(str(note.get("id", "")), note)) + actions.add_child(edit_button) + + var archive_button := Button.new() + archive_button.text = "Archive" + archive_button.custom_minimum_size = Vector2(85, 40) + archive_button.add_theme_stylebox_override("normal", Brand.secondary_button_box(dark)) + archive_button.add_theme_stylebox_override("hover", Brand.secondary_button_box(dark, true)) + archive_button.add_theme_color_override("font_color", Color("ff5f76") if dark else Color("be123c")) + archive_button.pressed.connect(func(): + FlashcardService.archive_notes([str(note.get("id", ""))], true) + await get_tree().create_timer(0.3).timeout + _reload_notes()) + actions.add_child(archive_button) + + return row + + +func _row_separator(dark: bool) -> ColorRect: + var line := ColorRect.new() + line.custom_minimum_size = Vector2(0, 1) + line.color = Color("1c2b49") if dark else Brand.LIGHT_BORDER + return line + + +# ---------- Note editor ---------- + +func _build_editor_overlay() -> void: + editor_overlay = Control.new() + editor_overlay.visible = false + editor_overlay.top_level = true + editor_overlay.mouse_filter = Control.MOUSE_FILTER_STOP + editor_overlay.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + add_child(editor_overlay) + var dim := ColorRect.new() + dim.color = Color(0, 0, 0, 0.72) + dim.set_anchors_preset(Control.PRESET_FULL_RECT) + dim.gui_input.connect(func(event: InputEvent): + if event is InputEventMouseButton and event.pressed: + _close_editor()) + editor_overlay.add_child(dim) + var center := CenterContainer.new() + center.set_anchors_preset(Control.PRESET_FULL_RECT) + editor_overlay.add_child(center) + editor_panel = PanelContainer.new() + editor_panel.custom_minimum_size = Vector2(620, 560) + editor_panel.add_theme_stylebox_override("panel", Brand.panel_box(UIThemeService.is_dark_mode())) + center.add_child(editor_panel) + var content := VBoxContainer.new() + content.add_theme_constant_override("separation", 10) + editor_panel.add_child(content) + + var header := HBoxContainer.new() + content.add_child(header) + var title := Label.new() + title.text = "Flashcard" + title.size_flags_horizontal = Control.SIZE_EXPAND_FILL + title.add_theme_font_size_override("font_size", 22) + header.add_child(title) + var close_button := Button.new() + close_button.icon = preload("res://assets/icons/lucide/x.svg") + close_button.custom_minimum_size = Vector2(40, 40) + close_button.pressed.connect(_close_editor) + header.add_child(close_button) + + editor_deck_label = Label.new() + editor_deck_label.add_theme_font_size_override("font_size", 13) + editor_deck_label.add_theme_color_override("font_color", Brand.muted_color(UIThemeService.is_dark_mode())) + content.add_child(editor_deck_label) + + editor_type_selector = OptionButton.new() + editor_type_selector.add_item("Basic") + editor_type_selector.add_item("Cloze") + editor_type_selector.item_selected.connect(_on_editor_type_changed) + content.add_child(editor_type_selector) + + editor_hint_label = Label.new() + editor_hint_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + editor_hint_label.add_theme_font_size_override("font_size", 12) + editor_hint_label.add_theme_color_override("font_color", Brand.muted_color(UIThemeService.is_dark_mode())) + content.add_child(editor_hint_label) + + content.add_child(_field_label("Front / Cloze text")) + editor_front_input = TextEdit.new() + editor_front_input.custom_minimum_size = Vector2(0, 100) + editor_front_input.wrap_mode = TextEdit.LINE_WRAPPING_BOUNDARY + content.add_child(editor_front_input) + + content.add_child(_field_label("Answer")) + editor_back_input = TextEdit.new() + editor_back_input.custom_minimum_size = Vector2(0, 80) + editor_back_input.wrap_mode = TextEdit.LINE_WRAPPING_BOUNDARY + content.add_child(editor_back_input) + + content.add_child(_field_label("Extra (optional teaching pearl)")) + editor_extra_input = TextEdit.new() + editor_extra_input.custom_minimum_size = Vector2(0, 60) + editor_extra_input.wrap_mode = TextEdit.LINE_WRAPPING_BOUNDARY + content.add_child(editor_extra_input) + + content.add_child(_field_label("Tags (comma separated)")) + editor_tags_input = LineEdit.new() + content.add_child(editor_tags_input) + + editor_error_label = Label.new() + editor_error_label.add_theme_color_override("font_color", Style.RED) + editor_error_label.visible = false + content.add_child(editor_error_label) + + var save_button := Button.new() + save_button.text = "Save" + save_button.custom_minimum_size = Vector2(0, 46) + _style_primary_button(save_button) + save_button.pressed.connect(_save_editor) + content.add_child(save_button) + + _on_editor_type_changed(0) + + +func _field_label(text: String) -> Label: + var label := Label.new() + label.text = text + label.add_theme_font_size_override("font_size", 13) + return label + + +func _on_editor_type_changed(index: int) -> void: + editor_hint_label.text = ( + "Wrap the hidden answer in {{c1::answer}}, or {{c1::answer::hint}}. Reuse c1/c2/... to " + + "create independently scheduled cards from the same sentence." + if index == 1 else + "Front is the question stem. Back is the correct answer plus key takeaway.") + + +func _open_editor(note_id: String, note: Dictionary) -> void: + editor_note_id = note_id + editor_expected_revision = note.get("revision", null) + editor_type_selector.select(1 if str(note.get("type", "basic")) == "cloze" else 0) + editor_front_input.text = str(note.get("prompt", "")) + editor_back_input.text = str(note.get("answer", "")) + editor_extra_input.text = str(note.get("extra", "")) + editor_tags_input.text = ", ".join(PackedStringArray(note.get("tags", []))) + editor_error_label.visible = false + if note_id.is_empty(): + editor_target_deck_id = "" if _is_system_deck(_selected_deck_id()) else _selected_deck_id() + else: + editor_target_deck_id = str(note.get("deckId", "")) + editor_deck_label.text = "Saving to: %s" % _deck_name_for_id(editor_target_deck_id) + _on_editor_type_changed(editor_type_selector.selected) + editor_overlay.visible = true + + +func _deck_name_for_id(id: String) -> String: + if not id.is_empty(): + for summary in decks: + var deck: Dictionary = summary.get("deck", {}) + if str(deck.get("id", "")) == id: + return str(deck.get("name", "Default")) + return "Default" + + +func _close_editor() -> void: + editor_overlay.visible = false + + +func _save_editor() -> void: + var is_cloze := editor_type_selector.selected == 1 + var prompt := editor_front_input.text.strip_edges() + if prompt.is_empty(): + editor_error_label.text = "Front / cloze text is required." + editor_error_label.visible = true + return + if is_cloze and not RegEx.create_from_string("\\{\\{c\\d+::").search(prompt): + editor_error_label.text = "Cloze cards need at least one {{cN::answer}} marker." + editor_error_label.visible = true + return + var tags: Array = [] + for tag in editor_tags_input.text.split(","): + var trimmed := tag.strip_edges() + if not trimmed.is_empty(): + tags.append(trimmed) + var payload := { + "deckId": editor_target_deck_id, + "stableId": "", + "type": "cloze" if is_cloze else "basic", + "prompt": prompt, + "answer": editor_back_input.text, + "extra": editor_extra_input.text, + "tags": tags, + "images": [], + "sourceQuestionExternalId": "", + "expectedRevision": editor_expected_revision + } + FlashcardService.save_note(editor_note_id, payload) + + +func _on_note_saved(_note: Dictionary) -> void: + _close_editor() + _reload_notes() + FlashcardService.fetch_decks() + + +func _on_request_failed(tag: String, message: String) -> void: + if tag == "import_commit": + import_commit_button.disabled = false + editor_error_label.text = message + editor_error_label.visible = true + status_label.text = message + + +func _prompt_new_deck() -> void: + FlashcardService.create_deck("New Deck %d" % (decks.size() + 1), "") + + +func _start_study() -> void: + main.show_screen("flashcards_study", _selected_deck_id()) + + +# ---------- Import preview / commit ---------- + +func _build_import_overlay() -> void: + import_overlay = Control.new() + import_overlay.visible = false + import_overlay.top_level = true + import_overlay.mouse_filter = Control.MOUSE_FILTER_STOP + import_overlay.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + add_child(import_overlay) + var dim := ColorRect.new() + dim.color = Color(0, 0, 0, 0.72) + dim.set_anchors_preset(Control.PRESET_FULL_RECT) + import_overlay.add_child(dim) + var center := CenterContainer.new() + center.set_anchors_preset(Control.PRESET_FULL_RECT) + import_overlay.add_child(center) + import_panel = PanelContainer.new() + import_panel.custom_minimum_size = Vector2(620, 480) + import_panel.add_theme_stylebox_override("panel", Brand.panel_box(UIThemeService.is_dark_mode())) + center.add_child(import_panel) + var content := VBoxContainer.new() + content.add_theme_constant_override("separation", 10) + import_panel.add_child(content) + + var header := HBoxContainer.new() + content.add_child(header) + var title := Label.new() + title.text = "Import preview" + title.size_flags_horizontal = Control.SIZE_EXPAND_FILL + title.add_theme_font_size_override("font_size", 22) + header.add_child(title) + var close_button := Button.new() + close_button.icon = preload("res://assets/icons/lucide/x.svg") + close_button.custom_minimum_size = Vector2(40, 40) + close_button.pressed.connect(_close_import_overlay) + header.add_child(close_button) + + import_summary_label = Label.new() + import_summary_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + content.add_child(import_summary_label) + + var rows_scroll := ScrollContainer.new() + rows_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL + content.add_child(rows_scroll) + import_rows_container = VBoxContainer.new() + import_rows_container.size_flags_horizontal = Control.SIZE_EXPAND_FILL + import_rows_container.add_theme_constant_override("separation", 4) + rows_scroll.add_child(import_rows_container) + + var actions := HBoxContainer.new() + actions.add_theme_constant_override("separation", 10) + content.add_child(actions) + var cancel_button := Button.new() + cancel_button.text = "Cancel" + cancel_button.custom_minimum_size = Vector2(0, 46) + cancel_button.pressed.connect(_close_import_overlay) + actions.add_child(cancel_button) + import_commit_button = Button.new() + import_commit_button.text = "Commit import" + import_commit_button.custom_minimum_size = Vector2(0, 46) + import_commit_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _style_primary_button(import_commit_button) + import_commit_button.pressed.connect(_commit_pending_import) + actions.add_child(import_commit_button) + + +func _on_import_preview_ready(preview: Dictionary) -> void: + import_pending_token = str(preview.get("importToken", "")) + var create_count := int(preview.get("createCount", 0)) + var update_count := int(preview.get("updateCount", 0)) + var skip_count := int(preview.get("skipCount", 0)) + var error_count := int(preview.get("errorCount", 0)) + import_summary_label.text = "%d to create, %d to update, %d to skip, %d row error(s)." % [create_count, update_count, skip_count, error_count] + for child in import_rows_container.get_children(): + child.queue_free() + for raw_row in preview.get("rows", []): + import_rows_container.add_child(_import_row(raw_row)) + import_commit_button.disabled = import_pending_token.is_empty() or (create_count + update_count == 0) + status_label.text = "" + import_overlay.visible = true + + +func _import_row(row: Dictionary) -> Control: + var label := Label.new() + var action := str(row.get("action", "")) + var error := str(row.get("error", "")) + var detail := error if not error.is_empty() else str(row.get("prompt", "")) + label.text = "Row %d - %s: %s" % [int(row.get("rowNumber", 0)), action.capitalize(), detail] + label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + if action == "error": + label.add_theme_color_override("font_color", Style.RED) + return label + + +func _commit_pending_import() -> void: + if import_pending_token.is_empty(): + return + import_commit_button.disabled = true + FlashcardService.commit_import(import_pending_token) + + +func _on_import_committed(result: Dictionary) -> void: + import_overlay.visible = false + import_pending_token = "" + status_label.text = "Import complete: %d created, %d updated, %d skipped, %d error(s)." % [ + int(result.get("created", 0)), int(result.get("updated", 0)), int(result.get("skipped", 0)), int(result.get("errored", 0))] + _reload_notes() + FlashcardService.fetch_decks() + + +func _close_import_overlay() -> void: + import_overlay.visible = false + import_pending_token = "" + + +# ---------- Theme / responsive ---------- + +func _refresh_status() -> void: + var dark := UIThemeService.is_dark_mode() + if editor_panel != null: + editor_panel.add_theme_stylebox_override("panel", Brand.panel_box(dark)) + if editor_deck_label != null: + editor_deck_label.add_theme_color_override("font_color", Brand.muted_color(dark)) + if import_panel != null: + import_panel.add_theme_stylebox_override("panel", Brand.panel_box(dark)) + if import_menu != null: + import_menu.add_theme_stylebox_override("panel", Brand.panel_box(dark)) + if list_card != null: + list_card.add_theme_stylebox_override("panel", _list_card_box(dark)) + if list_section_title != null: + list_section_title.add_theme_color_override("font_color", Brand.text_color(dark)) + _build_stats() + _render_notes() + + +func _update_responsive_layout() -> void: + var use_compact := size.x < 720.0 + if compact_layout == use_compact: + return + compact_layout = use_compact + search_input.custom_minimum_size.x = 0.0 if compact_layout else 260.0 + _render_notes() diff --git a/scripts/ui/flashcards_hub.gd.uid b/scripts/ui/flashcards_hub.gd.uid new file mode 100644 index 0000000..3b87fb9 --- /dev/null +++ b/scripts/ui/flashcards_hub.gd.uid @@ -0,0 +1 @@ +uid://dkqsqq7b0dlgb diff --git a/scripts/ui/flashcards_study.gd b/scripts/ui/flashcards_study.gd new file mode 100644 index 0000000..65988cd --- /dev/null +++ b/scripts/ui/flashcards_study.gd @@ -0,0 +1,957 @@ +extends Control + +const Brand := preload("res://scripts/ui/medmind_style.gd") +const Style := preload("res://scripts/ui/ui_style.gd") +const Cloze := preload("res://scripts/ui/flashcard_cloze.gd") +const CircularProgress := preload("res://scripts/ui/circular_progress.gd") + +const MASTERY_STABILITY_LABEL := "Cards graduate to \"mastered\" once they've held up in review for a few weeks straight." +const PROGRESS_PIP_COUNT := 5 + +var main + +var deck_id := "" +var current_note: Dictionary = {} +var current_card: Dictionary = {} +var rating_options: Array = [] +var revealed := false +var last_client_review_id := "" +var last_card_id := "" +var study_day_start_utc := "" +var seen_card_ids: Array = [] +var reveal_started_ms := 0 +var loaded_started_ms := 0 +var awaiting_next := false +var due_remaining := 0 +var new_remaining := 0 + +# Session history: every card that leaves the "live" slot (graded, skipped, buried, or +# suspended) is pushed here, so Previous/Next can page back through what happened this session. +# Invariant: history_cursor == session_history.size() means "at the live card"; anything less +# means we're viewing a past entry in read-only mode. +var session_history: Array = [] +var history_cursor := 0 +var session_total := -1 +var shuffle_enabled := false + +# Shadow copy of the true in-flight card, populated only while browsing history so the live +# card is never lost and can be restored exactly via _return_to_live(). +var live_note: Dictionary = {} +var live_card: Dictionary = {} +var live_rating_options: Array = [] +var live_revealed := false +var live_due_remaining := 0 +var live_new_remaining := 0 + +var progress_label: Label +var progress_pips: Array = [] +var paused_banner: Label +var main_row: HBoxContainer +var card_panel: PanelContainer +var front_label: Label +var divider: ColorRect +var answer_caption: Label +var back_label: Label +var extra_caption: Label +var extra_label: Label +var card_position_label: Label +var tap_hint_label: Label +var grade_row: HBoxContainer +var grade_buttons: Dictionary = {} +var undo_button: Button +var previous_button: Button +var next_button: Button +var shuffle_button: Button +var options_button: Button +var options_menu: PanelContainer +var options_backdrop: Control +var options_bury_button: Button +var options_suspend_button: Button +var empty_label: Label +var compact_layout := false + +var deck_panel: PanelContainer +var deck_name_label: Label +var deck_system_badge: Label +var deck_ring: Control +var deck_mastered_label: Label +var change_deck_button: Button + +var stats_panel: PanelContainer +var stats_new_ring: Control +var stats_due_label: Label +var stats_learning_label: Label +var stats_review_label: Label +var streak_label: Label +var daily_goal_label: Label +var daily_goal_bar: ProgressBar + +var decks: Array = [] +var latest_counts: Dictionary = {} + + +func _ready() -> void: + set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + add_theme_constant_override("separation", 14) + _build() + resized.connect(_update_responsive_layout) + call_deferred("_update_responsive_layout") + FlashcardService.next_card_loaded.connect(_on_next_card_loaded) + FlashcardService.next_card_failed.connect(_on_next_card_failed) + FlashcardService.review_synced.connect(_on_review_synced) + FlashcardService.review_paused.connect(_on_review_paused) + FlashcardService.review_resumed.connect(_on_review_resumed) + FlashcardService.decks_changed.connect(_on_decks_changed) + FlashcardService.counts_changed.connect(_on_counts_changed) + UIThemeService.theme_changed.connect(func(_dark: bool): refresh_theme_controls()) + + +func on_show(payload: Variant = null) -> void: + deck_id = str(payload) if payload != null else "" + seen_card_ids.clear() + session_history.clear() + history_cursor = 0 + session_total = -1 + study_day_start_utc = FlashcardService.current_study_day_start_utc() + current_note = {} + current_card = {} + revealed = false + last_client_review_id = "" + last_card_id = "" + undo_button.visible = false + _close_options_menu() + _render_loading() + _update_footer_button_states() + _load_next() + FlashcardService.fetch_decks() + FlashcardService.fetch_counts() + + +func on_hide() -> void: + pass + + +func _build() -> void: + var root := VBoxContainer.new() + root.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + root.add_theme_constant_override("separation", 16) + add_child(root) + + var top_row := HBoxContainer.new() + root.add_child(top_row) + progress_label = Label.new() + progress_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL + progress_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + progress_label.add_theme_font_size_override("font_size", 15) + top_row.add_child(progress_label) + + var pips_row := HBoxContainer.new() + pips_row.alignment = BoxContainer.ALIGNMENT_CENTER + pips_row.add_theme_constant_override("separation", 6) + root.add_child(pips_row) + progress_pips = [] + for i in PROGRESS_PIP_COUNT: + var pip := PanelContainer.new() + pip.custom_minimum_size = Vector2(48, 6) + pips_row.add_child(pip) + progress_pips.append(pip) + + paused_banner = Label.new() + paused_banner.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + paused_banner.add_theme_color_override("font_color", Style.AMBER) + paused_banner.visible = false + root.add_child(paused_banner) + + main_row = HBoxContainer.new() + main_row.size_flags_vertical = Control.SIZE_EXPAND_FILL + main_row.size_flags_horizontal = Control.SIZE_EXPAND_FILL + main_row.add_theme_constant_override("separation", 20) + root.add_child(main_row) + + _build_deck_panel() + + var center_column := VBoxContainer.new() + center_column.size_flags_horizontal = Control.SIZE_EXPAND_FILL + center_column.size_flags_vertical = Control.SIZE_EXPAND_FILL + center_column.add_theme_constant_override("separation", 16) + main_row.add_child(center_column) + + var center := CenterContainer.new() + center.size_flags_vertical = Control.SIZE_EXPAND_FILL + center.size_flags_horizontal = Control.SIZE_EXPAND_FILL + center_column.add_child(center) + + card_panel = PanelContainer.new() + card_panel.custom_minimum_size = Vector2(720, 420) + card_panel.add_theme_stylebox_override("panel", Brand.content_card_box(UIThemeService.is_dark_mode(), true, Brand.ROYAL_BLUE)) + card_panel.mouse_filter = Control.MOUSE_FILTER_STOP + card_panel.gui_input.connect(_on_card_panel_input) + center.add_child(card_panel) + var card_content := VBoxContainer.new() + card_content.add_theme_constant_override("separation", 20) + card_panel.add_child(card_content) + + empty_label = Label.new() + empty_label.text = "Nothing due right now. Great work!" + empty_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + empty_label.visible = false + card_content.add_child(empty_label) + + front_label = Label.new() + front_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + front_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + front_label.add_theme_font_size_override("font_size", 25) + card_content.add_child(front_label) + + divider = ColorRect.new() + divider.custom_minimum_size = Vector2(0, 2) + divider.visible = false + card_content.add_child(divider) + + var tap_hint_center := CenterContainer.new() + card_content.add_child(tap_hint_center) + tap_hint_label = Label.new() + tap_hint_label.text = "Tap card to reveal answer" + tap_hint_label.visible = false + tap_hint_center.add_child(tap_hint_label) + + var answer_block := VBoxContainer.new() + answer_block.add_theme_constant_override("separation", 4) + card_content.add_child(answer_block) + answer_caption = _caption_label("ANSWER") + answer_block.add_child(answer_caption) + back_label = Label.new() + back_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + back_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + back_label.add_theme_font_size_override("font_size", 19) + back_label.visible = false + answer_block.add_child(back_label) + + var extra_block := VBoxContainer.new() + extra_block.add_theme_constant_override("separation", 4) + card_content.add_child(extra_block) + extra_caption = _caption_label("KEY TAKEAWAY") + extra_block.add_child(extra_caption) + extra_label = Label.new() + extra_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + extra_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + extra_label.add_theme_font_size_override("font_size", 15) + extra_label.visible = false + extra_block.add_child(extra_label) + + var position_row := HBoxContainer.new() + card_content.add_child(position_row) + var position_spacer := Control.new() + position_spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL + position_row.add_child(position_spacer) + card_position_label = Label.new() + card_position_label.add_theme_font_size_override("font_size", 12) + card_position_label.visible = false + position_row.add_child(card_position_label) + + grade_row = HBoxContainer.new() + grade_row.add_theme_constant_override("separation", 10) + grade_row.visible = false + center_column.add_child(grade_row) + for spec in [["again", "Again", "1", Style.RED], ["hard", "Hard", "2", Style.AMBER], ["good", "Good", "3", Style.CYAN], ["easy", "Easy", "4", Style.LIME]]: + var button := Button.new() + button.custom_minimum_size = Vector2(0, 64) + button.size_flags_horizontal = Control.SIZE_EXPAND_FILL + Style.style_chip(button, spec[3]) + button.pressed.connect(_grade.bind(spec[0])) + grade_row.add_child(button) + grade_buttons[spec[0]] = button + + undo_button = Button.new() + undo_button.text = "Undo this rating" + undo_button.custom_minimum_size = Vector2(0, 40) + undo_button.visible = false + undo_button.pressed.connect(_undo) + center_column.add_child(undo_button) + + var footer_center := CenterContainer.new() + center_column.add_child(footer_center) + var footer_row := HBoxContainer.new() + footer_row.add_theme_constant_override("separation", 10) + footer_center.add_child(footer_row) + + previous_button = Button.new() + previous_button.text = "‹ Previous" + previous_button.custom_minimum_size = Vector2(120, 44) + previous_button.pressed.connect(_go_previous) + footer_row.add_child(previous_button) + + shuffle_button = Button.new() + shuffle_button.text = "Shuffle" + shuffle_button.toggle_mode = true + shuffle_button.custom_minimum_size = Vector2(110, 44) + shuffle_button.toggled.connect(func(pressed: bool): shuffle_enabled = pressed) + footer_row.add_child(shuffle_button) + + _build_options_menu(footer_row) + + next_button = Button.new() + next_button.text = "Next ›" + next_button.custom_minimum_size = Vector2(120, 44) + next_button.pressed.connect(_go_next) + footer_row.add_child(next_button) + + _build_stats_panel() + + refresh_theme_controls() + _update_footer_button_states() + + +func _caption_label(text: String) -> Label: + var label := Label.new() + label.text = text + label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + label.add_theme_font_size_override("font_size", 12) + label.visible = false + return label + + +func _style_ghost_button(button: Button, dark: bool) -> void: + var empty := StyleBoxEmpty.new() + button.add_theme_stylebox_override("normal", empty) + button.add_theme_stylebox_override("hover", empty) + button.add_theme_stylebox_override("pressed", empty) + button.add_theme_stylebox_override("focus", empty) + button.add_theme_color_override("font_color", Brand.muted_color(dark)) + button.add_theme_color_override("font_hover_color", Brand.text_color(dark)) + + +func _side_panel_box(dark: bool) -> StyleBoxFlat: + var box := StyleBoxFlat.new() + box.bg_color = Color("081426") if dark else Color.WHITE + box.border_color = Color("26344f") if dark else Brand.LIGHT_BORDER + box.set_border_width_all(1) + box.set_corner_radius_all(10) + box.content_margin_left = 18 + box.content_margin_right = 18 + box.content_margin_top = 18 + box.content_margin_bottom = 18 + return box + + +func _pip_box(filled: bool, dark: bool) -> StyleBoxFlat: + var box := StyleBoxFlat.new() + box.bg_color = Brand.ROYAL_BLUE if filled else (Color("26324a") if dark else Color("e2e8f0")) + box.set_corner_radius_all(3) + return box + + +func _panel_caption(text: String) -> Label: + var label := Label.new() + label.text = text + label.add_theme_font_size_override("font_size", 12) + return label + + +# ---------- Left panel: current deck ---------- + +func _build_deck_panel() -> void: + deck_panel = PanelContainer.new() + deck_panel.custom_minimum_size = Vector2(220, 0) + deck_panel.size_flags_vertical = Control.SIZE_SHRINK_BEGIN + main_row.add_child(deck_panel) + var content := VBoxContainer.new() + content.add_theme_constant_override("separation", 10) + deck_panel.add_child(content) + + content.add_child(_panel_caption("CURRENT DECK")) + deck_name_label = Label.new() + deck_name_label.text = "All decks" + deck_name_label.add_theme_font_size_override("font_size", 18) + deck_name_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + content.add_child(deck_name_label) + + deck_system_badge = Label.new() + deck_system_badge.text = "Official Deck" + deck_system_badge.add_theme_font_size_override("font_size", 12) + deck_system_badge.add_theme_color_override("font_color", Brand.PURPLE) + deck_system_badge.visible = false + content.add_child(deck_system_badge) + + var ring_center := CenterContainer.new() + content.add_child(ring_center) + deck_ring = CircularProgress.new() + deck_ring.tooltip_text = MASTERY_STABILITY_LABEL + ring_center.add_child(deck_ring) + + deck_mastered_label = Label.new() + deck_mastered_label.text = "-- cards mastered" + deck_mastered_label.add_theme_font_size_override("font_size", 12) + deck_mastered_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + content.add_child(deck_mastered_label) + + change_deck_button = Button.new() + change_deck_button.text = "Change Deck" + change_deck_button.custom_minimum_size = Vector2(0, 44) + change_deck_button.pressed.connect(func(): main.show_screen("flashcards_hub")) + content.add_child(change_deck_button) + + +# ---------- Right panel: spaced repetition stats ---------- + +func _build_stats_panel() -> void: + stats_panel = PanelContainer.new() + stats_panel.custom_minimum_size = Vector2(220, 0) + stats_panel.size_flags_vertical = Control.SIZE_SHRINK_BEGIN + main_row.add_child(stats_panel) + var content := VBoxContainer.new() + content.add_theme_constant_override("separation", 10) + stats_panel.add_child(content) + + content.add_child(_panel_caption("SPACED REPETITION")) + + var ring_center := CenterContainer.new() + content.add_child(ring_center) + stats_new_ring = CircularProgress.new() + ring_center.add_child(stats_new_ring) + + stats_due_label = _stat_row(content, "Due") + stats_learning_label = _stat_row(content, "Learning") + stats_review_label = _stat_row(content, "Review") + + var streak_row := HBoxContainer.new() + streak_row.add_theme_constant_override("separation", 10) + content.add_child(streak_row) + var streak_caption := VBoxContainer.new() + streak_caption.add_theme_constant_override("separation", 0) + streak_row.add_child(streak_caption) + streak_caption.add_child(_panel_caption("STREAK")) + streak_label = Label.new() + streak_label.text = "-- days" + streak_label.add_theme_font_size_override("font_size", 16) + streak_caption.add_child(streak_label) + + var goal_block := VBoxContainer.new() + goal_block.add_theme_constant_override("separation", 4) + content.add_child(goal_block) + goal_block.add_child(_panel_caption("DAILY GOAL")) + daily_goal_label = Label.new() + daily_goal_label.text = "-- / --" + daily_goal_label.add_theme_font_size_override("font_size", 16) + goal_block.add_child(daily_goal_label) + daily_goal_bar = ProgressBar.new() + daily_goal_bar.custom_minimum_size = Vector2(0, 8) + daily_goal_bar.show_percentage = false + daily_goal_bar.min_value = 0 + daily_goal_bar.max_value = 1 + goal_block.add_child(daily_goal_bar) + + +func _stat_row(parent: VBoxContainer, caption: String) -> Label: + var row := HBoxContainer.new() + parent.add_child(row) + var caption_label := Label.new() + caption_label.text = caption + caption_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL + row.add_child(caption_label) + var value := Label.new() + value.text = "--" + value.add_theme_font_size_override("font_size", 15) + row.add_child(value) + return value + + +# ---------- Options menu ---------- + +func _build_options_menu(footer_row: HBoxContainer) -> void: + options_backdrop = Control.new() + options_backdrop.visible = false + options_backdrop.top_level = true + options_backdrop.mouse_filter = Control.MOUSE_FILTER_STOP + options_backdrop.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + options_backdrop.gui_input.connect(func(event: InputEvent): + if event is InputEventMouseButton and event.pressed: + _close_options_menu()) + add_child(options_backdrop) + + options_button = Button.new() + options_button.text = "Options" + options_button.custom_minimum_size = Vector2(0, 44) + options_button.pressed.connect(_toggle_options_menu) + footer_row.add_child(options_button) + + options_menu = PanelContainer.new() + options_menu.visible = false + options_menu.top_level = true + add_child(options_menu) + var list := VBoxContainer.new() + list.add_theme_constant_override("separation", 2) + options_menu.add_child(list) + + _options_menu_item(list, "Manage Flashcards", func(): main.show_screen("flashcards_hub")) + options_bury_button = _options_menu_item(list, "Bury card until tomorrow", func(): _bury_current_card()) + options_suspend_button = _options_menu_item(list, "Suspend card", func(): _suspend_current_card()) + + +func _options_menu_item(list: VBoxContainer, text: String, action: Callable) -> Button: + var button := Button.new() + button.text = text + button.custom_minimum_size = Vector2(200, 40) + button.alignment = HORIZONTAL_ALIGNMENT_LEFT + button.pressed.connect(func(): + _close_options_menu() + action.call()) + list.add_child(button) + return button + + +func _toggle_options_menu() -> void: + if options_menu.visible: + _close_options_menu() + return + var at_live_with_card := history_cursor == session_history.size() and not current_card.is_empty() + options_bury_button.disabled = not at_live_with_card + options_suspend_button.disabled = not at_live_with_card + options_menu.visible = true + options_backdrop.visible = true + options_menu.position = options_button.get_global_position() - Vector2(0, options_menu.size.y + 4) + + +func _close_options_menu() -> void: + if options_menu != null: + options_menu.visible = false + if options_backdrop != null: + options_backdrop.visible = false + + +func _bury_current_card() -> void: + if history_cursor != session_history.size() or current_card.is_empty(): + return + var card_id := str(current_card.get("id", "")) + FlashcardService.bury_cards([card_id], _bury_until_tomorrow_utc()) + _push_history("buried", card_id) + if not seen_card_ids.has(card_id): + seen_card_ids.append(card_id) + _load_next() + _render_loading() + + +func _suspend_current_card() -> void: + if history_cursor != session_history.size() or current_card.is_empty(): + return + var card_id := str(current_card.get("id", "")) + FlashcardService.suspend_cards([card_id], true) + _push_history("suspended", card_id) + if not seen_card_ids.has(card_id): + seen_card_ids.append(card_id) + _load_next() + _render_loading() + + +func _bury_until_tomorrow_utc() -> String: + var study_day_unix := Time.get_unix_time_from_datetime_string(study_day_start_utc.trim_suffix("Z")) + return "%sZ" % Time.get_datetime_string_from_unix_time(study_day_unix + 86400, false) + + +func refresh_theme_controls() -> void: + if card_panel == null: + return + var dark := UIThemeService.is_dark_mode() + card_panel.add_theme_stylebox_override("panel", Brand.content_card_box(dark, true, Brand.ROYAL_BLUE)) + empty_label.add_theme_color_override("font_color", Brand.muted_color(dark)) + divider.color = Brand.ROYAL_BLUE if dark else Brand.PURPLE + answer_caption.add_theme_color_override("font_color", Brand.ROYAL_BLUE if dark else Brand.PURPLE) + extra_caption.add_theme_color_override("font_color", Brand.muted_color(dark)) + extra_label.add_theme_color_override("font_color", Brand.muted_color(dark)) + card_position_label.add_theme_color_override("font_color", Brand.muted_color(dark)) + tap_hint_label.add_theme_color_override("font_color", Brand.muted_color(dark)) + _style_ghost_button(undo_button, dark) + previous_button.add_theme_stylebox_override("normal", Brand.secondary_button_box(dark)) + previous_button.add_theme_stylebox_override("hover", Brand.secondary_button_box(dark, true)) + next_button.add_theme_stylebox_override("normal", Brand.secondary_button_box(dark)) + next_button.add_theme_stylebox_override("hover", Brand.secondary_button_box(dark, true)) + options_button.add_theme_stylebox_override("normal", Brand.secondary_button_box(dark)) + options_button.add_theme_stylebox_override("hover", Brand.secondary_button_box(dark, true)) + deck_panel.add_theme_stylebox_override("panel", _side_panel_box(dark)) + stats_panel.add_theme_stylebox_override("panel", _side_panel_box(dark)) + options_menu.add_theme_stylebox_override("panel", Brand.panel_box(dark)) + _update_progress_pips(history_cursor) + _refresh_deck_panel() + _refresh_stats_panel() + + +func _update_responsive_layout() -> void: + if card_panel == null: + return + compact_layout = size.x < 1080.0 + deck_panel.visible = not compact_layout + stats_panel.visible = not compact_layout + var available := maxf((size.x - 48.0) if compact_layout else (size.x - 520.0), 240.0) + card_panel.custom_minimum_size.x = minf(720.0, available) + card_panel.custom_minimum_size.y = 340.0 if size.x < 720.0 else 420.0 + + +# ---------- Deck / stats data ---------- + +func _on_decks_changed(loaded_decks: Array) -> void: + decks = loaded_decks + _refresh_deck_panel() + + +func _refresh_deck_panel() -> void: + if deck_ring == null: + return + var dark := UIThemeService.is_dark_mode() + var deck_name := "All decks" + var total := 0 + var mastered := 0 + var is_system := false + if deck_id.is_empty(): + for summary in decks: + total += int(summary.get("total", 0)) + mastered += int(summary.get("mastered", 0)) + else: + for summary in decks: + var deck: Dictionary = summary.get("deck", {}) + if str(deck.get("id", "")) == deck_id: + deck_name = str(deck.get("name", "Deck")) + total = int(summary.get("total", 0)) + mastered = int(summary.get("mastered", 0)) + is_system = bool(deck.get("isSystem", false)) + break + deck_name_label.text = deck_name + deck_system_badge.visible = is_system + var fraction := float(mastered) / float(total) if total > 0 else 0.0 + deck_ring.configure(fraction, "%d%%" % roundi(fraction * 100.0), "Mastery", Brand.ROYAL_BLUE, dark) + deck_mastered_label.text = "%d / %d cards mastered" % [mastered, total] + + +func _on_counts_changed(counts: Dictionary) -> void: + latest_counts = counts + _refresh_stats_panel() + + +func _refresh_stats_panel() -> void: + if stats_new_ring == null or latest_counts.is_empty(): + return + var dark := UIThemeService.is_dark_mode() + var due := int(latest_counts.get("due", 0)) + var new_available := int(latest_counts.get("newAvailable", 0)) + var learning := int(latest_counts.get("learning", 0)) + var review := int(latest_counts.get("review", 0)) + var streak := int(latest_counts.get("currentStreakDays", 0)) + var reviews_today := int(latest_counts.get("reviewsToday", 0)) + var daily_goal := int(latest_counts.get("dailyGoal", 0)) + + var new_fraction := float(new_available) / float(new_available + due) if (new_available + due) > 0 else 0.0 + stats_new_ring.configure(new_fraction, str(new_available), "New", Brand.ROYAL_BLUE, dark) + stats_due_label.text = str(due) + stats_learning_label.text = str(learning) + stats_review_label.text = str(review) + streak_label.text = "%d day%s" % [streak, "" if streak == 1 else "s"] + daily_goal_label.text = "%d / %d" % [reviews_today, daily_goal] + daily_goal_bar.max_value = maxf(1.0, float(daily_goal)) + daily_goal_bar.value = float(reviews_today) + + +# ---------- Session history / navigation ---------- + +func _push_history(action: String, card_id: String, rating: String = "", client_review_id: String = "") -> void: + var entry := { + "note": current_note, "card": current_card, "rating_options": rating_options, + "action": action, "card_id": card_id + } + if not rating.is_empty(): + entry["rating"] = rating + entry["client_review_id"] = client_review_id + session_history.append(entry) + history_cursor = session_history.size() + + +func _snapshot_live_before_browsing() -> void: + live_note = current_note + live_card = current_card + live_rating_options = rating_options + live_revealed = revealed + live_due_remaining = due_remaining + live_new_remaining = new_remaining + + +func _return_to_live() -> void: + history_cursor = session_history.size() + current_note = live_note + current_card = live_card + rating_options = live_rating_options + due_remaining = live_due_remaining + new_remaining = live_new_remaining + undo_button.visible = false + revealed = false + if current_note.is_empty() and current_card.is_empty(): + _render_empty({"dueRemaining": due_remaining, "newRemaining": new_remaining}) + else: + _render_card() + if live_revealed: + _reveal() + _update_card_position_label(history_cursor) + _update_progress_pips(history_cursor) + _update_footer_button_states() + + +func _render_history_entry(index: int) -> void: + var entry: Dictionary = session_history[index] + current_note = entry.get("note", {}) + current_card = entry.get("card", {}) + rating_options = entry.get("rating_options", []) + revealed = false + _render_card() + _reveal() + grade_row.visible = false + tap_hint_label.visible = false + var is_graded_entry: bool = entry.get("action", "") == "graded" + var is_last_graded_card: bool = str(entry.get("card_id", "")) == last_card_id + var has_undoable_review: bool = not last_client_review_id.is_empty() + undo_button.visible = is_graded_entry and is_last_graded_card and has_undoable_review + undo_button.disabled = false + undo_button.text = "Undo this rating" + _update_card_position_label(index) + _update_progress_pips(index) + _update_footer_button_states() + + +func _go_previous() -> void: + if history_cursor <= 0: + return + if history_cursor == session_history.size(): + _snapshot_live_before_browsing() + history_cursor -= 1 + _render_history_entry(history_cursor) + + +func _go_next() -> void: + if history_cursor >= session_history.size(): + _skip_current_card() + return + history_cursor += 1 + if history_cursor >= session_history.size(): + _return_to_live() + else: + _render_history_entry(history_cursor) + + +func _skip_current_card() -> void: + if current_card.is_empty() or awaiting_next: + return + var card_id := str(current_card.get("id", "")) + _push_history("skipped", card_id) + if not seen_card_ids.has(card_id): + seen_card_ids.append(card_id) + _load_next() + _render_loading() + + +func _update_card_position_label(position_index: int) -> void: + card_position_label.visible = session_total > 0 + if session_total > 0: + card_position_label.text = "Card %d of %d" % [position_index + 1, maxi(session_total, position_index + 1)] + + +func _update_progress_pips(position_index: int) -> void: + var dark := UIThemeService.is_dark_mode() + var fraction := 0.0 if session_total <= 0 else float(position_index) / float(session_total) + var filled := clampi(floori(fraction * progress_pips.size()), 0, progress_pips.size()) + for i in progress_pips.size(): + var pip: PanelContainer = progress_pips[i] + pip.add_theme_stylebox_override("panel", _pip_box(i < filled, dark)) + + +func _update_footer_button_states() -> void: + var at_live := history_cursor == session_history.size() + previous_button.disabled = history_cursor <= 0 + next_button.disabled = at_live and current_card.is_empty() + + +func _load_next() -> void: + awaiting_next = true + FlashcardService.request_next_card(deck_id, seen_card_ids, study_day_start_utc, shuffle_enabled) + + +func _on_next_card_loaded(card: Dictionary) -> void: + awaiting_next = false + var note: Variant = card.get("note") + var schedule: Variant = card.get("card") + if typeof(note) != TYPE_DICTIONARY or typeof(schedule) != TYPE_DICTIONARY: + current_note = {} + current_card = {} + _render_empty(card) + _update_footer_button_states() + return + current_note = note + current_card = schedule + rating_options = card.get("ratingOptions", []) + due_remaining = int(card.get("dueRemaining", 0)) + new_remaining = int(card.get("newRemaining", 0)) + if session_total < 0: + session_total = due_remaining + new_remaining + history_cursor = session_history.size() + revealed = false + loaded_started_ms = Time.get_ticks_msec() + _render_card() + _update_card_position_label(history_cursor) + _update_progress_pips(history_cursor) + _update_footer_button_states() + + +func _on_next_card_failed(message: String) -> void: + awaiting_next = false + progress_label.text = message + + +func _render_loading() -> void: + empty_label.visible = false + front_label.visible = true + front_label.text = "Loading..." + _hide_answer() + tap_hint_label.visible = false + grade_row.visible = false + + +func _render_empty(card: Dictionary) -> void: + empty_label.visible = true + front_label.visible = false + _hide_answer() + tap_hint_label.visible = false + grade_row.visible = false + progress_label.text = "0 due" + if typeof(card) == TYPE_DICTIONARY: + progress_label.text = "%d due, %d new available" % [int(card.get("dueRemaining", 0)), int(card.get("newRemaining", 0))] + + +func _hide_answer() -> void: + divider.visible = false + answer_caption.visible = false + back_label.visible = false + extra_caption.visible = false + extra_label.visible = false + + +func _render_card() -> void: + empty_label.visible = false + var note_type := str(current_note.get("type", "basic")) + var variant := int(current_card.get("variant", 0)) + if note_type == "cloze": + front_label.text = Cloze.render_front(str(current_note.get("prompt", "")), variant) + else: + front_label.text = str(current_note.get("prompt", "")) + front_label.visible = true + _hide_answer() + divider.visible = true + tap_hint_label.visible = true + grade_row.visible = false + progress_label.text = "%d due, %d new available" % [due_remaining, new_remaining] + + +func _reveal() -> void: + if current_note.is_empty(): + return + revealed = true + reveal_started_ms = Time.get_ticks_msec() + var note_type := str(current_note.get("type", "basic")) + if note_type == "cloze": + back_label.text = Cloze.render_back(str(current_note.get("prompt", ""))) + else: + back_label.text = str(current_note.get("answer", "")) + var extra := str(current_note.get("extra", "")) + divider.visible = true + answer_caption.visible = true + back_label.visible = true + extra_caption.visible = not extra.is_empty() + extra_label.visible = not extra.is_empty() + extra_label.text = extra + tap_hint_label.visible = false + grade_row.visible = true + for rating in grade_buttons: + var button: Button = grade_buttons[rating] + button.text = "%s\n%s" % [rating.capitalize(), _interval_label_for(rating)] + + +func _on_card_panel_input(event: InputEvent) -> void: + if history_cursor != session_history.size() or revealed: + return + if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT: + _reveal() + + +func _interval_label_for(rating: String) -> String: + for option in rating_options: + if str(option.get("rating", "")) == rating: + return str(option.get("intervalLabel", "")) + return "" + + +func _grade(rating: String) -> void: + if current_card.is_empty() or history_cursor != session_history.size(): + return + var card_id := str(current_card.get("id", "")) + var expected_revision := int(current_card.get("revision", 0)) + var duration_seconds := int(float(Time.get_ticks_msec() - loaded_started_ms) / 1000.0) + var client_review_id := FlashcardService.submit_review(card_id, rating, expected_revision, duration_seconds, study_day_start_utc) + last_client_review_id = client_review_id + last_card_id = card_id + _push_history("graded", card_id, rating, client_review_id) + if not seen_card_ids.has(card_id): + seen_card_ids.append(card_id) + _load_next() + _render_loading() + FlashcardService.fetch_counts() + + +func _on_review_synced(_card_id: String, _updated_card: Dictionary) -> void: + pass + + +func _undo() -> void: + if last_client_review_id.is_empty(): + return + undo_button.disabled = true + FlashcardService.undo_last_review(last_client_review_id, func(_restored: Dictionary): + var undone_card_id := last_card_id + last_client_review_id = "" + last_card_id = "" + seen_card_ids.erase(undone_card_id) + _return_to_live() + _load_next() + _render_loading() + FlashcardService.fetch_counts()) + + +func _on_review_paused(message: String) -> void: + paused_banner.text = message + paused_banner.visible = true + + +func _on_review_resumed() -> void: + paused_banner.visible = false + if awaiting_next: + _load_next() + + +func _input(event: InputEvent) -> void: + if not visible or not is_visible_in_tree(): + return + if not (event is InputEventKey) or not event.pressed or event.echo: + return + if history_cursor != session_history.size(): + return + if not revealed: + if event.keycode == KEY_SPACE: + _reveal() + get_viewport().set_input_as_handled() + return + var rating := rating_for_keycode(event.keycode) + if not rating.is_empty(): + _grade(rating) + get_viewport().set_input_as_handled() + + +static func rating_for_keycode(keycode: Key) -> String: + match keycode: + KEY_1: return "again" + KEY_2: return "hard" + KEY_3: return "good" + KEY_4: return "easy" + _: return "" diff --git a/scripts/ui/flashcards_study.gd.uid b/scripts/ui/flashcards_study.gd.uid new file mode 100644 index 0000000..d340f2b --- /dev/null +++ b/scripts/ui/flashcards_study.gd.uid @@ -0,0 +1 @@ +uid://cd26fs2ry8iij diff --git a/scripts/ui/login_screen.gd b/scripts/ui/login_screen.gd index d72a3d9..432f99f 100644 --- a/scripts/ui/login_screen.gd +++ b/scripts/ui/login_screen.gd @@ -4,6 +4,9 @@ const Brand := preload("res://scripts/ui/medmind_style.gd") const EndpointConfig := preload("res://scripts/autoload/api_endpoint_config.gd") const AUTH_BASE := "https://us3q.ranaze.com/api/Auth" const EXTERNAL_AUTH_STORAGE_PREFIX := "ranaze_external_auth_url_" +const STEAM_DARK_ICON := preload("res://assets/icons/brands/steam-dark.svg") +const STEAM_LIGHT_ICON := preload("res://assets/icons/brands/steam-light.svg") +const GOOGLE_ICON := preload("res://assets/icons/brands/google.svg") var main: Control var auth_base_url := AUTH_BASE @@ -258,6 +261,8 @@ func _build_login_view() -> VBoxContainer: for provider in [["steam", "Steam"], ["google", "Google"]]: var external := Button.new() external.text = "Sign in with %s" % provider[1] + external.icon = GOOGLE_ICON if provider[0] == "google" else STEAM_LIGHT_ICON + external.add_theme_constant_override("icon_max_width", 20) external.custom_minimum_size = Vector2(0, 68) external.visible = false external.pressed.connect(_start_external.bind(str(provider[0]), str(provider[1]))) @@ -550,6 +555,8 @@ func _apply_theme() -> void: if not button.text.begins_with("Sign in with "): button.add_theme_stylebox_override("normal", Brand.gradient_button_box()) button.add_theme_color_override("font_color", Color.WHITE) + if external_buttons.has("steam"): + (external_buttons["steam"] as Button).icon = STEAM_DARK_ICON if dark else STEAM_LIGHT_ICON var theme_normal := StyleBoxFlat.new() theme_normal.bg_color = Color("111b30") if dark else Brand.LIGHT_SURFACE theme_normal.border_color = Color("33415d") if dark else Brand.LIGHT_BORDER diff --git a/scripts/ui/main.gd b/scripts/ui/main.gd index 71533f4..511a711 100644 --- a/scripts/ui/main.gd +++ b/scripts/ui/main.gd @@ -5,6 +5,7 @@ const Brand := preload("res://scripts/ui/medmind_style.gd") const NavigationRoutes := preload("res://scripts/ui/navigation_routes.gd") var screens := {} +var _account_profile_synced := false var header_panel: PanelContainer var header_title: Label var environment_badge: Label @@ -153,7 +154,7 @@ func _build_ui() -> void: environment_badge = Label.new() environment_badge.text = "UAT" environment_badge.tooltip_text = "User acceptance testing environment" - environment_badge.visible = str(ProjectSettings.get_setting("step3/deployment_environment", "development")) == "uat" + environment_badge.visible = Brand.is_uat_environment() environment_badge.add_theme_font_override("font", Brand.font) environment_badge.add_theme_font_size_override("font_size", 13) environment_badge.add_theme_color_override("font_color", Color("052e16")) @@ -247,6 +248,12 @@ func _build_ui() -> void: screens_container.add_child(selector) screens["selection"] = selector + var dynamic_questions := preload("res://scripts/ui/dynamic_questions_screen.gd").new() + dynamic_questions.name = "DynamicQuestionsScreen" + dynamic_questions.main = self + screens_container.add_child(dynamic_questions) + screens["dynamic_questions"] = dynamic_questions + var map := preload("res://scripts/ui/study_map.gd").new() map.name = "StudyMap" map.main = self @@ -310,6 +317,13 @@ func _build_ui() -> void: var ccs_history := preload("res://scripts/ui/ccs_history.gd").new() ccs_history.name = "CcsHistory"; ccs_history.main = self; screens_container.add_child(ccs_history); screens["ccs_history"] = ccs_history + var flashcards_hub := preload("res://scripts/ui/flashcards_hub.gd").new() + flashcards_hub.name = "FlashcardsHub"; flashcards_hub.main = self + screens_container.add_child(flashcards_hub); screens["flashcards_hub"] = flashcards_hub + var flashcards_study := preload("res://scripts/ui/flashcards_study.gd").new() + flashcards_study.name = "FlashcardsStudy"; flashcards_study.main = self + screens_container.add_child(flashcards_study); screens["flashcards_study"] = flashcards_study + var profile := preload("res://scripts/ui/progress_profile.gd").new() profile.name = "ProgressProfile" profile.main = self @@ -402,8 +416,10 @@ func _populate_navigation(container: VBoxContainer, include_brand: bool) -> void _add_nav_button(container, "home", "Dashboard", "chart-no-axes-column-increasing", true) _add_nav_button(container, "question_bank", "Question Bank", "clipboard-list", true) - _add_nav_button(container, "ccs", "CCS Cases", "stethoscope", true) + _add_nav_button(container, "dynamic_questions", "Dynamic Questions", "activity", true, Brand.is_uat_environment()) + _add_nav_button(container, "ccs", "CCS Cases", "stethoscope", true, Brand.is_uat_environment()) _add_nav_button(container, "exams", "Exam Simulations", "clipboard-list", true) + _add_nav_button(container, "flashcards", "Flashcards", "clipboard-plus", true) _add_nav_button(container, "bookmarks", "Bookmarks", "bookmark", true) _add_nav_button(container, "notes", "Notes", "notebook-pen", true) @@ -417,7 +433,7 @@ func _populate_navigation(container: VBoxContainer, include_brand: bool) -> void _add_nav_button(container, "logout", "Log out", "log-out", true) -func _add_nav_button(container: VBoxContainer, key: String, label_text: String, icon_name: String, enabled: bool) -> void: +func _add_nav_button(container: VBoxContainer, key: String, label_text: String, icon_name: String, enabled: bool, visible_flag: bool = true) -> void: var button := Button.new() button.text = label_text button.name = "Nav%s" % key.to_pascal_case() @@ -431,6 +447,7 @@ func _add_nav_button(container: VBoxContainer, key: String, label_text: String, button.add_theme_font_size_override("font_size", 18) button.disabled = not enabled button.tooltip_text = "Coming Soon" if not enabled else label_text + button.visible = visible_flag button.pressed.connect(_on_navigation_pressed.bind(key)) container.add_child(button) if not nav_buttons.has(key): @@ -445,8 +462,10 @@ func _on_navigation_pressed(key: String) -> void: match key: "home": show_screen("dashboard") "question_bank": show_screen("selection", "question_bank") + "dynamic_questions": show_screen("dynamic_questions") "exams": show_screen("simulation_history") "ccs": show_screen("ccs_hub") + "flashcards": show_screen("flashcards_study") "performance": show_screen("profile") "study_plan": show_screen("map") "settings": show_screen("settings") @@ -462,8 +481,11 @@ func _update_header_title(screen_name: String) -> void: var titles := { "dashboard": "Dashboard", "selection": "Question Bank", + "dynamic_questions": "Dynamic Questions", "simulation_history": "Exam Simulations", "ccs_hub": "CCS Cases", + "flashcards_hub": "Flashcards", + "flashcards_study": "Flashcards", "profile": "Profile", "settings": "Settings", "notes": "Notes", @@ -706,7 +728,7 @@ func _input(event: InputEvent) -> void: _close_account_menu() get_viewport().set_input_as_handled() return - if current_screen_name in ["player", "ccs_player"]: + if current_screen_name in ["player", "ccs_player", "flashcards_study"]: _toggle_app_menu() get_viewport().set_input_as_handled() @@ -744,6 +766,12 @@ func open_legal_document(kind: String) -> void: func _on_auth_changed() -> void: _refresh_header_controls() + if AuthState.is_logged_in: + if not _account_profile_synced: + _account_profile_synced = true + AccountProfileService.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"]: show_screen("legal_consent") @@ -884,9 +912,13 @@ func _toggle_app_menu() -> void: app_menu.visible = true app_menu.size = app_menu.custom_minimum_size app_menu.position = (get_viewport_rect().size - app_menu.size) * 0.5 - menu_exit_button.visible = (current_screen_name == "player" and BlockSessionService.block_started) or current_screen_name == "ccs_player" + menu_exit_button.visible = (current_screen_name == "player" and BlockSessionService.block_started) or current_screen_name in ["ccs_player", "flashcards_study"] if menu_exit_button.visible: - menu_exit_button.text = "Leave Case" if current_screen_name == "ccs_player" else "Leave (timer continues)" if BlockSessionService.session_kind == "simulation" else "Exit Run" + menu_exit_button.text = ( + "Abandon CCS Case" if current_screen_name == "ccs_player" + else "Exit Study Session" if current_screen_name == "flashcards_study" + else "Leave (timer continues)" if BlockSessionService.session_kind == "simulation" + else "Exit Run") Style.style_chip(menu_exit_button, Style.AMBER) if current_screen_name == "player": BlockSessionService.pause_current_question_timer() @@ -903,13 +935,18 @@ func _exit_run() -> void: var ccs_player = screens.get("ccs_player") if ccs_player != null and ccs_player.has_method("leave_case"): ccs_player.leave_case() return + if current_screen_name == "flashcards_study": + show_screen("flashcards_hub") + return var was_core_run := BlockSessionService.session_kind == "core" var was_review_run := BlockSessionService.session_kind == "review" + var was_dynamic_run := BlockSessionService.session_kind == "dynamic" if BlockSessionService.block_started and BlockSessionService.session_kind != "simulation": BlockSessionService.finish_block() elif BlockSessionService.session_kind == "simulation": BlockSessionService.block_started = false - if was_review_run: + if was_review_run or was_dynamic_run: + DashboardService.refresh() show_screen("dashboard") else: show_screen("selection", "exam" if was_core_run else "step3") diff --git a/scripts/ui/medmind_style.gd b/scripts/ui/medmind_style.gd index 69c86fc..3b7dd4d 100644 --- a/scripts/ui/medmind_style.gd +++ b/scripts/ui/medmind_style.gd @@ -22,11 +22,12 @@ const LIGHT_MUTED := Color("64748b") static var font: Font = preload("res://assets/fonts/Inter-Variable.ttf") -static func ranaze_mark_texture() -> AtlasTexture: - var texture := AtlasTexture.new() - texture.atlas = preload("res://assets/branding/ranaze-mark.png") - texture.region = Rect2(400, 150, 736, 736) - return texture +static func ranaze_mark_texture() -> Texture2D: + return preload("res://assets/branding/ranaze-mark.png") + + +static func is_uat_environment() -> bool: + return str(ProjectSettings.get_setting("step3/deployment_environment", "development")) == "uat" static func text_color(dark: bool) -> Color: @@ -146,6 +147,12 @@ static func icon_circle_box(dark: bool, accent: Color, radius: int, filled: bool box.border_color = Color(accent, 0.48 if dark else 0.18) box.set_border_width_all(1) box.set_corner_radius_all(radius) + # A perfectly circular StyleBoxFlat (radius == half the box size, no straight edges + # between corners) is a known Godot degenerate case that can draw a faint seam through + # the middle when antialiasing is on; more corner segments plus disabling AA renders a + # clean circle instead at these small badge sizes. + box.corner_detail = 20 + box.anti_aliasing = false return box diff --git a/scripts/ui/navigation_routes.gd b/scripts/ui/navigation_routes.gd index 0c53738..8070cec 100644 --- a/scripts/ui/navigation_routes.gd +++ b/scripts/ui/navigation_routes.gd @@ -10,11 +10,15 @@ static func key_for_screen(screen_name: String, payload: Variant = null) -> Stri elif screen_name in ["map", "setup", "review"]: key = "study_plan" if screen_name == "map" else "question_bank" elif screen_name == "player": - key = "question_bank" + key = "dynamic_questions" if BlockSessionService.session_kind == "dynamic" else "question_bank" + elif screen_name == "dynamic_questions": + key = "dynamic_questions" elif screen_name.begins_with("simulation_"): key = "exams" elif screen_name.begins_with("ccs_"): key = "ccs" + elif screen_name.begins_with("flashcards_"): + key = "flashcards" elif screen_name == "profile": key = "performance" elif screen_name == "settings": diff --git a/scripts/ui/question_player.gd b/scripts/ui/question_player.gd index 721c7e9..0067286 100644 --- a/scripts/ui/question_player.gd +++ b/scripts/ui/question_player.gd @@ -2,12 +2,18 @@ extends VBoxContainer 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 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") const LABS_LIGHT_ICON := preload("res://assets/icons/medmind/labs-light.svg") const REPORT_QUESTION_DARK_ICON := preload("res://assets/icons/medmind/report-question-dark.svg") const REPORT_QUESTION_LIGHT_ICON := preload("res://assets/icons/medmind/report-question-light.svg") +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 FEEDBACK_FADE_SECONDS := 0.45 const CORRECT_ADVANCE_DELAY_SECONDS := 1.25 const TEXT_SCALE_MIN := 0.85 @@ -59,20 +65,23 @@ var awaiting_next_question := false var simulation_submit_pending := false var progress_button: Button +var progress_bar: ProgressBar +var progress_percent_label: Label +var bottom_progress_label: Label +var bottom_progress_bar: ProgressBar +var header_wordmark_label: Label +var category_label: Label var timer_label: Label +var timer_caption_label: Label var stem_label: RichTextLabel var lead_label: Label var explanation_label: RichTextLabel var references_container: VBoxContainer var correct_review_panel: PanelContainer -var correct_answer_heading: Label var teaching_cards: HFlowContainer var review_followups_section: VBoxContainer var review_followups_content: VBoxContainer -var wrong_options_section: VBoxContainer -var wrong_options_content: VBoxContainer var review_followup_states: Array = [] -var wrong_option_expanded := {} var question_scroll: ScrollContainer var question_elo_label: Label var next_button: Button @@ -80,16 +89,20 @@ var skip_button: Button var text_smaller_button: Button var text_larger_button: Button var labs_button: Button +var calculator_button: Button var clear_highlight_button: Button var flag_button: Button var report_button: Button var notes_button: Button +var flashcard_button: Button +var flashcard_prefill_dialog: Control var bookmark_button: Button var app_feedback_button: Button var theme_button: Button var question_toolbar: HFlowContainer var header_stack: VBoxContainer var header_row: HBoxContainer +var header_brand: HBoxContainer var header_toolbar_host: HBoxContainer var nav_palette: GridContainer var navigator_popup: PopupPanel @@ -101,10 +114,13 @@ var question_content: VBoxContainer var media_container: VBoxContainer var timer_bar: ProgressBar var choice_badges: Array[Label] = [] +var choice_badge_textures: Array[TextureRect] = [] +var choice_selected_icons: Array[TextureRect] = [] var feedback_fog: ColorRect var labs_overlay: Control var labs_panel: PanelContainer var labs_content: VBoxContainer +var calculator_panel var report_overlay: Control var report_panel: PanelContainer var report_form_content: VBoxContainer @@ -139,9 +155,13 @@ var green_fog_time := 0.0 var correct_advance_delay := 0.0 var shake_time := 0.0 var shake_origin := Vector2.ZERO +var untimed_elapsed_start_msec := 0 func _ready() -> void: theme_service = get_node("/root/UIThemeService") + question_text_scale = _synced_question_text_scale() + if not AccountProfileService.profile_changed.is_connected(_on_account_profile_changed): + AccountProfileService.profile_changed.connect(_on_account_profile_changed) set_anchors_preset(Control.PRESET_FULL_RECT) add_theme_constant_override("separation", 10) if not QuestionBankService.choice_percentages_updated.is_connected(_on_choice_percentages_updated): @@ -154,6 +174,14 @@ func _ready() -> void: QuestionBankService.question_report_submitted.connect(_on_question_report_submitted) if not QuestionBankService.question_report_failed.is_connected(_on_question_report_failed): QuestionBankService.question_report_failed.connect(_on_question_report_failed) + if not DynamicQuestionService.question_loaded.is_connected(_on_dynamic_question_loaded): + DynamicQuestionService.question_loaded.connect(_on_dynamic_question_loaded) + if not DynamicQuestionService.request_failed.is_connected(_on_dynamic_question_failed): + DynamicQuestionService.request_failed.connect(_on_dynamic_question_failed) + if not DynamicQuestionService.report_submitted.is_connected(_on_question_report_submitted): + DynamicQuestionService.report_submitted.connect(_on_question_report_submitted) + if not DynamicQuestionService.report_failed.is_connected(_on_question_report_failed): + DynamicQuestionService.report_failed.connect(_on_question_report_failed) if not QuestionBankService.questions_changed.is_connected(_on_random_question_changed): QuestionBankService.questions_changed.connect(_on_random_question_changed) if not QuestionBankService.questions_load_failed.is_connected(_on_random_question_failed): @@ -194,22 +222,59 @@ func _ready() -> void: header_stack = VBoxContainer.new() header_stack.add_theme_constant_override("separation", 8) card_content.add_child(header_stack) + + header_brand = HBoxContainer.new() + header_brand.add_theme_constant_override("separation", 8) + header_stack.add_child(header_brand) + var header_brand_mark := TextureRect.new() + header_brand_mark.texture = Brand.ranaze_mark_texture() + header_brand_mark.custom_minimum_size = Vector2(28, 28) + header_brand_mark.expand_mode = TextureRect.EXPAND_IGNORE_SIZE + header_brand_mark.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED + header_brand.add_child(header_brand_mark) + header_wordmark_label = Label.new() + header_wordmark_label.text = "RanaZe" + header_wordmark_label.add_theme_font_override("font", Brand.font) + header_wordmark_label.add_theme_font_size_override("font_size", 18) + header_brand.add_child(header_wordmark_label) + + var header_brand_spacer := Control.new() + header_brand_spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL + header_brand.add_child(header_brand_spacer) + header_row = HBoxContainer.new() - header_row.add_theme_constant_override("separation", 10) + header_row.add_theme_constant_override("separation", 16) header_stack.add_child(header_row) + var progress_group := VBoxContainer.new() + progress_group.add_theme_constant_override("separation", 2) + progress_group.size_flags_vertical = Control.SIZE_SHRINK_CENTER + header_row.add_child(progress_group) + progress_button = Button.new() progress_button.flat = true progress_button.alignment = HORIZONTAL_ALIGNMENT_LEFT - progress_button.custom_minimum_size = Vector2(126, 34) + progress_button.custom_minimum_size = Vector2(126, 20) progress_button.tooltip_text = "Open question navigator" _set_scaled_button_font(progress_button, 14) progress_button.pressed.connect(_toggle_question_navigator) - header_row.add_child(progress_button) + progress_group.add_child(progress_button) - var header_spacer := Control.new() - header_spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL - header_row.add_child(header_spacer) + var progress_bar_row := HBoxContainer.new() + progress_bar_row.add_theme_constant_override("separation", 8) + progress_group.add_child(progress_bar_row) + + progress_bar = ProgressBar.new() + progress_bar.show_percentage = false + progress_bar.min_value = 0.0 + progress_bar.max_value = 100.0 + progress_bar.custom_minimum_size = Vector2(120, 6) + progress_bar.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN + progress_bar_row.add_child(progress_bar) + + progress_percent_label = Label.new() + _set_scaled_label(progress_percent_label, 13, Brand.SLATE) + progress_bar_row.add_child(progress_percent_label) timer_label = Label.new() _set_scaled_label(timer_label, 14, Brand.SLATE) @@ -221,6 +286,10 @@ func _ready() -> void: question_toolbar.add_theme_constant_override("v_separation", 8) question_toolbar.alignment = FlowContainer.ALIGNMENT_END + # All the underlying buttons are constructed up front (same as before) so every + # other place in this file that mutates them by reference - theming, visibility, + # tooltips, badge state - keeps working unmodified. Only *where* each one is + # parented (directly in question_toolbar vs. reparented into a popup) changes. theme_button = Button.new() theme_button.custom_minimum_size = Vector2(44, 44) theme_button.size_flags_horizontal = Control.SIZE_SHRINK_CENTER @@ -230,7 +299,6 @@ func _ready() -> void: theme_button.focus_mode = Control.FOCUS_NONE UISoundService.set_button_sound(theme_button, "toggle") theme_button.pressed.connect(theme_service.toggle_theme) - question_toolbar.add_child(theme_button) _refresh_theme_button() text_smaller_button = Button.new() @@ -240,7 +308,6 @@ func _ready() -> void: Style.style_chip(text_smaller_button, Style.CYAN) UISoundService.set_button_sound(text_smaller_button, "toggle") text_smaller_button.pressed.connect(func(): _adjust_question_text_scale(-TEXT_SCALE_STEP)) - question_toolbar.add_child(text_smaller_button) text_larger_button = Button.new() text_larger_button.text = "A+" @@ -249,26 +316,34 @@ func _ready() -> void: Style.style_chip(text_larger_button, Style.CYAN) UISoundService.set_button_sound(text_larger_button, "toggle") text_larger_button.pressed.connect(func(): _adjust_question_text_scale(TEXT_SCALE_STEP)) - question_toolbar.add_child(text_larger_button) + + calculator_button = Button.new() + calculator_button.name = "CalculatorButton" + calculator_button.tooltip_text = "Open calculator" + calculator_button.icon = preload("res://assets/icons/lucide/calculator.svg") + calculator_button.custom_minimum_size = Vector2(44, 44) + calculator_button.add_theme_constant_override("icon_max_width", 22) + calculator_button.alignment = HORIZONTAL_ALIGNMENT_CENTER + calculator_button.icon_alignment = HORIZONTAL_ALIGNMENT_CENTER + calculator_button.focus_mode = Control.FOCUS_NONE + UISoundService.set_button_sound(calculator_button, "open") + calculator_button.pressed.connect(_toggle_calculator) labs_button = Button.new() labs_button.tooltip_text = "Review lab results" labs_button.custom_minimum_size = Vector2(44, 44) UISoundService.set_button_sound(labs_button, "open") labs_button.pressed.connect(_show_labs_modal) - question_toolbar.add_child(labs_button) clear_highlight_button = Button.new() clear_highlight_button.tooltip_text = "Clear highlights" clear_highlight_button.custom_minimum_size = Vector2(44, 44) UISoundService.set_button_sound(clear_highlight_button, "eliminate") clear_highlight_button.pressed.connect(_clear_current_highlights) - question_toolbar.add_child(clear_highlight_button) flag_button = Button.new() flag_button.toggle_mode = true flag_button.tooltip_text = "Flag question" - flag_button.icon = _make_flag_icon() flag_button.custom_minimum_size = Vector2(44, 44) Style.style_chip(flag_button, Style.AMBER) UISoundService.set_button_sound(flag_button, "toggle") @@ -278,7 +353,21 @@ func _ready() -> void: if BlockSessionService.session_kind == "simulation": _save_simulation_current_response() ) - question_toolbar.add_child(flag_button) + + flashcard_button = Button.new() + flashcard_button.tooltip_text = "Save as flashcard (available once answered)" + flashcard_button.icon = preload("res://assets/icons/lucide/clipboard-plus.svg") + flashcard_button.custom_minimum_size = Vector2(44, 44) + flashcard_button.add_theme_constant_override("icon_max_width", 22) + flashcard_button.alignment = HORIZONTAL_ALIGNMENT_CENTER + flashcard_button.icon_alignment = HORIZONTAL_ALIGNMENT_CENTER + flashcard_button.focus_mode = Control.FOCUS_NONE + flashcard_button.disabled = true + UISoundService.set_button_sound(flashcard_button, "toggle") + flashcard_button.pressed.connect(_toggle_flashcard_current_question) + flashcard_prefill_dialog = FlashcardPrefillDialog.new() + flashcard_prefill_dialog.name = "FlashcardPrefillDialog" + add_child(flashcard_prefill_dialog) bookmark_button = Button.new() bookmark_button.tooltip_text = "Save question" @@ -290,14 +379,12 @@ func _ready() -> void: bookmark_button.focus_mode = Control.FOCUS_NONE UISoundService.set_button_sound(bookmark_button, "toggle") bookmark_button.pressed.connect(_toggle_bookmark_current_question) - question_toolbar.add_child(bookmark_button) report_button = Button.new() report_button.tooltip_text = "Report question" report_button.custom_minimum_size = Vector2(44, 44) UISoundService.set_button_sound(report_button, "open") report_button.pressed.connect(_toggle_report_current_question) - question_toolbar.add_child(report_button) notes_button = Button.new() notes_button.tooltip_text = "Open notes" @@ -309,7 +396,6 @@ func _ready() -> void: notes_button.focus_mode = Control.FOCUS_NONE UISoundService.set_button_sound(notes_button, "open") notes_button.pressed.connect(func(): main.open_notes_dialog()) - question_toolbar.add_child(notes_button) app_feedback_button = Button.new() app_feedback_button.text = "Feedback" @@ -317,14 +403,28 @@ func _ready() -> void: app_feedback_button.custom_minimum_size = Vector2(92, 44) Style.style_chip(app_feedback_button, Style.CYAN) app_feedback_button.pressed.connect(func(): main.open_feedback_dialog()) + + # 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(text_smaller_button) + question_toolbar.add_child(text_larger_button) + question_toolbar.add_child(calculator_button) + question_toolbar.add_child(labs_button) + question_toolbar.add_child(clear_highlight_button) + question_toolbar.add_child(flag_button) + question_toolbar.add_child(flashcard_button) + question_toolbar.add_child(bookmark_button) + question_toolbar.add_child(report_button) + question_toolbar.add_child(notes_button) + question_toolbar.add_child(theme_button) question_toolbar.add_child(app_feedback_button) - question_toolbar.move_child(labs_button, 0) - question_toolbar.move_child(theme_button, question_toolbar.get_child_count() - 2) header_toolbar_host = HBoxContainer.new() header_toolbar_host.alignment = BoxContainer.ALIGNMENT_END header_toolbar_host.add_child(question_toolbar) - header_row.add_child(header_toolbar_host) + header_brand.add_child(header_toolbar_host) navigator_popup = PopupPanel.new() navigator_popup.name = "QuestionNavigator" @@ -364,15 +464,22 @@ func _ready() -> void: var content_margin := MarginContainer.new() content_margin.size_flags_horizontal = 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_right", 12) question_scroll.add_child(content_margin) question_content = VBoxContainer.new() question_content.size_flags_horizontal = Control.SIZE_EXPAND_FILL + question_content.size_flags_vertical = Control.SIZE_EXPAND_FILL question_content.add_theme_constant_override("separation", 12) content_margin.add_child(question_content) + category_label = Label.new() + category_label.visible = false + _set_scaled_label(category_label, 14, Brand.SKY_BLUE) + question_content.add_child(category_label) + stem_label = RichTextLabel.new() stem_label.fit_content = true stem_label.bbcode_enabled = true @@ -404,14 +511,18 @@ func _ready() -> void: _set_scaled_label(question_elo_label, 16, Style.AMBER) question_content.add_child(question_elo_label) + var choices_container := VBoxContainer.new() + choices_container.add_theme_constant_override("separation", 10) + question_content.add_child(choices_container) + for index in range(5): var choice_row := VBoxContainer.new() - choice_row.add_theme_constant_override("separation", 6) - question_content.add_child(choice_row) + choice_row.add_theme_constant_override("separation", 0) + choices_container.add_child(choice_row) choice_rows.append(choice_row) var button := Button.new() - button.custom_minimum_size = Vector2(0, 54) + button.custom_minimum_size = Vector2(0, 68) button.alignment = HORIZONTAL_ALIGNMENT_LEFT Style.style_answer_button(button) UISoundService.set_button_sound(button, "select") @@ -420,20 +531,36 @@ func _ready() -> void: button.gui_input.connect(func(event: InputEvent, i := index): _choice_gui_input(event, i)) choice_buttons.append(button) + var badge_wrap := Control.new() + badge_wrap.mouse_filter = Control.MOUSE_FILTER_IGNORE + badge_wrap.anchor_left = 0.0 + badge_wrap.anchor_top = 0.0 + badge_wrap.anchor_right = 0.0 + badge_wrap.anchor_bottom = 0.0 + badge_wrap.offset_left = 16 + badge_wrap.offset_top = 17 + badge_wrap.offset_right = 50 + badge_wrap.offset_bottom = 51 + button.add_child(badge_wrap) + + # The badge circle is a pre-rendered SVG (tinted per-state via modulate) rather than a + # StyleBoxFlat corner-radius circle - a perfectly circular StyleBoxFlat is a known Godot + # degenerate case that can show a seam/faceting at this size even with AA tuned. + var badge_texture := TextureRect.new() + badge_texture.mouse_filter = Control.MOUSE_FILTER_IGNORE + badge_texture.texture = BADGE_CIRCLE_OUTLINE_ICON + badge_texture.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED + badge_texture.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + badge_wrap.add_child(badge_texture) + choice_badge_textures.append(badge_texture) + var badge := Label.new() badge.mouse_filter = Control.MOUSE_FILTER_IGNORE badge.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER badge.vertical_alignment = VERTICAL_ALIGNMENT_CENTER - badge.anchor_left = 0.0 - badge.anchor_top = 0.0 - badge.anchor_right = 0.0 - badge.anchor_bottom = 0.0 - badge.offset_left = 12 - badge.offset_top = 12 - badge.offset_right = 42 - badge.offset_bottom = 42 - _set_scaled_label(badge, 14, Brand.muted_color(true)) - button.add_child(badge) + badge.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + _set_scaled_label(badge, 15, Brand.muted_color(true)) + badge_wrap.add_child(badge) choice_badges.append(badge) var percent_label := Label.new() @@ -446,13 +573,30 @@ func _ready() -> void: percent_label.anchor_right = 1.0 percent_label.anchor_bottom = 0.0 percent_label.offset_left = -82 - percent_label.offset_top = 8 + percent_label.offset_top = 18 percent_label.offset_right = -18 - percent_label.offset_bottom = 40 + percent_label.offset_bottom = 50 _set_scaled_label(percent_label, 18, Style.TEXT) button.add_child(percent_label) choice_percent_labels.append(percent_label) + var selected_icon := TextureRect.new() + selected_icon.visible = false + selected_icon.mouse_filter = Control.MOUSE_FILTER_IGNORE + selected_icon.texture = preload("res://assets/icons/lucide/circle-check.svg") + selected_icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED + selected_icon.modulate = Brand.PURPLE + selected_icon.anchor_left = 1.0 + selected_icon.anchor_top = 0.0 + selected_icon.anchor_right = 1.0 + selected_icon.anchor_bottom = 0.0 + selected_icon.offset_left = -50 + selected_icon.offset_top = 18 + selected_icon.offset_right = -18 + selected_icon.offset_bottom = 50 + button.add_child(selected_icon) + choice_selected_icons.append(selected_icon) + var top_label := RichTextLabel.new() top_label.visible = false top_label.mouse_filter = Control.MOUSE_FILTER_IGNORE @@ -463,10 +607,10 @@ func _ready() -> void: top_label.anchor_top = 0.0 top_label.anchor_right = 1.0 top_label.anchor_bottom = 0.0 - top_label.offset_left = 54 - top_label.offset_top = 10 + top_label.offset_left = 62 + top_label.offset_top = 18 top_label.offset_right = -96 - top_label.offset_bottom = 42 + top_label.offset_bottom = 50 _set_scaled_rich_text(top_label, 16) top_label.add_theme_color_override("default_color", Style.RED) button.add_child(top_label) @@ -488,24 +632,51 @@ func _ready() -> void: _build_answer_review_ui() actions = HBoxContainer.new() - actions.add_theme_constant_override("separation", 10) + actions.add_theme_constant_override("separation", 16) + card_content.add_child(actions) + skip_button = Button.new() skip_button.text = "Skip" - skip_button.custom_minimum_size = Vector2(120, 48) - Style.style_chip(skip_button, Style.AMBER) + skip_button.flat = true + skip_button.custom_minimum_size = Vector2(0, 40) UISoundService.set_button_sound(skip_button, "back") skip_button.pressed.connect(_skip_current_question) actions.add_child(skip_button) + var bottom_progress_group := VBoxContainer.new() + bottom_progress_group.add_theme_constant_override("separation", 4) + bottom_progress_group.size_flags_horizontal = Control.SIZE_EXPAND_FILL + bottom_progress_group.alignment = BoxContainer.ALIGNMENT_CENTER + actions.add_child(bottom_progress_group) + + bottom_progress_label = Label.new() + bottom_progress_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + _set_scaled_label(bottom_progress_label, 13, Brand.SLATE) + bottom_progress_group.add_child(bottom_progress_label) + + bottom_progress_bar = ProgressBar.new() + bottom_progress_bar.show_percentage = false + bottom_progress_bar.min_value = 0.0 + bottom_progress_bar.max_value = 100.0 + bottom_progress_bar.custom_minimum_size = Vector2(160, 6) + bottom_progress_bar.size_flags_horizontal = Control.SIZE_SHRINK_CENTER + bottom_progress_group.add_child(bottom_progress_bar) + + # The timer readout still runs (BlockSessionService pacing/scoring depends on + # it, and _update_timer_bar keeps its text current), it's just no longer + # parented into the bottom bar - the caption + digits and the "Ctrl+Enter" + # hint were clutter next to the Submit button. + timer_caption_label = Label.new() + timer_caption_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT + _set_scaled_label(timer_caption_label, 12, Brand.SLATE) + next_button = Button.new() next_button.text = "Submit Block" if BlockSessionService.session_kind == "simulation" and BlockSessionService.current_index + 1 == BlockSessionService.active_questions.size() else "Next" if BlockSessionService.session_kind == "simulation" else "Submit" next_button.tooltip_text = "" next_button.custom_minimum_size = Vector2(140, 48) - next_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL Style.style_primary_button(next_button) next_button.pressed.connect(_submit_or_next) actions.add_child(next_button) - card_content.add_child(actions) timer_footer = HBoxContainer.new() timer_footer.add_theme_constant_override("separation", 12) @@ -520,7 +691,6 @@ func _ready() -> void: timer_bar.add_theme_stylebox_override("background", Style.button_box(Color("232b3d"), Color("303b53"))) timer_bar.add_theme_stylebox_override("fill", Style.button_box(Style.LIME, Style.LIME)) timer_footer.add_child(timer_bar) - timer_footer.add_child(timer_label) feedback_fog = ColorRect.new() feedback_fog.color = Color(1.0, 0.0, 0.0, 0.0) @@ -530,6 +700,8 @@ func _ready() -> void: add_child(feedback_fog) _build_labs_modal() + calculator_panel = CalculatorPanel.new() + add_child(calculator_panel) _build_report_modal() _build_media_viewer() resized.connect(_fit_modal_panels) @@ -553,10 +725,6 @@ func _build_answer_review_ui() -> void: review_content.add_theme_constant_override("separation", 10) review_margin.add_child(review_content) - correct_answer_heading = Label.new() - _set_scaled_label(correct_answer_heading, 17, Brand.SUCCESS) - review_content.add_child(correct_answer_heading) - explanation_label = RichTextLabel.new() explanation_label.bbcode_enabled = true explanation_label.fit_content = true @@ -595,19 +763,6 @@ func _build_answer_review_ui() -> void: review_followups_content.add_theme_constant_override("separation", 8) review_followups_section.add_child(review_followups_content) - wrong_options_section = VBoxContainer.new() - wrong_options_section.visible = false - wrong_options_section.add_theme_constant_override("separation", 8) - question_content.add_child(wrong_options_section) - var wrong_options_heading := Label.new() - wrong_options_heading.text = "WHY OTHER OPTIONS ARE INCORRECT" - wrong_options_heading.name = "WrongOptionsHeading" - _set_scaled_label(wrong_options_heading, 15, Brand.text_color(true)) - wrong_options_section.add_child(wrong_options_heading) - wrong_options_content = VBoxContainer.new() - wrong_options_content.add_theme_constant_override("separation", 6) - wrong_options_section.add_child(wrong_options_content) - func _fit_modal_panels() -> void: var available := get_viewport_rect().size - Vector2(32, 32) @@ -622,19 +777,19 @@ func _fit_modal_panels() -> void: func _refresh_responsive_layout() -> void: - if header_toolbar_host == null or header_row == null or header_stack == null: + if header_toolbar_host == null or header_brand == null or header_stack == null: return var compact := size.x < 1120.0 - var target_parent: Control = header_row + var target_parent: Control = header_brand if compact: target_parent = header_stack if header_toolbar_host.get_parent() != 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 question_toolbar.size_flags_horizontal = Control.SIZE_EXPAND_FILL - header_toolbar_host.custom_minimum_size.x = 0.0 if compact else 520.0 - app_feedback_button.text = "" if size.x < 760.0 else "Feedback" - app_feedback_button.custom_minimum_size.x = 44 if size.x < 760.0 else 92 + # 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 if size.x < 560.0: nav_palette.columns = 5 else: @@ -657,30 +812,72 @@ func _question_content_box(dark: bool) -> StyleBoxFlat: return box -func _answer_box(dark: bool, state: String) -> StyleBoxFlat: +func _answer_box(dark: bool, state: String, attached: bool = false) -> StyleBoxFlat: var box := StyleBoxFlat.new() - var background := Color("0b172b") if dark else Color("ffffff") - var border := Color("2b3958") if dark else Brand.LIGHT_BORDER - if state == "selected": - background = Color("17183b") if dark else Color("f7f3ff") - border = Brand.PURPLE - elif state == "correct": + box.content_margin_left = 62 + box.content_margin_right = 82 + box.content_margin_top = 22 + box.content_margin_bottom = 22 + if state == "default" or state == "eliminated": + # Every choice reads as its own subtly-bordered card, even before answering - + # only selected/correct/wrong states get a stronger accent border/background. + box.bg_color = Color("0b172b") if dark else Color("ffffff") + box.border_color = Color("22304d") if dark else Color("e7ebf2") + box.set_border_width_all(1) + box.set_corner_radius_all(14) + return box + var background := Color("17183b") if dark else Color("eff4ff") + var border := Brand.PURPLE + if state == "correct": background = Color("08251d") if dark else Color("ecfdf3") border = Brand.SUCCESS elif state == "wrong": background = Color("32131d") if dark else Color("fff1f2") border = Color("fb7185") - elif state == "eliminated": - background = Color("0a1323") if dark else Color("f8fafc") - border = Color("26344f") if dark else Color("e2e8f0") box.bg_color = background box.border_color = border - box.set_border_width_all(1) - box.set_corner_radius_all(7) - box.content_margin_left = 54 - box.content_margin_right = 82 - box.content_margin_top = 12 - box.content_margin_bottom = 12 + box.set_border_width_all(2) + box.set_corner_radius_all(14) + if attached: + # A review/follow-up panel is docked directly beneath this choice - drop the + # shared edge so the two read as one continuous card instead of two stacked boxes. + box.border_width_bottom = 0 + box.corner_radius_bottom_left = 0 + box.corner_radius_bottom_right = 0 + return box + + +func _attached_panel_box(dark: bool, accent: Color) -> StyleBoxFlat: + var box := StyleBoxFlat.new() + box.bg_color = Color("17183b") if dark else Color("eff4ff") + if accent == Brand.SUCCESS: + box.bg_color = Color("08251d") if dark else Color("ecfdf3") + elif accent == Color("fb7185"): + box.bg_color = Color("32131d") if dark else Color("fff1f2") + box.border_color = accent + box.border_width_left = 2 + box.border_width_right = 2 + box.border_width_bottom = 2 + box.border_width_top = 0 + box.corner_radius_top_left = 0 + box.corner_radius_top_right = 0 + box.corner_radius_bottom_left = 14 + box.corner_radius_bottom_right = 14 + box.content_margin_left = 12 + box.content_margin_right = 12 + box.content_margin_top = 10 + box.content_margin_bottom = 10 + return box + + +func _correct_review_panel_box(dark: bool) -> StyleBoxFlat: + # correct_review_panel already pads its content via an inner MarginContainer, + # so skip the shared attached-panel content margin to avoid doubling it up. + var box := _attached_panel_box(dark, Brand.SUCCESS) + box.content_margin_left = 0 + box.content_margin_right = 0 + box.content_margin_top = 0 + box.content_margin_bottom = 0 return box @@ -697,32 +894,22 @@ func _toolbar_box(dark: bool, active: bool = false) -> StyleBoxFlat: return box -func _theme_toggle_box(dark: bool, hovered: bool = false) -> StyleBoxFlat: - var box := StyleBoxFlat.new() - box.bg_color = Color("111b30") if dark else Brand.LIGHT_SURFACE - box.border_color = Brand.AMBER if hovered else (Color("33415d") if dark else Brand.LIGHT_BORDER) - box.set_border_width_all(1) - box.set_corner_radius_all(22) - box.content_margin_left = 0 - box.content_margin_right = 0 - box.content_margin_top = 0 - box.content_margin_bottom = 0 - return box - - func _style_theme_toggle_button(dark: bool) -> void: if theme_button == null: return Brand.set_theme_toggle_icon(theme_button, dark) theme_button.tooltip_text = "Switch to light mode" if dark else "Switch to dark mode" - theme_button.add_theme_stylebox_override("normal", _theme_toggle_box(dark)) - theme_button.add_theme_stylebox_override("hover", _theme_toggle_box(dark, true)) - theme_button.add_theme_stylebox_override("pressed", _theme_toggle_box(dark, true)) + # Match the same rounded-square box every other toolbar icon uses, rather than + # the distinct circular pill shape - it should read as one of the toolbar, not + # a visually separate control. + theme_button.add_theme_stylebox_override("normal", _toolbar_box(dark)) + theme_button.add_theme_stylebox_override("hover", _toolbar_box(dark, true)) + theme_button.add_theme_stylebox_override("pressed", _toolbar_box(dark, true)) theme_button.add_theme_stylebox_override("focus", StyleBoxEmpty.new()) theme_button.add_theme_constant_override("h_separation", 0) -func _style_asset_toolbar_button(button: Button, texture: Texture2D, dark: bool) -> void: +func _style_asset_toolbar_button(button: Button, texture: Texture2D, dark: bool, active: bool = false) -> void: if button == null: return button.text = "" @@ -736,7 +923,7 @@ func _style_asset_toolbar_button(button: Button, texture: Texture2D, dark: bool) button.size_flags_vertical = Control.SIZE_SHRINK_CENTER button.add_theme_constant_override("h_separation", 0) button.add_theme_constant_override("icon_max_width", 23) - button.add_theme_stylebox_override("normal", _toolbar_box(dark)) + button.add_theme_stylebox_override("normal", _toolbar_box(dark, active)) button.add_theme_stylebox_override("hover", _toolbar_box(dark, true)) button.add_theme_stylebox_override("pressed", _toolbar_box(dark, true)) button.add_theme_stylebox_override("focus", StyleBoxEmpty.new()) @@ -767,15 +954,6 @@ func _primary_action_box(dark: bool, state: String) -> StyleBoxFlat: return box -func _correct_review_box(dark: bool) -> StyleBoxFlat: - var box := StyleBoxFlat.new() - box.bg_color = Color("08251d") if dark else Color("f0fdf4") - box.border_color = Color(Brand.SUCCESS, 0.62) - box.set_border_width_all(1) - box.set_corner_radius_all(7) - return box - - func _teaching_card_box(dark: bool, accent: Color) -> StyleBoxFlat: var box := StyleBoxFlat.new() box.bg_color = Color(accent, 0.08 if dark else 0.045) @@ -802,19 +980,6 @@ func _review_followup_box(_dark: bool, accent: Color, answered: bool) -> StyleBo return box -func _wrong_option_box(dark: bool) -> StyleBoxFlat: - var box := StyleBoxFlat.new() - box.bg_color = Color("0b172b") if dark else Color("ffffff") - box.border_color = Color("2b3958") if dark else Brand.LIGHT_BORDER - box.set_border_width_all(1) - box.set_corner_radius_all(6) - box.content_margin_left = 10 - box.content_margin_right = 10 - box.content_margin_top = 6 - box.content_margin_bottom = 6 - return box - - func _style_review_followup_option(button: Button, dark: bool, option_id: String, selected_id: String, correct_id: String, answered: bool) -> void: var state := "default" if answered and option_id == correct_id: @@ -846,7 +1011,25 @@ func _apply_question_theme() -> void: progress_button.add_theme_color_override("font_color", Brand.text_color(dark)) progress_button.add_theme_color_override("font_hover_color", Brand.SKY_BLUE) progress_button.add_theme_color_override("font_disabled_color", Brand.text_color(dark)) + if header_wordmark_label != null: + header_wordmark_label.add_theme_color_override("font_color", Brand.text_color(dark)) + var progress_track := Style.button_box(Color("232b3d") if dark else Color("e2e8f0"), Color("303b53") if dark else Color("cbd5e1")) + var progress_fill := Style.button_box(Brand.ROYAL_BLUE, Brand.ROYAL_BLUE) + if progress_bar != null: + progress_bar.add_theme_stylebox_override("background", progress_track) + progress_bar.add_theme_stylebox_override("fill", progress_fill) + if progress_percent_label != null: + progress_percent_label.add_theme_color_override("font_color", Brand.muted_color(dark)) + if bottom_progress_bar != null: + bottom_progress_bar.add_theme_stylebox_override("background", progress_track) + bottom_progress_bar.add_theme_stylebox_override("fill", progress_fill) + if bottom_progress_label != null: + bottom_progress_label.add_theme_color_override("font_color", Brand.muted_color(dark)) + if category_label != null: + category_label.add_theme_color_override("font_color", Brand.SKY_BLUE if dark else Brand.ROYAL_BLUE) timer_label.add_theme_color_override("font_color", Brand.muted_color(dark)) + if timer_caption_label != null: + timer_caption_label.add_theme_color_override("font_color", Brand.muted_color(dark)) stem_label.add_theme_color_override("default_color", Brand.text_color(dark)) lead_label.add_theme_color_override("font_color", Brand.text_color(dark)) explanation_label.add_theme_color_override("default_color", Brand.text_color(dark)) @@ -856,19 +1039,16 @@ func _apply_question_theme() -> void: var review_followup_subtitle := find_child("ReviewFollowupSubtitle", true, false) as Label if review_followup_subtitle != null: review_followup_subtitle.add_theme_color_override("font_color", Brand.muted_color(dark)) - var wrong_options_heading := find_child("WrongOptionsHeading", true, false) as Label - if wrong_options_heading != null: - wrong_options_heading.add_theme_color_override("font_color", Brand.text_color(dark)) if correct_review_panel != null: - correct_review_panel.add_theme_stylebox_override("panel", _correct_review_box(dark)) + correct_review_panel.add_theme_stylebox_override("panel", _correct_review_panel_box(dark)) question_elo_label.add_theme_color_override("font_color", Brand.AMBER) 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, flag_button, bookmark_button, notes_button, app_feedback_button]: + for control in [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 == flag_button and flagged) or (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", ""))) control.add_theme_stylebox_override("normal", _toolbar_box(dark, active)) control.add_theme_stylebox_override("hover", _toolbar_box(dark, true)) control.add_theme_stylebox_override("pressed", _toolbar_box(dark, true)) @@ -877,11 +1057,19 @@ func _apply_question_theme() -> void: _style_asset_toolbar_button(labs_button, LABS_DARK_ICON if dark else LABS_LIGHT_ICON, dark) _style_asset_toolbar_button(clear_highlight_button, CLEAR_HIGHLIGHT_DARK_ICON if dark else CLEAR_HIGHLIGHT_LIGHT_ICON, dark) _style_asset_toolbar_button(report_button, REPORT_QUESTION_DARK_ICON if dark else REPORT_QUESTION_LIGHT_ICON, dark) + _style_asset_toolbar_button(flag_button, FLAG_DARK_ICON if dark else FLAG_LIGHT_ICON, dark, flagged) _style_theme_toggle_button(dark) + if calculator_panel != null: + calculator_panel.refresh_theme_controls() if skip_button != null: - skip_button.add_theme_stylebox_override("normal", _toolbar_box(dark)) - skip_button.add_theme_stylebox_override("hover", _toolbar_box(dark, true)) - skip_button.add_theme_color_override("font_color", Brand.text_color(dark)) + 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("hover", StyleBoxEmpty.new()) + skip_button.add_theme_stylebox_override("pressed", StyleBoxEmpty.new()) + skip_button.add_theme_stylebox_override("disabled", StyleBoxEmpty.new()) + skip_button.add_theme_color_override("font_color", skip_link_color) + skip_button.add_theme_color_override("font_hover_color", skip_link_color) + skip_button.add_theme_color_override("font_disabled_color", Brand.muted_color(dark)) if next_button != null: next_button.add_theme_stylebox_override("normal", _primary_action_box(dark, "normal")) next_button.add_theme_stylebox_override("hover", _primary_action_box(dark, "hover")) @@ -898,10 +1086,13 @@ func _apply_question_theme() -> void: _style_choice_button_for_state(index, choice_id) for attempted_choice_id in followup_attempt_order: _render_followup_state(str(attempted_choice_id)) - if correct_review_panel != null and correct_review_panel.visible: + # Guarding on current_answer/pending_correct_advance too (not just the panel's + # own visible flag) so these can never render for a question that hasn't + # actually been answered yet, even if visible was left stale by some other path. + if correct_review_panel != null and correct_review_panel.visible and (not current_answer.is_empty() or pending_correct_advance): _render_teaching_cards() _render_review_followups() - _render_wrong_option_reviews() + _render_unselected_distractor_explanations() func on_show(_payload: Variant = null) -> void: @@ -915,6 +1106,9 @@ func on_hide() -> void: _hide_media_viewer() _hide_report_modal(true) _hide_labs_modal() + if calculator_panel != null: + calculator_panel.close_panel() + calculator_panel.reset_calculation() func _save_simulation_current_response() -> void: @@ -975,6 +1169,12 @@ func _process(delta: float) -> void: _update_timer_bar() if BlockSessionService.seconds_remaining <= 0: _finish() + if visible and not _is_session_timer_visible() and (report_overlay == null or not report_overlay.visible): + # No real time limit applies here - show a simple local count-up stopwatch + # ("Time elapsed") so the bottom-bar time slot is never blank, without + # touching BlockSessionService's own (block-scoped, not per-question) timing. + var elapsed_seconds := int(floor(float(Time.get_ticks_msec() - untimed_elapsed_start_msec) / 1000.0)) + _set_timer_display(maxi(0, elapsed_seconds)) func _input(event: InputEvent) -> void: @@ -1001,9 +1201,20 @@ func _input(event: InputEvent) -> void: _hide_labs_modal() get_viewport().set_input_as_handled() return + if calculator_panel != null and calculator_panel.visible and event is InputEventKey: + return if not visible or pending_correct_advance or stem_label == null: return + if event is InputEventKey and event.pressed and not event.echo and event.keycode == KEY_ENTER and (event.ctrl_pressed or event.meta_pressed): + var focus_owner := get_viewport().gui_get_focus_owner() + var focus_blocks_shortcut := focus_owner is TextEdit or focus_owner is LineEdit + var overlay_blocks_shortcut := (report_overlay != null and report_overlay.visible) or (labs_overlay != null and labs_overlay.visible) + if not focus_blocks_shortcut and not overlay_blocks_shortcut and next_button != null and next_button.visible and not next_button.disabled: + get_viewport().set_input_as_handled() + _submit_or_next() + return + var mouse_event := event as InputEventMouseButton if mouse_event == null or mouse_event.button_index != MOUSE_BUTTON_LEFT: return @@ -1042,7 +1253,7 @@ func _load_question() -> void: next_button.text = "Submit" next_button.disabled = true skip_button.disabled = false - skip_button.visible = BlockSessionService.session_kind != "review" + skip_button.visible = BlockSessionService.session_kind not in ["review", "dynamic"] skip_button.text = "Skip" report_button.visible = BlockSessionService.session_kind != "simulation" @@ -1050,28 +1261,51 @@ func _load_question() -> void: if question.is_empty(): if BlockSessionService.session_kind == "review": main.show_screen("dashboard") + elif BlockSessionService.session_kind == "dynamic": + main.show_screen("dynamic_questions") else: main.show_screen("bookmarks" if BlockSessionService.session_kind == "bookmark" else "selection", "exam" if BlockSessionService.session_kind == "core" else "step3") return - BookmarkService.observe_question(question) + if BlockSessionService.session_kind != "dynamic": + BookmarkService.observe_question(question) progress_button.text = _question_progress_text() - progress_button.disabled = BlockSessionService.session_kind in ["core", "bookmark", "review"] - progress_button.tooltip_text = "" if BlockSessionService.session_kind in ["core", "bookmark", "review"] else "Open question navigator" + progress_button.disabled = BlockSessionService.session_kind in ["core", "bookmark", "review", "dynamic"] + progress_button.tooltip_text = "" if BlockSessionService.session_kind in ["core", "bookmark", "review", "dynamic"] else "Open question navigator" + _update_progress_indicators() + untimed_elapsed_start_msec = Time.get_ticks_msec() var show_timer := _is_session_timer_visible() - timer_label.visible = show_timer + # The time slot in the bottom bar is always visible - a countdown ("Time remaining") + # when a time limit applies, otherwise a simple count-up stopwatch ("Time elapsed") + # so the area is never blank for untimed practice. + timer_label.visible = true timer_bar.visible = show_timer timer_footer.visible = show_timer + if timer_caption_label != null: + # "core" sessions show a per-question stopwatch that counts UP (no fixed + # limit, just a soft pacing signal), while simulation/Timed sessions count + # DOWN to a real deadline - the caption should match which one it is. + if not show_timer or BlockSessionService.session_kind == "core": + timer_caption_label.text = "Time elapsed" + else: + timer_caption_label.text = "Time remaining" if show_timer: if BlockSessionService.session_kind == "core": _set_timer_display(int(floor(BlockSessionService.get_core_question_total_elapsed_seconds()))) else: _set_timer_display(int(ceil(BlockSessionService.seconds_remaining))) + else: + _set_timer_display(0) _update_timer_bar() stem_plain_text = str(question.get("stem", "")) explanation_plain_text = str(question.get("explanation", "")) _render_stem() _load_question_media(question) lead_label.text = question.get("lead_in", "") + var category_text := str(question.get("discipline", "")).strip_edges() + if category_text.is_empty(): + category_text = str(question.get("system", "")).strip_edges() + category_label.text = category_text + category_label.visible = not category_text.is_empty() _refresh_labs_button() presented_choices = question.get("presented_choices", question.get("choices", [])).duplicate(true) @@ -1163,9 +1397,9 @@ func _should_clear_selection_from_click(mouse_event: InputEventMouseButton) -> b return false if labs_button != null and labs_button.visible and labs_button.get_global_rect().has_point(pointer_position): return false - if flag_button != null and flag_button.get_global_rect().has_point(pointer_position): + if flag_button != null and flag_button.visible and flag_button.get_global_rect().has_point(pointer_position): return false - if report_button != null and report_button.get_global_rect().has_point(pointer_position): + if report_button != null and report_button.visible and report_button.get_global_rect().has_point(pointer_position): return false for button in choice_buttons: if button.visible and button.get_global_rect().has_point(pointer_position): @@ -1300,8 +1534,13 @@ func _submit_or_next() -> void: if selected_choice == "": return var question_id_for_attempt := str(BlockSessionService.get_current_question().get("id", "")) + if BlockSessionService.session_kind == "dynamic": + var elapsed := int(floor(BlockSessionService.get_current_elapsed_seconds())) + DynamicQuestionService.submit_attempt(selected_choice, elapsed) + current_answer = BlockSessionService.answer_current(selected_choice, eliminated, false) + _show_answer_review() + return if BlockSessionService.session_kind in ["core", "bookmark", "review"]: - _collapse_resolved_followups() var attempted_choice := selected_choice if BlockSessionService.session_kind == "core": QuestionBankService.record_first_attempt( @@ -1350,7 +1589,12 @@ func _submit_or_next() -> void: current_answer = BlockSessionService.answer_current(selected_choice, eliminated, flagged) _show_answer_review() else: - if BlockSessionService.session_kind == "core": + if BlockSessionService.session_kind == "dynamic": + awaiting_next_question = true + next_button.text = "Generating…" + next_button.disabled = true + DynamicQuestionService.load_next() + elif BlockSessionService.session_kind == "core": _request_next_core_question() elif BlockSessionService.session_kind == "bookmark": if BookmarkService.has_next_practice(): @@ -1369,6 +1613,26 @@ func _submit_or_next() -> void: _advance_or_finish() +func _on_dynamic_question_loaded(question: Dictionary) -> void: + if not awaiting_next_question or BlockSessionService.session_kind != "dynamic": + return + if not BlockSessionService.replace_dynamic_question(question): + _on_dynamic_question_failed("The next dynamic question could not be prepared.") + return + awaiting_next_question = false + _load_question() + UISoundService.play_sound("question") + + +func _on_dynamic_question_failed(message: String) -> void: + if not awaiting_next_question or BlockSessionService.session_kind != "dynamic": + return + awaiting_next_question = false + next_button.text = "Retry" + next_button.disabled = false + next_button.tooltip_text = message + + func _request_next_core_question() -> void: awaiting_next_question = true next_button.text = "Loading..." @@ -1423,6 +1687,7 @@ func _show_answer_review() -> void: skip_button.disabled = false if BlockSessionService.session_kind == "bookmark" else true if question_scroll != null: question_scroll.call_deferred("ensure_control_visible", explanation_label) + _refresh_toolbar_state() func _set_answer_review_timer_visibility() -> void: @@ -1438,6 +1703,9 @@ func _show_explanation() -> void: var question := BlockSessionService.get_current_question() for button in choice_buttons: button.disabled = true + var correct_id := str(question.get("correct_choice_id", "")) + _dock_correct_review_panel(correct_id) + correct_review_panel.visible = true var choices := _get_presented_choices() for index in range(choices.size()): var choice: Dictionary = choices[index] @@ -1448,17 +1716,23 @@ func _show_explanation() -> void: explanation_plain_text = str(question.get("explanation", "")) _render_explanation() explanation_label.visible = true - correct_review_panel.visible = true - var correct_id := str(question.get("correct_choice_id", "")) - correct_answer_heading.text = "Correct Answer: %s" % _display_label_for_choice_id(correct_id) _update_question_elo_label(true) _refresh_nav_palette() + _update_content_vertical_alignment() + + +func _dock_correct_review_panel(correct_id: String) -> void: + var index := _choice_index_for_id(correct_id) + if index < 0 or index >= choice_rows.size(): + return + if correct_review_panel.get_parent() != choice_rows[index]: + correct_review_panel.reparent(choice_rows[index]) func _render_answer_review_sections() -> void: _render_teaching_cards() _render_review_followups() - _render_wrong_option_reviews() + _render_unselected_distractor_explanations() func _clear_answer_review() -> void: @@ -1468,13 +1742,22 @@ func _clear_answer_review() -> void: correct_review_panel.visible = false _clear_container_children(teaching_cards) _clear_container_children(review_followups_content) - _clear_container_children(wrong_options_content) review_followup_states.clear() - wrong_option_expanded.clear() if review_followups_section != null: review_followups_section.visible = false - if wrong_options_section != null: - wrong_options_section.visible = false + _update_content_vertical_alignment() + + +func _update_content_vertical_alignment() -> void: + if question_content == null: + return + # Before an answer is submitted the stem+choices block is usually shorter than the + # available panel height; center it so it doesn't sit pinned to the top with a slab + # of empty space below. Once the answer review content appears, go back to top + # alignment so the growing explanation/follow-up sections read top-down and scroll + # naturally instead of being centered mid-panel. + var answered := correct_review_panel != null and correct_review_panel.visible + question_content.alignment = BoxContainer.ALIGNMENT_BEGIN if answered else BoxContainer.ALIGNMENT_CENTER func _clear_container_children(container: Control) -> void: @@ -1494,7 +1777,7 @@ func _render_teaching_cards() -> void: if takeaway == "": var correct_id := str(question.get("correct_choice_id", "")) var explanations: Dictionary = question.get("choice_explanations", {}) - takeaway = _strip_correct_prefix(str(explanations.get(correct_id, "")).strip_edges()) + takeaway = _strip_verdict_prefix(str(explanations.get(correct_id, "")).strip_edges()) var pearl := str(question.get("usmle_pearl", "")).strip_edges() if takeaway != "": _add_teaching_card("KEY TAKEAWAY", takeaway, Brand.SKY_BLUE) @@ -1528,6 +1811,9 @@ func _render_review_followups() -> void: if review_followups_content == null: return _clear_container_children(review_followups_content) + if not BlockSessionService.show_review_followups: + review_followups_section.visible = false + return var question := BlockSessionService.get_current_question() var authored: Array = question.get("review_followups", []) if review_followup_states.size() != authored.size(): @@ -1602,63 +1888,57 @@ func _answer_review_followup(index: int, choice_id: String) -> void: _render_review_followups() -func _render_wrong_option_reviews() -> void: - if wrong_options_content == null: +func _render_unselected_distractor_explanations() -> void: + # Choices the user never picked (so never triggered the wrong-answer + # follow-up quiz) still need their "why incorrect" explanation shown - + # nest it directly in that choice's own card instead of a repeated + # aggregate list at the bottom. Choices that DID trigger a follow-up + # already show this same explanation inside the resolved quiz view. + # Guard against ever rendering on a question that hasn't actually been + # answered yet, regardless of what triggered this call. + if current_answer.is_empty() and not pending_correct_advance: return - _clear_container_children(wrong_options_content) var question := BlockSessionService.get_current_question() var correct_id := str(question.get("correct_choice_id", "")) - var explanations: Dictionary = question.get("choice_explanations", {}) - var incorrect_count := 0 - for choice in _get_presented_choices(): + var choices := _get_presented_choices() + for index in range(choices.size()): + if index >= choice_followup_panels.size(): + continue + var choice: Dictionary = choices[index] if typeof(choice) != TYPE_DICTIONARY: continue var choice_id := str(choice.get("id", "")) - if choice_id == correct_id: + if choice_id == "" or choice_id == correct_id or followup_states.has(choice_id): continue - incorrect_count += 1 - _add_wrong_option_review(choice, str(explanations.get(choice_id, ""))) - wrong_options_section.visible = incorrect_count > 0 + _clear_followup_content(index) + var content := choice_followup_contents[index] + var explanation := Label.new() + explanation.text = _strip_verdict_prefix(_distractor_explanation_text(choice_id)) + explanation.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + _set_scaled_label(explanation, 14, Brand.text_color(_followup_is_dark())) + content.add_child(explanation) + choice_followup_panels[index].add_theme_stylebox_override( + "panel", + _attached_panel_box(_followup_is_dark(), Color("fb7185")) + ) + choice_followup_panels[index].visible = true + _style_choice_button_for_state(index, choice_id) -func _add_wrong_option_review(choice: Dictionary, explanation: String) -> void: - var dark := _followup_is_dark() - var choice_id := str(choice.get("id", "")) - var expanded := bool(wrong_option_expanded.get(choice_id, false)) - var card := PanelContainer.new() - card.add_theme_stylebox_override("panel", _wrong_option_box(dark)) - wrong_options_content.add_child(card) - var content := VBoxContainer.new() - content.add_theme_constant_override("separation", 6) - card.add_child(content) - var header := Button.new() - header.flat = true - header.alignment = HORIZONTAL_ALIGNMENT_LEFT - header.text = "%s. %s" % [_display_label_for_choice_id(choice_id), str(choice.get("text", ""))] - header.icon = preload("res://assets/icons/lucide/chevron-down.svg") if expanded else preload("res://assets/icons/lucide/chevron-right.svg") - header.icon_alignment = HORIZONTAL_ALIGNMENT_RIGHT - header.expand_icon = false - header.add_theme_color_override("font_color", Brand.text_color(dark)) - header.add_theme_color_override("icon_normal_color", Brand.muted_color(dark)) - _set_scaled_button_font(header, 14) - header.pressed.connect(_toggle_wrong_option_review.bind(choice_id)) - content.add_child(header) - if expanded: - var detail := Label.new() - detail.text = explanation - detail.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART - _set_scaled_label(detail, 14, Brand.muted_color(dark)) - content.add_child(detail) +func _distractor_explanation_text(choice_id: String) -> String: + var question := BlockSessionService.get_current_question() + var explanations: Dictionary = question.get("choice_explanations", {}) + var explanation := str(explanations.get(choice_id, "")).strip_edges() + if explanation != "": + return explanation + return "Review the stem finding that makes this option less appropriate." -func _toggle_wrong_option_review(choice_id: String) -> void: - wrong_option_expanded[choice_id] = not bool(wrong_option_expanded.get(choice_id, false)) - _render_wrong_option_reviews() - - -func _strip_correct_prefix(value: String) -> String: +func _strip_verdict_prefix(value: String) -> String: if value.begins_with("Correct. "): return value.substr(9) + if value.begins_with("Incorrect. "): + return value.substr(11) return value @@ -1747,7 +2027,7 @@ func _exit_run() -> void: BlockSessionService.finish_block() main.show_screen("bookmarks") return - if BlockSessionService.session_kind == "review": + if BlockSessionService.session_kind in ["review", "dynamic"]: BlockSessionService.finish_block() DashboardService.refresh() main.show_screen("dashboard") @@ -1794,13 +2074,42 @@ func _is_session_timer_visible() -> bool: func _question_progress_text() -> String: if BlockSessionService.session_kind == "core": - return "Question %d" % (BlockSessionService.core_answer_count + 1) - return "Question %d of %d" % [ + var block_size := BlockSessionService.core_block_size + return "%d of %d" % [(BlockSessionService.core_answer_count % block_size) + 1, block_size] + return "%d of %d" % [ mini(BlockSessionService.current_index + 1, BlockSessionService.active_questions.size()), BlockSessionService.active_questions.size() ] +func _update_progress_indicators() -> void: + # "core" sessions have no fixed total (endless queue), so there's no meaningful + # ratio/percentage to show - hide the bars rather than showing a misleading value. + var show_progress := BlockSessionService.session_kind != "core" + if progress_bar != null: + progress_bar.visible = show_progress + if progress_percent_label != null: + progress_percent_label.visible = show_progress + if bottom_progress_bar != null: + bottom_progress_bar.visible = show_progress + if bottom_progress_label != null: + bottom_progress_label.visible = show_progress + if not show_progress: + return + var total := BlockSessionService.active_questions.size() + var current := mini(BlockSessionService.current_index + 1, maxi(total, 1)) + var ratio := float(current) / float(maxi(total, 1)) + var percent := int(round(ratio * 100.0)) + if progress_bar != null: + progress_bar.value = ratio * 100.0 + if progress_percent_label != null: + progress_percent_label.text = "%d%%" % percent + if bottom_progress_bar != null: + bottom_progress_bar.value = ratio * 100.0 + if bottom_progress_label != null: + bottom_progress_label.text = "%d of %d" % [current, total] + + func _toggle_question_navigator() -> void: if navigator_popup == null or BlockSessionService.session_kind == "core": return @@ -1968,22 +2277,41 @@ func _refresh_toolbar_state() -> void: var question_id := str(BlockSessionService.get_current_question().get("id", "")) report_button.tooltip_text = "Report submitted; report another issue" if reported_question_ids.has(question_id) else "Report question" if notes_button != null: - notes_button.visible = BlockSessionService.session_kind != "simulation" + notes_button.visible = BlockSessionService.session_kind not in ["simulation", "dynamic"] + if flashcard_button != null: + flashcard_button.visible = BlockSessionService.session_kind not in ["simulation", "dynamic"] + flashcard_button.disabled = current_answer.is_empty() if bookmark_button != null: - bookmark_button.visible = BlockSessionService.session_kind != "simulation" + bookmark_button.visible = BlockSessionService.session_kind not in ["simulation", "dynamic"] var bookmark_question_id := str(BlockSessionService.get_current_question().get("id", "")) var bookmark_value := BookmarkService.is_bookmarked(bookmark_question_id) var bookmark_pending := BookmarkService.is_pending(bookmark_question_id) bookmark_button.tooltip_text = ("Saved; waiting to sync" if bookmark_pending else "Remove bookmark") if bookmark_value else ("Removal waiting to sync" if bookmark_pending else "Save question") + if calculator_button != null: + calculator_button.visible = true _apply_question_theme() +func _toggle_calculator() -> void: + if calculator_panel != null: + calculator_panel.toggle_panel() + + func _toggle_bookmark_current_question() -> void: var question := BlockSessionService.get_current_question() if not question.is_empty(): BookmarkService.toggle_question(question) +func _toggle_flashcard_current_question() -> void: + if current_answer.is_empty(): + return + var question := BlockSessionService.get_current_question() + if question.is_empty() or flashcard_prefill_dialog == null: + return + flashcard_prefill_dialog.open_for_question(question, current_answer) + + func _on_bookmark_status_changed(question_id: String, value: bool, pending: bool) -> void: if question_id != str(BlockSessionService.get_current_question().get("id", "")) or bookmark_button == null: return @@ -2037,8 +2365,6 @@ func _on_review_pass_completed(summary: Dictionary) -> void: func refresh_theme_controls() -> void: if media_viewer_background != null: media_viewer_background.color = Style.BACKGROUND - if flag_button != null: - flag_button.icon = _make_flag_icon() _refresh_toolbar_state() _apply_saved_choice_state() _apply_question_theme() @@ -2055,6 +2381,22 @@ func _adjust_question_text_scale(delta: float) -> void: if is_equal_approx(next_scale, question_text_scale): return question_text_scale = next_scale + AccountProfileService.update_text_scale(next_scale) + _refresh_scaled_fonts(self) + _render_stem() + _apply_saved_choice_state() + + +func _synced_question_text_scale() -> float: + var saved: float = AccountProfileService.preferences.get("questionTextScale", 1.0) + return clamp(saved, TEXT_SCALE_MIN, TEXT_SCALE_MAX) + + +func _on_account_profile_changed() -> void: + var synced := _synced_question_text_scale() + if is_equal_approx(synced, question_text_scale): + return + question_text_scale = synced _refresh_scaled_fonts(self) _render_stem() _apply_saved_choice_state() @@ -2679,7 +3021,10 @@ func _submit_question_report() -> void: return report_error_label.text = "" _set_report_submitting(true) - QuestionBankService.submit_question_report(report_question_id, category, details) + if BlockSessionService.session_kind == "dynamic": + DynamicQuestionService.submit_report(report_question_id, category, details) + else: + QuestionBankService.submit_question_report(report_question_id, category, details) func _on_question_report_submitted(question_id: String, _report_id: String) -> void: @@ -2933,20 +3278,14 @@ func _set_choice_button_text(index: int, choice: Dictionary) -> void: var display_label := _display_label_for_index(index) if index < choice_badges.size(): choice_badges[index].text = display_label - choice_buttons[index].custom_minimum_size = Vector2(0, 54) _set_choice_percent_label(index, "") _set_choice_top_label(index, "") - if locked_wrong_choice_ids.has(choice_id): + var reviewing := pending_correct_advance or not current_answer.is_empty() + if locked_wrong_choice_ids.has(choice_id) or reviewing: var percentage := _get_choice_selection_percentage(choice_id) if percentage >= 0: _set_choice_percent_label(index, "%d%%" % percentage) - choice_buttons[index].text = choice_text - elif _should_show_correct_choice_percentage(choice_id): - var correct_percentage := _get_choice_selection_percentage(choice_id) - if correct_percentage >= 0: - _set_choice_percent_label(index, "%d%%" % correct_percentage) - choice_buttons[index].text = choice_text - elif eliminated.has(choice_id): + if eliminated.has(choice_id): choice_buttons[index].text = "" _set_choice_top_label(index, "[s]%s[/s]" % _escape_bbcode(choice_text)) else: @@ -2962,18 +3301,19 @@ func _style_choice_button_for_state(index: int, choice_id: String) -> void: var reviewing := pending_correct_advance or not current_answer.is_empty() if reviewing and choice_id == correct_id: state = "correct" - elif locked_wrong_choice_ids.has(choice_id) or (reviewing and choice_id == selected_choice): + elif locked_wrong_choice_ids.has(choice_id) or (reviewing and choice_id != "" and choice_id != correct_id): state = "wrong" elif choice_id == selected_choice: state = "selected" elif eliminated.has(choice_id): state = "eliminated" var button := choice_buttons[index] - var normal_box := _answer_box(dark, state) + var attached := _row_has_attached_panel(index) + var normal_box := _answer_box(dark, state, attached) button.add_theme_stylebox_override("normal", normal_box) button.add_theme_stylebox_override("disabled", normal_box) - button.add_theme_stylebox_override("pressed", _answer_box(dark, "selected" if state == "default" else state)) - button.add_theme_stylebox_override("hover", _answer_box(dark, "selected" if state == "default" else state)) + button.add_theme_stylebox_override("pressed", _answer_box(dark, "selected" if state == "default" else state, attached)) + button.add_theme_stylebox_override("hover", _answer_box(dark, "selected" if state == "default" else state, attached)) var text_color := Brand.muted_color(dark) if state == "eliminated" else Brand.text_color(dark) button.add_theme_color_override("font_color", text_color) button.add_theme_color_override("font_disabled_color", text_color) @@ -2983,8 +3323,24 @@ func _style_choice_button_for_state(index: int, choice_id: String) -> void: if state == "selected": accent = Brand.PURPLE elif state == "correct": accent = Brand.SUCCESS elif state == "wrong": accent = Color("fb7185") - badge.add_theme_stylebox_override("normal", Brand.icon_circle_box(dark, accent, 15, state in ["selected", "correct", "wrong"])) - badge.add_theme_color_override("font_color", Color.WHITE if state in ["selected", "correct", "wrong"] else Brand.muted_color(dark)) + var filled := state in ["selected", "correct", "wrong"] + if index < choice_badge_textures.size(): + var badge_texture := choice_badge_textures[index] + badge_texture.texture = BADGE_CIRCLE_FILLED_ICON if filled else BADGE_CIRCLE_OUTLINE_ICON + badge_texture.modulate = accent + badge.add_theme_color_override("font_color", Color.WHITE if filled else Brand.muted_color(dark)) + if index < choice_selected_icons.size(): + choice_selected_icons[index].visible = state == "selected" + + +func _row_has_attached_panel(index: int) -> bool: + if index < 0 or index >= choice_rows.size(): + return false + if index < choice_followup_panels.size() and choice_followup_panels[index].visible: + return true + if correct_review_panel != null and correct_review_panel.visible and correct_review_panel.get_parent() == choice_rows[index]: + return true + return false func _mark_review_input_handled() -> void: @@ -3017,11 +3373,6 @@ func _get_choice_selection_percentage(choice_id: String) -> int: return clamp(int(percentages.get(choice_id, -1)), 0, 100) -func _should_show_correct_choice_percentage(choice_id: String) -> bool: - var question := BlockSessionService.get_current_question() - return choice_id == str(question.get("correct_choice_id", "")) and (pending_correct_advance or not current_answer.is_empty()) - - func _apply_correct_answer_preview() -> void: var question := BlockSessionService.get_current_question() var correct_id := str(question.get("correct_choice_id", "")) @@ -3069,7 +3420,6 @@ func _build_followup_panel(_index: int, choice_id: String) -> void: "selected_option_text": "", "was_correct": false, "resolved": false, - "collapsed": false, "has_authored_choices": bool(followup.get("has_authored_choices", false)), "is_main_correct": false } @@ -3084,7 +3434,6 @@ func _append_followup_attempt(choice_id: String) -> void: func _render_active_followup(index: int, state: Dictionary) -> void: var content := choice_followup_contents[index] - _build_followup_header(content, state, true, false) var body_content := _add_followup_body(content, false) var prompt := Label.new() @@ -3142,7 +3491,6 @@ func _resolve_followup(_followup: Dictionary, selected_button: Button, _style_se state["selected_option_text"] = selected_option_text state["was_correct"] = is_correct state["resolved"] = true - state["collapsed"] = false followup_states[resolved_choice_id] = state _render_followup_state(resolved_choice_id) @@ -3158,28 +3506,6 @@ func _unlock_after_followup() -> void: _apply_saved_choice_state() -func _collapse_resolved_followups() -> void: - for choice_id in followup_states.keys(): - var state: Dictionary = followup_states[choice_id] - if bool(state.get("resolved", false)): - state["collapsed"] = true - followup_states[choice_id] = state - _render_followup_state(choice_id) - _apply_saved_choice_state() - - -func _toggle_followup_state(choice_id: String) -> void: - if not followup_states.has(choice_id) or followup_required: - return - var state: Dictionary = followup_states[choice_id] - if not bool(state.get("resolved", false)): - return - state["collapsed"] = not bool(state.get("collapsed", false)) - followup_states[choice_id] = state - _render_followup_state(choice_id) - _apply_saved_choice_state() - - func _render_followup_state(choice_id: String) -> void: if not followup_states.has(choice_id): return @@ -3191,92 +3517,19 @@ func _render_followup_state(choice_id: String) -> void: _style_followup_card(index, state) if not bool(state.get("resolved", false)): _render_active_followup(index, state) - elif bool(state.get("collapsed", false)): - _build_followup_header(choice_followup_contents[index], state, false, true) else: _build_resolved_followup(index, state) choice_followup_panels[index].visible = true + _style_choice_button_for_state(index, choice_id) func _build_resolved_followup(index: int, state: Dictionary) -> void: var content := choice_followup_contents[index] - _build_followup_header(content, state, true, true) - var is_main_correct := bool(state.get("is_main_correct", false)) - var body_content := _add_followup_body(content, is_main_correct) - - var heading := Label.new() - heading.text = "Why this is appropriate:" if is_main_correct else "Why this is less appropriate:" - _set_scaled_label(heading, 14, Brand.SUCCESS if is_main_correct else Color("fb5f6d")) - body_content.add_child(heading) - var explanation := Label.new() - explanation.text = _choice_review_explanation(str(state.get("choice_id", "")), state) + explanation.text = _strip_verdict_prefix(_choice_review_explanation(str(state.get("choice_id", "")), state)) explanation.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART _set_scaled_label(explanation, 14, Brand.text_color(_followup_is_dark())) - body_content.add_child(explanation) - - if not is_main_correct and bool(state.get("has_authored_choices", false)): - var similar_button := Button.new() - similar_button.text = "Ask similar question" - similar_button.custom_minimum_size = Vector2(172, 38) - similar_button.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN - Style.style_chip(similar_button, Color("fb5f6d")) - _set_scaled_button_font(similar_button, 14) - similar_button.pressed.connect(_restart_followup.bind(str(state.get("choice_id", "")))) - body_content.add_child(similar_button) - - -func _build_followup_header(content: VBoxContainer, state: Dictionary, expanded: bool, interactive: bool) -> void: - var dark := _followup_is_dark() - var is_main_correct := bool(state.get("is_main_correct", false)) - var accent := Brand.SUCCESS if is_main_correct else Color("fb5f6d") - var row := HBoxContainer.new() - row.add_theme_constant_override("separation", 10) - content.add_child(row) - - var status := Label.new() - status.text = "✓" if is_main_correct else "x" - status.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER - status.vertical_alignment = VERTICAL_ALIGNMENT_CENTER - status.custom_minimum_size = Vector2(30, 30) - status.add_theme_stylebox_override("normal", Brand.icon_circle_box(dark, accent, 15, true)) - _set_scaled_label(status, 15, Color.WHITE) - row.add_child(status) - - var summary := Button.new() - summary.name = "FollowupSummary" - summary.flat = true - summary.text = "Review: %s. %s" % [_display_label_for_choice_id(str(state.get("choice_id", ""))), str(state.get("choice_text", ""))] - summary.alignment = HORIZONTAL_ALIGNMENT_LEFT - summary.size_flags_horizontal = Control.SIZE_EXPAND_FILL - summary.disabled = not interactive - _set_scaled_button_font(summary, 15) - summary.add_theme_color_override("font_color", Brand.text_color(dark)) - summary.add_theme_color_override("font_hover_color", accent) - summary.add_theme_color_override("font_disabled_color", Brand.text_color(dark)) - if interactive: - summary.pressed.connect(_toggle_followup_state.bind(str(state.get("choice_id", "")))) - row.add_child(summary) - - var percentage := _get_choice_selection_percentage(str(state.get("choice_id", ""))) - if percentage >= 0: - var percentage_label := Label.new() - percentage_label.text = "%d%%" % percentage - percentage_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER - percentage_label.custom_minimum_size = Vector2(58, 30) - percentage_label.add_theme_stylebox_override("normal", Brand.badge_box(dark, accent)) - _set_scaled_label(percentage_label, 14, accent) - row.add_child(percentage_label) - - if interactive: - var toggle := Button.new() - toggle.flat = true - toggle.tooltip_text = "Collapse review" if expanded else "Expand review" - toggle.icon = preload("res://assets/icons/lucide/chevron-down.svg") if expanded else preload("res://assets/icons/lucide/chevron-right.svg") - toggle.custom_minimum_size = Vector2(34, 30) - toggle.add_theme_color_override("icon_normal_color", Brand.text_color(dark)) - toggle.pressed.connect(_toggle_followup_state.bind(str(state.get("choice_id", "")))) - row.add_child(toggle) + content.add_child(explanation) func _add_followup_body(content: VBoxContainer, is_correct: bool) -> VBoxContainer: @@ -3292,26 +3545,13 @@ func _add_followup_body(content: VBoxContainer, is_correct: bool) -> VBoxContain func _style_followup_card(index: int, state: Dictionary) -> void: if index < 0 or index >= choice_followup_panels.size(): return + var is_correct := bool(state.get("is_main_correct", false)) choice_followup_panels[index].add_theme_stylebox_override( "panel", - _followup_card_box(_followup_is_dark(), bool(state.get("is_main_correct", false))) + _attached_panel_box(_followup_is_dark(), Brand.SUCCESS if is_correct else Color("fb7185")) ) -func _followup_card_box(dark: bool, is_correct: bool) -> StyleBoxFlat: - var accent := Brand.SUCCESS if is_correct else Color("fb5f6d") - var box := StyleBoxFlat.new() - box.bg_color = Color("0b172b") if dark else Color("ffffff") - box.border_color = Color(accent, 0.72) - box.set_border_width_all(1) - box.set_corner_radius_all(7) - box.content_margin_left = 12 - box.content_margin_right = 12 - box.content_margin_top = 10 - box.content_margin_bottom = 10 - return box - - func _followup_body_box(dark: bool, is_correct: bool) -> StyleBoxFlat: var accent := Brand.SUCCESS if is_correct else Color("fb5f6d") var box := StyleBoxFlat.new() @@ -3336,8 +3576,6 @@ func _choice_review_explanation(choice_id: String, state: Dictionary) -> String: var question := BlockSessionService.get_current_question() var explanations: Dictionary = question.get("choice_explanations", {}) var explanation := str(explanations.get(choice_id, "")).strip_edges() - if explanation.begins_with("Correct. "): - explanation = explanation.substr(9) if explanation != "": return explanation var teaching_point := str(state.get("teaching_point", "")).strip_edges() @@ -3348,39 +3586,7 @@ func _choice_review_explanation(choice_id: String, state: Dictionary) -> String: return "Review the stem finding that makes this option less appropriate." -func _restart_followup(choice_id: String) -> void: - if followup_required or not followup_states.has(choice_id): - return - var state: Dictionary = followup_states[choice_id] - if not bool(state.get("has_authored_choices", false)): - return - var options: Array = state.get("choices", []).duplicate(true) - options.shuffle() - state["choices"] = options - state["selected_option_id"] = "" - state["selected_option_text"] = "" - state["was_correct"] = false - state["resolved"] = false - state["collapsed"] = false - followup_states[choice_id] = state - active_followup_choice_id = choice_id - followup_required = true - followup_answered = false - next_button.disabled = true - if skip_button != null: - skip_button.disabled = true - _render_followup_state(choice_id) - var index := _choice_index_for_id(choice_id) - if index >= 0 and question_scroll != null: - question_scroll.call_deferred("ensure_control_visible", choice_followup_panels[index]) - - func _ensure_answer_review_followups() -> void: - for attempted_id in followup_states.keys(): - var attempted_state: Dictionary = followup_states[attempted_id] - if bool(attempted_state.get("resolved", false)): - attempted_state["collapsed"] = true - followup_states[attempted_id] = attempted_state for choice_id in followup_attempt_order: _render_followup_state(str(choice_id)) @@ -3497,22 +3703,6 @@ func _update_question_elo_label(show_label: bool) -> void: question_elo_label.visible = true -func _make_flag_icon() -> Texture2D: - var image := Image.create(24, 24, false, Image.FORMAT_RGBA8) - image.fill(Color(0, 0, 0, 0)) - for y in range(4, 20): - image.set_pixel(6, y, Style.TEXT) - image.set_pixel(7, y, Style.TEXT) - for x in range(8, 18): - for y in range(4, 11): - if x < 16 or y < 9: - image.set_pixel(x, y, Style.AMBER) - for y in range(11, 14): - for x in range(8, 14 - (y - 11)): - image.set_pixel(x, y, Style.AMBER) - return ImageTexture.create_from_image(image) - - func _refresh_nav_palette() -> void: if nav_palette == null: return diff --git a/scripts/ui/session_expiry_dialog.gd b/scripts/ui/session_expiry_dialog.gd index f07dbb5..a93826a 100644 --- a/scripts/ui/session_expiry_dialog.gd +++ b/scripts/ui/session_expiry_dialog.gd @@ -117,8 +117,8 @@ func _refresh_succeeded() -> void: func _refresh_failed(message: String, terminal: bool) -> void: - if not visible: - return + visible = true + move_to_front() error_label.text = message stay_button.text = "Stay logged in" stay_button.disabled = terminal @@ -161,6 +161,7 @@ func _apply_theme() -> void: return var dark := UIThemeService.is_dark_mode() var panel_style := Brand.panel_box(dark) + panel_style.bg_color.a = 1.0 panel_style.content_margin_left = 32 panel_style.content_margin_right = 32 panel_style.content_margin_top = 30 diff --git a/scripts/ui/settings_screen.gd b/scripts/ui/settings_screen.gd index 607f545..970ed1a 100644 --- a/scripts/ui/settings_screen.gd +++ b/scripts/ui/settings_screen.gd @@ -1,10 +1,18 @@ extends VBoxContainer const Brand := preload("res://scripts/ui/medmind_style.gd") +const QUESTION_TEXT_SCALE_MIN := 0.85 +const QUESTION_TEXT_SCALE_MAX := 1.35 +const QUESTION_TEXT_SCALE_STEP := 0.1 +const CORE_BLOCK_SIZE_STEP := 5 var main: Control var theme_toggle: CheckButton var sound_toggle: CheckButton +var followups_toggle: CheckButton +var question_scale_value_label: Label +var ccs_scale_value_label: Label +var core_block_size_value_label: Label func _ready() -> void: @@ -52,6 +60,17 @@ func _ready() -> void: sound_toggle.toggled.connect(UISoundService.set_sounds_enabled) content.add_child(sound_toggle) + followups_toggle = CheckButton.new() + followups_toggle.text = "Follow-up questions" + followups_toggle.tooltip_text = "Optional reinforcement questions shown after you answer" + followups_toggle.custom_minimum_size = Vector2(0, 52) + followups_toggle.toggled.connect(BlockSessionService.set_show_review_followups) + content.add_child(followups_toggle) + + content.add_child(_build_text_scale_row("Question bank text size", "question")) + content.add_child(_build_text_scale_row("CCS case text size", "ccs")) + content.add_child(_build_core_block_size_row()) + var legal_panel := PanelContainer.new() legal_panel.custom_minimum_size = Vector2(520, 0) legal_panel.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN @@ -74,11 +93,112 @@ func _ready() -> void: legal_content.add_child(button) UIThemeService.theme_changed.connect(_on_theme_changed) + AccountProfileService.profile_changed.connect(_refresh_text_scale_labels) + CCSService.text_scale_changed.connect(_refresh_text_scale_labels) + _refresh_text_scale_labels() func on_show(_payload: Variant = null) -> void: theme_toggle.set_pressed_no_signal(UIThemeService.is_dark_mode()) sound_toggle.set_pressed_no_signal(UISoundService.are_sounds_enabled()) + followups_toggle.set_pressed_no_signal(BlockSessionService.show_review_followups) + _refresh_text_scale_labels() + + +func _build_text_scale_row(label_text: String, kind: String) -> HBoxContainer: + var row := HBoxContainer.new() + row.custom_minimum_size = Vector2(0, 52) + row.add_theme_constant_override("separation", 12) + + var label := Label.new() + label.text = label_text + label.size_flags_horizontal = Control.SIZE_EXPAND_FILL + row.add_child(label) + + var smaller := Button.new() + smaller.text = "A-" + smaller.tooltip_text = "Decrease text size" + smaller.custom_minimum_size = Vector2(44, 44) + row.add_child(smaller) + + var value_label := Label.new() + value_label.custom_minimum_size = Vector2(56, 0) + value_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + row.add_child(value_label) + + var larger := Button.new() + larger.text = "A+" + larger.tooltip_text = "Increase text size" + larger.custom_minimum_size = Vector2(44, 44) + row.add_child(larger) + + if kind == "question": + question_scale_value_label = value_label + smaller.pressed.connect(_adjust_question_text_scale.bind(-QUESTION_TEXT_SCALE_STEP)) + larger.pressed.connect(_adjust_question_text_scale.bind(QUESTION_TEXT_SCALE_STEP)) + else: + ccs_scale_value_label = value_label + smaller.pressed.connect(_adjust_ccs_text_scale.bind(-CCSService.TEXT_SCALE_STEP)) + larger.pressed.connect(_adjust_ccs_text_scale.bind(CCSService.TEXT_SCALE_STEP)) + return row + + +func _build_core_block_size_row() -> HBoxContainer: + var row := HBoxContainer.new() + row.custom_minimum_size = Vector2(0, 52) + row.add_theme_constant_override("separation", 12) + + var label := Label.new() + label.text = "Question block size" + label.size_flags_horizontal = Control.SIZE_EXPAND_FILL + row.add_child(label) + + var smaller := Button.new() + smaller.text = "-" + smaller.tooltip_text = "Fewer questions per block" + smaller.custom_minimum_size = Vector2(44, 44) + smaller.pressed.connect(_adjust_core_block_size.bind(-CORE_BLOCK_SIZE_STEP)) + row.add_child(smaller) + + core_block_size_value_label = Label.new() + core_block_size_value_label.custom_minimum_size = Vector2(56, 0) + core_block_size_value_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + row.add_child(core_block_size_value_label) + + var larger := Button.new() + larger.text = "+" + larger.tooltip_text = "More questions per block" + larger.custom_minimum_size = Vector2(44, 44) + larger.pressed.connect(_adjust_core_block_size.bind(CORE_BLOCK_SIZE_STEP)) + row.add_child(larger) + return row + + +func _adjust_core_block_size(delta: int) -> void: + BlockSessionService.set_core_block_size(BlockSessionService.core_block_size + delta) + _refresh_text_scale_labels() + + +func _adjust_question_text_scale(delta: float) -> void: + var current: float = AccountProfileService.preferences.get("questionTextScale", 1.0) + var next_scale: float = clamp(current + delta, QUESTION_TEXT_SCALE_MIN, QUESTION_TEXT_SCALE_MAX) + AccountProfileService.update_text_scale(next_scale) + _refresh_text_scale_labels() + + +func _adjust_ccs_text_scale(delta: float) -> void: + CCSService.adjust_text_scale(delta) + _refresh_text_scale_labels() + + +func _refresh_text_scale_labels() -> void: + if question_scale_value_label != null: + var question_scale: float = AccountProfileService.preferences.get("questionTextScale", 1.0) + question_scale_value_label.text = "%d%%" % round(question_scale * 100) + if ccs_scale_value_label != null: + ccs_scale_value_label.text = "%d%%" % round(CCSService.text_scale * 100) + if core_block_size_value_label != null: + core_block_size_value_label.text = str(BlockSessionService.core_block_size) func _on_theme_toggled(enabled: bool) -> void: diff --git a/scripts/ui/study_selector.gd b/scripts/ui/study_selector.gd index de61422..4d2cc27 100644 --- a/scripts/ui/study_selector.gd +++ b/scripts/ui/study_selector.gd @@ -155,7 +155,7 @@ func _build_mode_view() -> void: mode_view.add_child(title) var subtitle := Label.new() - subtitle.text = "Build clinical judgment through focused questions. Additional modes are in development." + subtitle.text = "Build clinical judgment through focused authored questions." subtitle.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART subtitle.add_theme_font_override("font", Brand.font) subtitle.add_theme_font_size_override("font_size", 17) @@ -187,28 +187,19 @@ func _build_mode_view() -> void: retry_button = question_card.retry retry_button.pressed.connect(_retry_question_bank) - _add_mode_card( - "Dynamic Questions", - "AI-generated practice", - "Coming Soon", - "Coming Soon", - "Endless practice with AI-generated questions\ntailored to your performance.", - "activity", - false, - Brand.PURPLE) - - var ccs_card := _add_mode_card( - "CCS Cases", - "Interactive case simulations", - "Available", - "Available", - "Practice real-world clinical scenarios and\nimprove your management skills.", - "stethoscope", - true, - Brand.TEAL) - ccs_button = ccs_card.action - ccs_status_label = ccs_card.status - ccs_button.pressed.connect(_select_ccs) + if Brand.is_uat_environment(): + var ccs_card := _add_mode_card( + "CCS Cases", + "Interactive case simulations", + "Available", + "Available", + "Practice real-world clinical scenarios and\nimprove your management skills.", + "stethoscope", + true, + Brand.TEAL) + ccs_button = ccs_card.action + ccs_status_label = ccs_card.status + ccs_button.pressed.connect(_select_ccs) func _build_question_bank_launch_view() -> void: diff --git a/tests/auth_session_test.gd b/tests/auth_session_test.gd index f58931d..6349df7 100644 --- a/tests/auth_session_test.gd +++ b/tests/auth_session_test.gd @@ -14,6 +14,7 @@ func _run() -> void: await process_frame _test_expiration_parsing() _test_warning_and_expiration() + _test_active_user_silently_refreshes_instead_of_warning() _test_refresh_preserves_identity() _test_refresh_in_flight_has_expiry_grace() _test_stale_responses_cannot_restore_or_end_session() @@ -40,6 +41,7 @@ func _test_warning_and_expiration() -> void: auth.session_warning.connect(warning_callback) auth.session_expired.connect(expiration_callback) auth.set_session("tester", "warning-token", "refresh-token", "USER", str(Time.get_unix_time_from_system() + 90.0)) + auth.last_activity_unix = Time.get_unix_time_from_system() - 3600.0 auth._evaluate_expiry() _assert(int(observed["warning_seconds"]) > 0 and int(observed["warning_seconds"]) <= 90, "session should warn inside the two-minute renewal window") auth.expiration_unix = Time.get_unix_time_from_system() - 1.0 @@ -49,6 +51,25 @@ func _test_warning_and_expiration() -> void: auth.session_expired.disconnect(expiration_callback) +func _test_active_user_silently_refreshes_instead_of_warning() -> void: + var observed := {"warning_seconds": -1} + var warning_callback := func(seconds: int): observed["warning_seconds"] = seconds + auth.session_warning.connect(warning_callback) + # Point at a local, always-refused port so the real refresh attempt this triggers fails + # fast and never touches the real network - the point of this test is that + # request_session_refresh() gets called at all, not that it succeeds. + auth.auth_base_url = "http://127.0.0.1:1" + auth.set_session("tester", "active-token", "refresh-token", "USER", str(Time.get_unix_time_from_system() + 90.0)) + auth.last_activity_unix = Time.get_unix_time_from_system() + auth._evaluate_expiry() + _assert(int(observed["warning_seconds"]) == -1 and not auth.warning_active, "an actively-used session should not show the expiry warning") + _assert(auth.refresh_in_progress, "an actively-used session nearing expiry should silently attempt a refresh instead of warning") + auth.session_warning.disconnect(warning_callback) + auth.refresh_in_progress = false + auth.auth_base_url = auth.DEFAULT_AUTH_BASE_URL + auth.clear_session() + + func _test_refresh_preserves_identity() -> void: var observed := {"refreshed": false} var refreshed_callback := func(): observed["refreshed"] = true diff --git a/tests/calculator_panel_test.gd b/tests/calculator_panel_test.gd new file mode 100644 index 0000000..1639aee --- /dev/null +++ b/tests/calculator_panel_test.gd @@ -0,0 +1,126 @@ +extends SceneTree + +var failures := 0 + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + _test_basic_arithmetic() + _test_decimal_and_chained_operations() + _test_percent_and_sign() + _test_square_root() + _test_repeated_equals() + _test_backspace_and_clear() + _test_error_recovery() + await _test_runtime_panel_behavior() + quit(1 if failures > 0 else 0) + + +func _calculator(): + return load("res://scripts/ui/calculator_panel.gd").new() + + +func _press(calculator, tokens: Array) -> void: + for token in tokens: + calculator.press_token(str(token)) + + +func _test_basic_arithmetic() -> void: + var calculator = _calculator() + _press(calculator, ["9", "+", "6", "="]) + _assert(calculator.get_display_text() == "15", "calculator should add operands") + calculator.reset_calculation() + _press(calculator, ["9", "−", "4", "="]) + _assert(calculator.get_display_text() == "5", "calculator should subtract operands") + calculator.reset_calculation() + _press(calculator, ["7", "×", "8", "="]) + _assert(calculator.get_display_text() == "56", "calculator should multiply operands") + calculator.reset_calculation() + _press(calculator, ["8", "÷", "4", "="]) + _assert(calculator.get_display_text() == "2", "calculator should divide operands") + calculator.free() + + +func _test_decimal_and_chained_operations() -> void: + var calculator = _calculator() + _press(calculator, ["1", ".", "5", "+", "2", ".", "5", "×", "2", "="]) + _assert(calculator.get_display_text() == "8", "calculator should evaluate chained handheld operations left to right") + calculator.free() + + +func _test_percent_and_sign() -> void: + var calculator = _calculator() + _press(calculator, ["5", "%"]) + _assert(calculator.get_display_text() == "0.05", "percent should convert the displayed operand to a fraction") + _press(calculator, ["±"]) + _assert(calculator.get_display_text() == "-0.05", "sign toggle should negate the displayed operand") + calculator.free() + + +func _test_square_root() -> void: + var calculator = _calculator() + _press(calculator, ["8", "1", "√"]) + _assert(calculator.get_display_text() == "9", "square root should replace the displayed operand") + calculator.free() + + +func _test_repeated_equals() -> void: + var calculator = _calculator() + _press(calculator, ["5", "+", "2", "=", "="]) + _assert(calculator.get_display_text() == "9", "repeated equals should replay the last binary operation") + calculator.free() + + +func _test_backspace_and_clear() -> void: + var calculator = _calculator() + _press(calculator, ["1", "2", "3", "⌫"]) + _assert(calculator.get_display_text() == "12", "backspace should remove the final entry digit") + _press(calculator, ["AC"]) + _assert(calculator.get_display_text() == "0", "clear should reset the display") + calculator.free() + + +func _test_error_recovery() -> void: + var calculator = _calculator() + _press(calculator, ["8", "÷", "0", "="]) + _assert(calculator.get_display_text() == "Error", "division by zero should display an error") + _press(calculator, ["4"]) + _assert(calculator.get_display_text() == "4", "digit entry should recover from an error") + _press(calculator, ["±", "√"]) + _assert(calculator.get_display_text() == "Error", "square root of a negative operand should display an error") + calculator.free() + + +func _test_runtime_panel_behavior() -> void: + var host := Control.new() + host.size = Vector2(360, 640) + root.add_child(host) + var calculator = _calculator() + host.add_child(calculator) + await process_frame + calculator.open_panel() + await process_frame + _assert(calculator.visible, "calculator should open as a floating panel") + _assert(calculator.size.x <= 336.0, "calculator should fit a narrow web viewport") + _press(calculator, ["2", "+", "3", "="]) + calculator.close_panel() + calculator.open_panel() + _assert(calculator.get_display_text() == "5", "closing and reopening should preserve the active-session value") + calculator.position = Vector2(1000, 1000) + calculator._clamp_to_viewport() + _assert(calculator.position.x + calculator.size.x <= calculator.get_viewport_rect().size.x, "calculator dragging should remain horizontally bounded") + _assert(calculator.position.y + calculator.size.y <= calculator.get_viewport_rect().size.y, "calculator dragging should remain vertically bounded") + calculator.reset_calculation() + _assert(calculator.get_display_text() == "0", "session reset should clear calculator state") + host.queue_free() + await process_frame + + +func _assert(condition: bool, message: String) -> void: + if condition: + return + failures += 1 + push_error(message) diff --git a/tests/calculator_panel_test.gd.uid b/tests/calculator_panel_test.gd.uid new file mode 100644 index 0000000..65b40de --- /dev/null +++ b/tests/calculator_panel_test.gd.uid @@ -0,0 +1 @@ +uid://j08gujwjb7jx diff --git a/tests/ccs_player_test.gd b/tests/ccs_player_test.gd index 8b4d79b..6ec725e 100644 --- a/tests/ccs_player_test.gd +++ b/tests/ccs_player_test.gd @@ -1,8 +1,5 @@ extends SceneTree -const Style := preload("res://scripts/ui/ui_style.gd") -const Brand := preload("res://scripts/ui/medmind_style.gd") - var failures := 0 var ccs_service @@ -13,8 +10,6 @@ func _initialize() -> void: func _run() -> void: ccs_service = root.get_node("CCSService") - var theme_service = root.get_node("UIThemeService") - var prior_dark_mode: bool = theme_service.is_dark_mode() var prior_attempt: Dictionary = ccs_service.attempt.duplicate(true) var prior_orders: Array = ccs_service.orders.duplicate(true) var prior_scale: float = ccs_service.text_scale @@ -33,55 +28,42 @@ func _run() -> void: player._render() await process_frame - _assert(player.heading.text == "CCS Case | Emergency Department", "CCS header should show the readable location without a case id") - _assert(player._ordered_vital_keys(ccs_service.attempt.vitals) == ["temperature", "bloodPressure", "pulse", "respirations", "oxygenSaturation"], "vital chips should use the clinical priority order") - _assert(player.vitals_flow.get_child_count() == 5, "every supplied vital should render as a chip") - _assert(player.search_button.icon != null, "Order Clerk search should use a bundled icon rather than a font glyph") - _assert(player.summary.get_meta("ccs_region", "") == "summary", "summary highlighting should use its own region") - _assert(player.history.get_meta("ccs_region", "") == "history", "history highlighting should use its own region") - _assert(player.appearance.get_meta("ccs_region", "") == "appearance", "appearance highlighting should use its own region") - _assert(player.chart.get_meta("ccs_region", "") == "chart", "chart highlighting should use its own region") - _assert(not player.workspace.vertical, "wide CCS screens should use chart and Order Clerk columns") - _assert(player.orders.get_child_count() == 1, "active orders should render in the Order Clerk") - var active_order_row := player.orders.get_child(0) as HBoxContainer - var active_order_label := active_order_row.get_child(0) as Label - _assert(active_order_label.text == "Normal Saline Bolus", "internal order codes should be human readable") - _assert(player.actions.get_child_count() == 2, "available physical examinations should render as actions") - _assert(player.locations.get_child_count() == 2, "allowed locations should render as move actions") - _assert(not player.clock.visible, "the normal active-case countdown should remain hidden") - + _assert(player.heading.text == "CCS Case | Emergency Department", "header should show the readable location") + _assert(player.action_buttons.size() == 4, "CCS workspace should expose four persistent clinical tasks") + _assert(player.tabs.get_tab_count() == 7, "CCS chart should expose seven predictable tabs") + _assert(player.tabs.get_tab_title(0) == "Order Sheet", "the first chart tab should be the order sheet") + _assert(player.allotted_label.text.contains("18 minutes + 2 minutes"), "20-minute cases should reserve two final-order minutes") + _assert(player.active_orders.get_child_count() == 1, "active orders should render as structured rows") + _assert(player._ordered_vital_keys(ccs_service.attempt.vitals) == ["temperature", "bloodPressure", "pulse", "respirations", "oxygenSaturation"], "vitals should use clinical priority order") + _assert(player._chart_entries("lab").size() == 1, "categorized results should render in their assigned tab") + _assert(player._simulated_time_text(60).contains("10:00 AM"), "simulated time should derive from the case start") + _assert(not player.opening_active, "completed opening flows should not replay on resume") + _assert(not player.final_orders_notice.visible, "active cases should not show final-order instructions") var clerk_font_size: int = player.search.get_theme_font_size("font_size") - var chart_font_size: int = player.summary.get_theme_font_size("normal_font_size") + var original_size: int = player.clinical_labels[0].get_theme_font_size("normal_font_size") + var prior_profile_api_url: String = ProjectSettings.get_setting("step3/profile_api_url", "") + ProjectSettings.set_setting("step3/profile_api_url", "http://127.0.0.1:1") player._zoom(ccs_service.TEXT_SCALE_STEP) - _assert(player.summary.get_theme_font_size("normal_font_size") > chart_font_size, "text zoom should enlarge clinical chart text") - _assert(player.search.get_theme_font_size("font_size") == clerk_font_size, "text zoom should not resize Order Clerk controls") + ProjectSettings.set_setting("step3/profile_api_url", prior_profile_api_url) + _assert(player.clinical_labels[0].get_theme_font_size("normal_font_size") > original_size, "clinical text zoom should remain available") + _assert(player.search.get_theme_font_size("font_size") == clerk_font_size, "clinical text zoom should not resize order controls") + + player._stage_order("bmp") + _assert(player.staged_order_codes == ["bmp"], "orders should stage before submission") + _assert(not player.commit_orders_button.disabled, "staged orders should enable confirmation") + player._clear_staged_orders() + _assert(player.commit_orders_button.disabled, "clearing staged orders should disable confirmation") player.size = Vector2(800, 900) player._update_responsive_layout() - _assert(player.workspace.vertical, "narrow CCS screens should stack the Order Clerk below the chart") + _assert(player.action_grid.columns == 2, "narrow CCS screens should use a two-column action workspace") + ccs_service.attempt.state = "final_orders" - ccs_service.attempt.finalOrdersDeadlineUtc = Time.get_datetime_string_from_unix_time(Time.get_unix_time_from_system() + 90.0, true) player._render() - _assert(player.final_orders_notice.visible and player.clock.visible, "final-orders mode should show instructions and its deadline") - _assert(not player.time_row.visible, "time advancement should be unavailable during final orders") + _assert(player.final_orders_notice.visible, "final-orders mode should show instructions") + _assert(player.exit_button.text == "Submit Final Orders", "final-orders mode should expose final submission") + _assert(player.action_buttons[0].disabled and not player.action_buttons[1].disabled, "only order review should remain available in final orders") - theme_service.set_dark_mode(true) - await process_frame - theme_service.set_dark_mode(false) - Style.refresh_tree(player, true) - await process_frame - var light_panel := player.chart_panel.get_theme_stylebox("panel") as StyleBoxFlat - _assert(light_panel.bg_color.is_equal_approx(Brand.content_card_box(false, false).bg_color), "CCS light styles should win after the shell palette translation") - _assert(player.summary.get_theme_color("default_color").is_equal_approx(Brand.text_color(false)), "CCS light chart text should remain readable") - theme_service.set_dark_mode(true) - Style.refresh_tree(player, false) - await process_frame - var dark_panel := player.chart_panel.get_theme_stylebox("panel") as StyleBoxFlat - _assert(dark_panel.bg_color.is_equal_approx(Brand.content_card_box(true, false).bg_color), "CCS dark styles should win after the shell palette translation") - _assert(player.summary.get_theme_color("default_color").is_equal_approx(Brand.text_color(true)), "CCS dark chart text should remain readable") - - theme_service.set_dark_mode(prior_dark_mode) - await process_frame host.queue_free() await process_frame ccs_service.attempt = prior_attempt @@ -91,14 +73,24 @@ func _run() -> void: func _sample_attempt() -> Dictionary: + var now := Time.get_unix_time_from_system() return { "id": "attempt-1", "state": "active", + "revision": 1, + "openingCompleted": true, "location": "emergency_department", "patientSummary": "24-year-old woman with type 1 diabetes mellitus", "reasonForEncounter": "Two days of vomiting and progressive lethargy.", "history": "She stopped insulin when oral intake declined.", + "historySections": [{"code": "hpi", "title": "History of present illness", "text": "She stopped insulin when oral intake declined."}], "appearance": "She is lethargic but arousable.", + "simulatedStartUtc": "2026-07-15T09:00:00Z", + "simulatedMinute": 0, + "realTimeMinutes": 20, + "createdUtc": Time.get_datetime_string_from_unix_time(now - 60, true), + "deadlineUtc": Time.get_datetime_string_from_unix_time(now + 1020, true), + "hardDeadlineUtc": Time.get_datetime_string_from_unix_time(now + 1140, true), "vitals": { "oxygenSaturation": "98% on room air", "pulse": "118/min", @@ -106,14 +98,22 @@ func _sample_attempt() -> Dictionary: "respirations": "28/min", "bloodPressure": "88/56 mm Hg" }, - "chart": [{"simulatedMinute": 0, "category": "progress", "text": "Initial evaluation is in progress."}], - "activeOrderCodes": ["normal_saline_bolus"], - "availableExams": [ - {"code": "general", "name": "General and hydration examination"}, - {"code": "neurologic", "name": "Neurologic examination"} + "vitalHistory": [{"simulatedMinute": 0, "values": {"pulse": "118/min"}}], + "chart": [ + {"simulatedMinute": 0, "category": "progress", "code": "reason", "title": "Reason for encounter", "text": "Initial evaluation."}, + {"simulatedMinute": 12, "category": "lab", "code": "bmp", "title": "Basic metabolic panel", "text": "Glucose 580 mg/dL."} ], - "allowedLocations": ["emergency_department", "icu"], - "deadlineUtc": Time.get_datetime_string_from_unix_time(Time.get_unix_time_from_system() + 600.0, true) + "activeOrderCodes": ["normal_saline_bolus"], + "activeOrders": [{ + "code": "normal_saline_bolus", "name": "0.9% sodium chloride intravenous bolus", + "category": "medication", "route": "IV", "frequency": "Once", + "orderedMinute": 0, "reportMinute": 0 + }], + "availableExams": [ + {"code": "general", "name": "General and hydration examination", "durationMinutes": 1}, + {"code": "neurologic", "name": "Neurologic examination", "durationMinutes": 1} + ], + "allowedLocations": ["emergency_department", "icu"] } diff --git a/tests/flashcard_cloze_test.gd b/tests/flashcard_cloze_test.gd new file mode 100644 index 0000000..407361d --- /dev/null +++ b/tests/flashcard_cloze_test.gd @@ -0,0 +1,32 @@ +extends SceneTree + +const Cloze := preload("res://scripts/ui/flashcard_cloze.gd") + +var failures := 0 + + +func _initialize() -> void: + var text := "The capital of {{c1::France}} is {{c2::Paris::city}}." + + _assert(Cloze.render_front(text, 1) == "The capital of [...] is Paris.", + "front should hide the target cloze number and reveal the rest") + _assert(Cloze.render_front(text, 2) == "The capital of France is [city].", + "front should show the hint in brackets when one is provided") + _assert(Cloze.render_back(text) == "The capital of France is Paris.", + "back should reveal every cloze answer regardless of variant") + + var multiline := "Line one\n{{c1::multi\nline\nanswer}}\nLine two" + _assert(Cloze.render_back(multiline) == "Line one\nmulti\nline\nanswer\nLine two", + "cloze content spanning multiple lines should still parse as one deletion") + + _assert(Cloze.render_front("no markers here", 1) == "no markers here", + "text without cloze markers should render unchanged") + + quit(1 if failures > 0 else 0) + + +func _assert(condition: bool, message: String) -> void: + if condition: + return + failures += 1 + push_error(message) diff --git a/tests/flashcard_cloze_test.gd.uid b/tests/flashcard_cloze_test.gd.uid new file mode 100644 index 0000000..807771d --- /dev/null +++ b/tests/flashcard_cloze_test.gd.uid @@ -0,0 +1 @@ +uid://c7vg16bm3svqg diff --git a/tests/flashcard_navigation_test.gd b/tests/flashcard_navigation_test.gd new file mode 100644 index 0000000..54738d3 --- /dev/null +++ b/tests/flashcard_navigation_test.gd @@ -0,0 +1,23 @@ +extends SceneTree + +const NavigationRoutes := preload("res://scripts/ui/navigation_routes.gd") + +var failures := 0 + + +func _initialize() -> void: + _assert(NavigationRoutes.key_for_screen("flashcards_hub") == "flashcards", + "the flashcards library screen should activate the Flashcards nav entry") + _assert(NavigationRoutes.key_for_screen("flashcards_study") == "flashcards", + "the flashcards study screen should activate the Flashcards nav entry") + _assert(NavigationRoutes.key_for_screen("dashboard") == "home", + "unrelated routes should be unaffected by the new flashcards prefix rule") + + quit(1 if failures > 0 else 0) + + +func _assert(condition: bool, message: String) -> void: + if condition: + return + failures += 1 + push_error(message) diff --git a/tests/flashcard_navigation_test.gd.uid b/tests/flashcard_navigation_test.gd.uid new file mode 100644 index 0000000..50903d9 --- /dev/null +++ b/tests/flashcard_navigation_test.gd.uid @@ -0,0 +1 @@ +uid://brao3s40oeuax diff --git a/tests/flashcard_outbox_test.gd b/tests/flashcard_outbox_test.gd new file mode 100644 index 0000000..5136ad1 --- /dev/null +++ b/tests/flashcard_outbox_test.gd @@ -0,0 +1,51 @@ +extends SceneTree + +# Exercises FlashcardService's offline-review-queue bookkeeping without touching the network +# or user:// disk (mirrors the load(...).new() pattern used by review_queue_session_test.gd - +# the instance is never added to the tree, so _ready() never runs and the real HTTPRequest +# nodes/autoload wiring are never touched). + +var failures := 0 + + +func _initialize() -> void: + var service = load("res://scripts/autoload/flashcard_service.gd").new() + + var uuid: String = service._new_uuid() + _assert(uuid.length() == 36, "a client review id should be a standard 36-character UUID") + var segments := uuid.split("-") + _assert(segments.size() == 5, "a UUID should have five dash-separated segments") + _assert(segments[2].begins_with("4"), "the generated UUID should be version 4") + _assert("89ab".contains(segments[3][0]), "the generated UUID should have a valid RFC 4122 variant nibble") + + service.active_account_key = "account-a" + service.outbox = [ + {"account": "account-b", "payload": {"cardId": "other-account-card"}, "retry_count": 0}, + {"account": "account-a", "payload": {"cardId": "card-1"}, "retry_count": 0} + ] + _assert(service.has_pending_reviews(), "a queued review for the active account should be reported as pending") + _assert(service._next_account_item_index() == 1, + "outbox lookup should skip items queued under a different account (offline queue recovery must not leak across accounts)") + + service.active_account_key = "account-c" + _assert(not service.has_pending_reviews(), + "switching to an account with no queued reviews should report nothing pending") + + service.active_account_key = "account-a" + service.outbox[1]["active"] = true + _assert(service._active_item_index() == 1, "the in-flight retry item should be discoverable while marked active") + + var iso: String = service.current_study_day_start_utc() + _assert(iso.ends_with("Z"), "the study day boundary must be expressed as a UTC instant") + _assert(Time.get_unix_time_from_datetime_string(iso.trim_suffix("Z")) > 0, + "the study day boundary string should parse back into a valid datetime") + + service.free() + quit(1 if failures > 0 else 0) + + +func _assert(condition: bool, message: String) -> void: + if condition: + return + failures += 1 + push_error(message) diff --git a/tests/flashcard_outbox_test.gd.uid b/tests/flashcard_outbox_test.gd.uid new file mode 100644 index 0000000..37f1ade --- /dev/null +++ b/tests/flashcard_outbox_test.gd.uid @@ -0,0 +1 @@ +uid://ckoxu1ks8sr1d diff --git a/tests/flashcard_ownership_ui_test.gd b/tests/flashcard_ownership_ui_test.gd new file mode 100644 index 0000000..54fb50c --- /dev/null +++ b/tests/flashcard_ownership_ui_test.gd @@ -0,0 +1,107 @@ +extends SceneTree + +# Exercises the client-side "system vs. user" flashcard split added to the hub screen: deck +# summaries splitting into My/Official groups in the deck selector, Edit/Archive controls being +# absent on system-owned note rows, and "New Card" refusing to target a system deck. Mirrors +# flashcard_theme_responsive_test.gd's instantiation pattern (added to the tree so _ready() runs). + +var failures := 0 + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + var host := Control.new() + host.size = Vector2(1280, 800) + root.add_child(host) + + var Hub := load("res://scripts/ui/flashcards_hub.gd") + var hub = Hub.new() + hub.size = host.size + host.add_child(hub) + await process_frame + + # ---------- Deck selector splits into "My Decks" / "Official Decks" ---------- + var summaries := [ + {"deck": {"id": "deck-mine", "name": "My Cardio Deck", "isSystem": false}, "total": 5, "mastered": 0, "due": 1, "new": 2}, + {"deck": {"id": "deck-system", "name": "Step 3 Essentials", "isSystem": true}, "total": 10, "mastered": 3, "due": 0, "new": 4}, + ] + hub._on_decks_changed(summaries) + _assert(hub.deck_ids.size() == hub.deck_selector.item_count, + "deck_ids should stay index-aligned with the OptionButton's items (including separators)") + + var my_header_index := -1 + var official_header_index := -1 + var mine_item_index := -1 + var system_item_index := -1 + for i in hub.deck_selector.item_count: + if hub.deck_selector.is_item_separator(i): + if hub.deck_selector.get_item_text(i) == "My Decks": + my_header_index = i + elif hub.deck_selector.get_item_text(i) == "Official Decks": + official_header_index = i + elif hub.deck_selector.get_item_text(i) == "My Cardio Deck": + mine_item_index = i + elif hub.deck_selector.get_item_text(i) == "Step 3 Essentials": + system_item_index = i + + _assert(my_header_index >= 0, "a 'My Decks' separator header should be present") + _assert(official_header_index >= 0, "an 'Official Decks' separator header should be present") + _assert(mine_item_index > my_header_index, "the user's own deck should be listed under the 'My Decks' header") + _assert(system_item_index > official_header_index, "the system deck should be listed under the 'Official Decks' header") + _assert(str(hub.deck_ids[mine_item_index]) == "deck-mine", "deck_ids should still resolve the correct id for the user's own deck") + _assert(str(hub.deck_ids[system_item_index]) == "deck-system", "deck_ids should still resolve the correct id for the system deck") + _assert(hub._is_system_deck("deck-system"), "_is_system_deck should report true for the seeded system deck") + _assert(not hub._is_system_deck("deck-mine"), "_is_system_deck should report false for the user's own deck") + + # ---------- System note rows have no Edit/Archive controls, plus an "Official" badge ---------- + hub.notes = [ + {"id": "note-mine", "type": "basic", "prompt": "Mine", "answer": "A", "tags": [], "isSystem": false}, + {"id": "note-system", "type": "basic", "prompt": "System", "answer": "A", "tags": [], "isSystem": true}, + ] + hub._render_notes() + await process_frame + + var mine_row := hub.list_container.get_child(0) as BoxContainer + var mine_actions := mine_row.get_child(2) as HBoxContainer + _assert(mine_actions.get_child_count() == 2, "the user's own note row should show both Edit and Archive") + + var system_row := hub.list_container.get_child(2) as BoxContainer + var system_actions := system_row.get_child(2) as HBoxContainer + _assert(system_actions.get_child_count() == 0, "a system note row should show no Edit/Archive controls at all") + + var system_header_row := (system_row.get_child(1) as VBoxContainer).get_child(0) as HBoxContainer + var found_official_badge := false + for child in system_header_row.get_children(): + if child is Label and (child as Label).text == "Official": + found_official_badge = true + _assert(found_official_badge, "a system note row should show an 'Official' badge") + + # ---------- "New Card" against a system-selected deck falls back to the default deck ---------- + hub.decks = summaries + var system_index: int = hub.deck_ids.find("deck-system") + hub.deck_selector.select(system_index) + hub._open_editor("", {}) + _assert(hub.editor_target_deck_id.is_empty(), + "opening the editor for a brand-new card while a system deck is selected should fall back to the default deck instead") + hub._close_editor() + + var mine_index: int = hub.deck_ids.find("deck-mine") + hub.deck_selector.select(mine_index) + hub._open_editor("", {}) + _assert(hub.editor_target_deck_id == "deck-mine", + "opening the editor for a brand-new card while the user's own deck is selected should target that deck") + + hub.free() + host.queue_free() + await process_frame + quit(1 if failures > 0 else 0) + + +func _assert(condition: bool, message: String) -> void: + if condition: + return + failures += 1 + push_error(message) diff --git a/tests/flashcard_ownership_ui_test.gd.uid b/tests/flashcard_ownership_ui_test.gd.uid new file mode 100644 index 0000000..65ff0be --- /dev/null +++ b/tests/flashcard_ownership_ui_test.gd.uid @@ -0,0 +1 @@ +uid://b1nkwx4qlt27l diff --git a/tests/flashcard_session_navigation_test.gd b/tests/flashcard_session_navigation_test.gd new file mode 100644 index 0000000..b6dc807 --- /dev/null +++ b/tests/flashcard_session_navigation_test.gd @@ -0,0 +1,173 @@ +extends SceneTree + +# Exercises the study screen's session-history navigation (skip / Previous / Next / undo-in- +# history) and the layout/footer-state fixes added alongside it. The screen is added to the +# tree (mirrors flashcard_theme_responsive_test.gd) so _input()'s tree-visibility guard behaves +# realistically; every FlashcardService call this drives (request_next_card/bury_cards/ +# suspend_cards) short-circuits before touching the network because AuthState reports no +# logged-in session in this headless context, matching the "no network" style of the other +# flashcard tests. + +var failures := 0 + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + var host := Control.new() + host.size = Vector2(1280, 800) + root.add_child(host) + + var Study := load("res://scripts/ui/flashcards_study.gd") + var screen = Study.new() + host.add_child(screen) + await process_frame + + # ---------- Layout: side panels must hug their content, not stretch full height ---------- + _assert(screen.deck_panel.size_flags_vertical == Control.SIZE_SHRINK_BEGIN, + "the current-deck panel should not stretch to the row's full height") + _assert(screen.stats_panel.size_flags_vertical == Control.SIZE_SHRINK_BEGIN, + "the spaced-repetition panel should not stretch to the row's full height") + + # ---------- Footer button states at session start (no history, no live card) ---------- + _assert(screen.previous_button.disabled, "Previous should be disabled with no history to step back into") + _assert(screen.next_button.disabled, "Next should be disabled with no live card to skip and no history to page through") + + screen.current_note = {"type": "basic", "prompt": "P3(live)", "answer": "A3", "extra": ""} + screen.current_card = {"id": "card-3", "variant": 0, "revision": 1} + screen.rating_options = [] + screen.due_remaining = 5 + screen.new_remaining = 3 + screen.session_total = 10 + screen._update_footer_button_states() + _assert(not screen.next_button.disabled, "Next should enable once a live card is present, to allow skipping it") + _assert(screen.previous_button.disabled, "Previous should still be disabled before any history exists") + + # ---------- Options menu opens even with no live card, and disables Bury/Suspend ---------- + screen.current_note = {} + screen.current_card = {} + screen._toggle_options_menu() + _assert(screen.options_menu.visible, "Options should open even with no live card (e.g. deck exhausted)") + _assert(screen.options_bury_button.disabled and screen.options_suspend_button.disabled, + "Bury/Suspend should disable when there is no live card to act on") + screen._close_options_menu() + + screen.current_note = {"type": "basic", "prompt": "P3(live)", "answer": "A3", "extra": ""} + screen.current_card = {"id": "card-3", "variant": 0, "revision": 1} + screen._toggle_options_menu() + _assert(not screen.options_bury_button.disabled and not screen.options_suspend_button.disabled, + "Bury/Suspend should enable once a live card is present") + screen._close_options_menu() + + # ---------- Undo-in-history only surfaces for the matching most-recently-graded entry ---------- + screen.session_history = [{ + "note": {"type": "basic", "prompt": "P1", "answer": "A1", "extra": ""}, + "card": {"id": "card-1", "variant": 0, "revision": 1}, + "rating_options": [], "action": "graded", "card_id": "card-1", + "rating": "good", "client_review_id": "review-1" + }] + screen.last_card_id = "card-99" + screen.last_client_review_id = "review-1" + screen._render_history_entry(0) + _assert(not screen.undo_button.visible, + "undo should not surface when the browsed entry's card id doesn't match the last graded card") + + screen.last_card_id = "" + screen.last_client_review_id = "" + screen._render_history_entry(0) + _assert(not screen.undo_button.visible, + "undo should not surface when there is no outstanding undoable review at all") + + # ---------- Skip: pushes a "skipped" history entry, excludes the card, no grading occurs ---------- + screen.seen_card_ids = [] + screen.session_history = [] + screen.history_cursor = 0 + screen.current_note = {"type": "basic", "prompt": "P3(live)", "answer": "A3", "extra": ""} + screen.current_card = {"id": "card-3", "variant": 0, "revision": 1} + screen.awaiting_next = false + screen._skip_current_card() + _assert(screen.session_history.size() == 1 and screen.session_history[0]["action"] == "skipped", + "skipping the live card should push a 'skipped' history entry") + _assert(screen.seen_card_ids.has("card-3"), "skipping should still exclude the card from reappearing this session") + + # ---------- Previous/Next paging through a hand-seeded history, back to live ---------- + screen.seen_card_ids = [] + screen.last_card_id = "card-1" + screen.last_client_review_id = "review-1" + screen.session_history = [ + { + "note": {"type": "basic", "prompt": "P1", "answer": "A1", "extra": ""}, + "card": {"id": "card-1", "variant": 0, "revision": 1}, + "rating_options": [], "action": "graded", "card_id": "card-1", + "rating": "good", "client_review_id": "review-1" + }, + { + "note": {"type": "basic", "prompt": "P2", "answer": "A2", "extra": ""}, + "card": {"id": "card-2", "variant": 0, "revision": 1}, + "rating_options": [], "action": "skipped", "card_id": "card-2" + } + ] + screen.history_cursor = screen.session_history.size() + screen.current_note = {"type": "basic", "prompt": "P3(live)", "answer": "A3", "extra": ""} + screen.current_card = {"id": "card-3", "variant": 0, "revision": 1} + screen.rating_options = [] + screen.due_remaining = 5 + screen.new_remaining = 3 + screen.revealed = false + + screen._go_previous() + _assert(screen.front_label.text == "P2", "the first Previous press should show the most recently added history entry") + _assert(not screen.grade_row.visible, "grading buttons should stay hidden while browsing history") + _assert(not screen.undo_button.visible, "a skipped history entry should never offer 'undo this rating'") + + screen._go_previous() + _assert(screen.front_label.text == "P1", "a second Previous press should step further back in history") + _assert(screen.undo_button.visible, "the specific most-recently-graded entry should surface 'undo this rating'") + + screen._go_previous() + _assert(screen.history_cursor == 0, "Previous should refuse to step before the start of history") + + screen._go_next() + _assert(screen.front_label.text == "P2", "Next while browsing history should step forward, not skip the live card") + + screen._go_next() + _assert(screen.front_label.text == "P3(live)" and screen.history_cursor == screen.session_history.size(), + "stepping past the last history entry should return to the live card") + _assert(screen.tap_hint_label.visible and not screen.grade_row.visible, + "returning to a live, unrevealed card should restore the tap-to-reveal hint") + + screen._go_next() + _assert(screen.session_history.size() == 3 and screen.session_history[2]["action"] == "skipped" and screen.session_history[2]["card_id"] == "card-3", + "Next while already at the live card should skip it (session-only, dual-meaning behavior)") + _assert(screen.seen_card_ids.has("card-3"), "skipping via Next should still exclude the card this session") + + # ---------- Keyboard shortcuts are ignored while browsing history ---------- + screen.session_history = [{ + "note": {"type": "basic", "prompt": "P1", "answer": "A1", "extra": ""}, + "card": {"id": "card-1", "variant": 0, "revision": 1}, + "rating_options": [], "action": "graded", "card_id": "card-1" + }] + screen.history_cursor = 0 + screen.current_note = {"type": "basic", "prompt": "P1", "answer": "A1", "extra": ""} + screen.current_card = {"id": "card-1", "variant": 0, "revision": 1} + screen.revealed = true + var key_event := InputEventKey.new() + key_event.keycode = KEY_1 + key_event.pressed = true + screen._input(key_event) + _assert(screen.session_history.size() == 1, + "a grading keystroke while browsing history should be ignored instead of grading the historical card") + + screen.free() + host.queue_free() + await process_frame + quit(1 if failures > 0 else 0) + + +func _assert(condition: bool, message: String) -> void: + if condition: + return + failures += 1 + push_error(message) diff --git a/tests/flashcard_session_navigation_test.gd.uid b/tests/flashcard_session_navigation_test.gd.uid new file mode 100644 index 0000000..f4a39e1 --- /dev/null +++ b/tests/flashcard_session_navigation_test.gd.uid @@ -0,0 +1 @@ +uid://cmhk4acaxbsjs diff --git a/tests/flashcard_study_render_test.gd b/tests/flashcard_study_render_test.gd new file mode 100644 index 0000000..76f4751 --- /dev/null +++ b/tests/flashcard_study_render_test.gd @@ -0,0 +1,88 @@ +extends SceneTree + +# Drives the flashcards study screen's reveal/grading render logic and keyboard mapping +# directly (no network): the screen is added to the tree so _ready() builds its controls and +# wires the FlashcardService signal connections, but no method that would send an HTTP +# request (submit_review/request_next_card) is invoked. + +var failures := 0 + + +func _initialize() -> void: + var Study := load("res://scripts/ui/flashcards_study.gd") + var screen = Study.new() + screen._build() + + # Keyboard grading shortcuts: 1-4 map to Again/Hard/Good/Easy; everything else is inert. + _assert(Study.rating_for_keycode(KEY_1) == "again", "1 should grade Again") + _assert(Study.rating_for_keycode(KEY_2) == "hard", "2 should grade Hard") + _assert(Study.rating_for_keycode(KEY_3) == "good", "3 should grade Good") + _assert(Study.rating_for_keycode(KEY_4) == "easy", "4 should grade Easy") + _assert(Study.rating_for_keycode(KEY_5) == "", "keys outside 1-4 should not grade a card") + + # Basic card: front-first, then reveal shows answer + extra and the four graded options. + screen.current_note = {"type": "basic", "prompt": "2 + 2 = ?", "answer": "4", "extra": "Basic arithmetic"} + screen.current_card = {"id": "card-1", "variant": 0, "revision": 1} + screen.rating_options = [ + {"rating": "again", "intervalLabel": "<1m"}, {"rating": "hard", "intervalLabel": "6m"}, + {"rating": "good", "intervalLabel": "1d"}, {"rating": "easy", "intervalLabel": "4d"} + ] + screen._render_card() + _assert(screen.front_label.text == "2 + 2 = ?", "the front should show only the prompt before reveal") + _assert(screen.tap_hint_label.visible, "the tap-to-reveal hint should be visible before the answer is shown") + _assert(not screen.grade_row.visible, "grading buttons should be hidden before reveal") + _assert(not screen.back_label.visible, "the back should stay hidden before reveal") + + screen._reveal() + _assert(screen.back_label.text == "4", "reveal should show the answer on its own, separate from the extra pearl") + _assert(screen.extra_label.text == "Basic arithmetic" and screen.extra_label.visible, "the extra takeaway should render in its own labeled section") + _assert(screen.answer_caption.visible and screen.extra_caption.visible, "revealing a card with an extra should show both the answer and takeaway captions") + _assert(screen.grade_row.visible, "grading buttons should appear after reveal") + _assert(not screen.tap_hint_label.visible, "the tap-to-reveal hint should hide once the card is revealed") + _assert(screen.grade_buttons["good"].text.contains("1d"), "each grade button should show its computed next interval") + + # Cloze card: only the card's own variant is hidden on the front; back reveals everything. + screen.current_note = {"type": "cloze", "prompt": "{{c1::Paris}} is the capital of France", "answer": "", "extra": "European capitals are a classic Step 3 target"} + screen.current_card = {"id": "card-2", "variant": 1, "revision": 1} + screen.revealed = false + screen._render_card() + _assert(screen.front_label.text == "[...] is the capital of France", "cloze front should hide only its own variant") + screen._reveal() + _assert(screen.back_label.text == "Paris is the capital of France", "cloze back should reveal every deletion") + _assert(screen.extra_label.text == "European capitals are a classic Step 3 target" and screen.extra_label.visible, + "a cloze note's extra takeaway should also render (previously silently dropped since only basic notes combined answer+extra into one label)") + + # A card with no extra should hide the takeaway section entirely rather than show an empty caption. + screen.current_note = {"type": "basic", "prompt": "What is 1 + 1?", "answer": "2", "extra": ""} + screen.current_card = {"id": "card-3", "variant": 0, "revision": 1} + screen.revealed = false + screen._render_card() + screen._reveal() + _assert(not screen.extra_label.visible and not screen.extra_caption.visible, "a card with no extra takeaway should hide that section instead of showing an empty caption") + + # Tapping the card panel reveals the answer, same as pressing Space. + screen.current_note = {"type": "basic", "prompt": "Tap test", "answer": "Tapped", "extra": ""} + screen.current_card = {"id": "card-4", "variant": 0, "revision": 1} + screen.revealed = false + screen._render_card() + var click := InputEventMouseButton.new() + click.button_index = MOUSE_BUTTON_LEFT + click.pressed = true + screen._on_card_panel_input(click) + _assert(screen.revealed and screen.back_label.text == "Tapped", "clicking the card panel should reveal the answer") + + # A second tap after reveal (or while browsing history) is a no-op - it must not re-trigger + # any reveal side effects (grading is guarded elsewhere; this only checks idempotence). + screen.back_label.text = "Tapped" + screen._on_card_panel_input(click) + _assert(screen.back_label.text == "Tapped", "tapping an already-revealed card should be a no-op") + + screen.free() + quit(1 if failures > 0 else 0) + + +func _assert(condition: bool, message: String) -> void: + if condition: + return + failures += 1 + push_error(message) diff --git a/tests/flashcard_study_render_test.gd.uid b/tests/flashcard_study_render_test.gd.uid new file mode 100644 index 0000000..2db7158 --- /dev/null +++ b/tests/flashcard_study_render_test.gd.uid @@ -0,0 +1 @@ +uid://b4g4ry3jt2gxn diff --git a/tests/flashcard_theme_responsive_test.gd b/tests/flashcard_theme_responsive_test.gd new file mode 100644 index 0000000..25d0441 --- /dev/null +++ b/tests/flashcard_theme_responsive_test.gd @@ -0,0 +1,152 @@ +extends SceneTree + +# Flashcards previously had no coverage for theme switching or narrow-viewport layout: the +# library screen's `_refresh_status()` and the study screen's `refresh_theme_controls()` were +# both no-op stubs, and neither screen reacted to its own resize. This drives both screens +# through a dark/light toggle and a phone-width resize with no network calls (mirrors +# flashcard_study_render_test.gd: content is injected directly rather than fetched). + +const Brand := preload("res://scripts/ui/medmind_style.gd") + +var failures := 0 + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + var host := Control.new() + host.size = Vector2(1280, 800) + root.add_child(host) + + var theme_service = root.get_node("UIThemeService") + var prior_dark: bool = theme_service.is_dark_mode() + + var Hub := load("res://scripts/ui/flashcards_hub.gd") + var hub = Hub.new() + hub.size = host.size + host.add_child(hub) + await process_frame + + hub.notes = [{"id": "note-1", "type": "basic", "prompt": "2 + 2", "answer": "4", "tags": []}] + + theme_service.set_dark_mode(true) + hub._render_notes() + await process_frame + var dark_list_box := hub.list_card.get_theme_stylebox("panel") as StyleBoxFlat + _assert(dark_list_box.bg_color.is_equal_approx(Color("081426")), + "the flashcards list card should use the dark surface while dark mode is active") + var dark_row := hub.list_container.get_child(0) as BoxContainer + var dark_icon_box := (dark_row.get_child(0) as PanelContainer).get_theme_stylebox("panel") as StyleBoxFlat + _assert(dark_icon_box.bg_color.is_equal_approx(Brand.icon_circle_box(true, Brand.ROYAL_BLUE, 20).bg_color), + "a basic card's type icon should use its dark accent styling") + + theme_service.set_dark_mode(false) + await process_frame + var light_list_box := hub.list_card.get_theme_stylebox("panel") as StyleBoxFlat + _assert(light_list_box.bg_color.is_equal_approx(Color.WHITE), + "toggling the app theme should rebuild the flashcards list card with the light surface (the library screen's theme_changed handler was previously a no-op)") + var light_row := hub.list_container.get_child(0) as BoxContainer + var light_icon_box := (light_row.get_child(0) as PanelContainer).get_theme_stylebox("panel") as StyleBoxFlat + _assert(light_icon_box.bg_color.is_equal_approx(Brand.icon_circle_box(false, Brand.ROYAL_BLUE, 20).bg_color), + "a basic card's type icon should use its light accent styling") + var light_menu_box := hub.import_menu.get_theme_stylebox("panel") as StyleBoxFlat + _assert(light_menu_box.bg_color.is_equal_approx(Brand.panel_box(false).bg_color), + "the import menu panel should also restyle to the light surface after a theme toggle") + + theme_service.set_dark_mode(prior_dark) + + hub.size = Vector2(360, 800) + hub._update_responsive_layout() + await process_frame + _assert(hub.compact_layout, "the flashcards library should detect a phone-width viewport") + var compact_row := hub.list_container.get_child(0) as BoxContainer + _assert(compact_row.vertical, "phone-width flashcard rows should stack their content above the edit/archive actions instead of clipping") + _assert(compact_row.get_combined_minimum_size().x <= hub.size.x, + "a phone-width flashcard row should fit inside the viewport (minimum %.1f px)" % compact_row.get_combined_minimum_size().x) + + hub.size = Vector2(1280, 800) + hub._update_responsive_layout() + await process_frame + _assert(not hub.compact_layout, "a wide viewport should restore the flashcards library's desktop row layout") + var wide_row := hub.list_container.get_child(0) as BoxContainer + _assert(not wide_row.vertical, "a wide viewport should lay flashcard row content out horizontally again") + + hub.free() + + var Study := load("res://scripts/ui/flashcards_study.gd") + var study = Study.new() + study._build() + + theme_service.set_dark_mode(true) + study.refresh_theme_controls() + var dark_study_box := study.card_panel.get_theme_stylebox("panel") as StyleBoxFlat + _assert(dark_study_box.bg_color.is_equal_approx(Brand.content_card_box(true, true, Brand.ROYAL_BLUE).bg_color), + "the study card should use the dark active-card surface while dark mode is active") + + theme_service.set_dark_mode(false) + study.refresh_theme_controls() + var light_study_box := study.card_panel.get_theme_stylebox("panel") as StyleBoxFlat + _assert(light_study_box.bg_color.is_equal_approx(Brand.content_card_box(false, true, Brand.ROYAL_BLUE).bg_color), + "toggling the app theme should restyle the study card to the light surface (refresh_theme_controls was previously a no-op)") + _assert(light_study_box.border_color.is_equal_approx(Brand.ROYAL_BLUE), + "the study card should keep its accent border after a theme refresh") + + theme_service.set_dark_mode(prior_dark) + + study.size = Vector2(360, 720) + study._update_responsive_layout() + _assert(study.compact_layout, "the study screen should detect a phone-width viewport") + _assert(study.card_panel.custom_minimum_size.x <= study.size.x, + "the study card should shrink to fit a phone-width viewport instead of overflowing (minimum %.1f px on a %.1f px viewport)" % [study.card_panel.custom_minimum_size.x, study.size.x]) + + study.size = Vector2(1280, 720) + study._update_responsive_layout() + _assert(not study.compact_layout, "a wide viewport should restore the study screen's desktop layout") + _assert(is_equal_approx(study.card_panel.custom_minimum_size.x, 720.0), + "a wide viewport should restore the study card's full 720px width") + _assert(study.deck_panel.visible and study.stats_panel.visible, + "a wide viewport should show the current-deck and spaced-repetition side panels") + + study.size = Vector2(360, 720) + study._update_responsive_layout() + _assert(not study.deck_panel.visible and not study.stats_panel.visible, + "a phone-width viewport should hide the side panels rather than squeeze a 3-column layout into no room") + + study.deck_id = "deck-1" + study.decks = [ + {"deck": {"id": "deck-1", "name": "Nephrology"}, "total": 150, "mastered": 48}, + {"deck": {"id": "deck-2", "name": "Cardiology"}, "total": 40, "mastered": 40}, + ] + study._refresh_deck_panel() + _assert(study.deck_name_label.text == "Nephrology", "the deck panel should show the currently-studied deck's name, not just the first one") + _assert(study.deck_mastered_label.text == "48 / 150 cards mastered", "the deck panel should report mastered/total for the current deck only") + + study.deck_id = "" + study._refresh_deck_panel() + _assert(study.deck_name_label.text == "All decks", "studying with no deck filter should label the panel \"All decks\"") + _assert(study.deck_mastered_label.text == "88 / 190 cards mastered", "\"All decks\" should sum mastered/total across every deck") + + study.latest_counts = { + "due": 5, "newAvailable": 15, "learning": 48, "review": 132, + "currentStreakDays": 1, "reviewsToday": 25, "dailyGoal": 50 + } + study._refresh_stats_panel() + _assert(study.stats_due_label.text == "5" and study.stats_learning_label.text == "48" and study.stats_review_label.text == "132", + "the spaced-repetition panel should reflect the latest due/learning/review counts") + _assert(study.streak_label.text == "1 day", "a streak of exactly one day should use the singular form") + _assert(study.daily_goal_label.text == "25 / 50" and is_equal_approx(study.daily_goal_bar.value, 25.0), + "the daily goal panel should show today's review count against the account's configured goal") + + study.free() + host.queue_free() + await process_frame + quit(1 if failures > 0 else 0) + + +func _assert(condition: bool, message: String) -> void: + if condition: + return + failures += 1 + push_error(message) diff --git a/tests/flashcard_theme_responsive_test.gd.uid b/tests/flashcard_theme_responsive_test.gd.uid new file mode 100644 index 0000000..ce9b26a --- /dev/null +++ b/tests/flashcard_theme_responsive_test.gd.uid @@ -0,0 +1 @@ +uid://y8dt7drjo221 diff --git a/tests/post_answer_review_test.gd b/tests/post_answer_review_test.gd index 5b02a2a..3516fdc 100644 --- a/tests/post_answer_review_test.gd +++ b/tests/post_answer_review_test.gd @@ -11,11 +11,11 @@ func _initialize() -> void: _test_correct_feedback_does_not_auto_advance() _test_correct_review_locks_answer_interactions() _test_eliminated_choice_uses_rich_text_strikeout() + _test_eliminated_choice_that_is_also_locked_wrong_still_strikes_through() _test_explanation_highlighting_has_no_status_heading() - _test_collapsed_followup_review_expands() + _test_resolved_followup_has_no_header_or_verdict_prefix() _test_review_content_normalization() _test_followup_attempt_order_is_stable() - _test_answer_review_collapses_nested_wrong_followups() _test_review_followup_is_optional_and_single_attempt() _test_question_failure_retry_classification() _test_transient_question_failure_keeps_player_waiting() @@ -143,6 +143,27 @@ func _test_eliminated_choice_uses_rich_text_strikeout() -> void: player.free() +func _test_eliminated_choice_that_is_also_locked_wrong_still_strikes_through() -> void: + var player = load("res://scripts/ui/question_player.gd").new() + player.choice_buttons.append(Button.new()) + player.choice_percent_labels.append(Label.new()) + var strike_label := RichTextLabel.new() + strike_label.bbcode_enabled = true + player.choice_top_labels.append(strike_label) + player.choice_followup_panels.append(PanelContainer.new()) + player.eliminated = ["A"] + player.locked_wrong_choice_ids = ["A"] + player.presented_choices = [{"id": "A", "text": "Observation only"}] + player._set_choice_button_text(0, player.presented_choices[0]) + _assert(strike_label.text == "[s]Observation only[/s]", "a choice that's both eliminated and locked-wrong should still strike through") + _assert(player.choice_buttons[0].text == "", "the struck-through choice's button text should stay cleared") + player.choice_buttons[0].free() + player.choice_percent_labels[0].free() + strike_label.free() + player.choice_followup_panels[0].free() + player.free() + + func _test_explanation_highlighting_has_no_status_heading() -> void: var player = load("res://scripts/ui/question_player.gd").new() player.explanation_label = RichTextLabel.new() @@ -156,7 +177,7 @@ func _test_explanation_highlighting_has_no_status_heading() -> void: player.free() -func _test_collapsed_followup_review_expands() -> void: +func _test_resolved_followup_has_no_header_or_verdict_prefix() -> void: var player = load("res://scripts/ui/question_player.gd").new() var panel := PanelContainer.new() var content := VBoxContainer.new() @@ -171,14 +192,16 @@ func _test_collapsed_followup_review_expands() -> void: player.followup_states["B"] = { "choice_id": "B", "choice_text": "Wrong answer", "prompt": "Why?", "selected_option_text": "Finding", "was_correct": false, - "teaching_point": "Review the finding.", "resolved": true, "collapsed": true + "teaching_point": "Incorrect. Review the finding.", "resolved": true } player._render_followup_state("B") - var review_button := content.find_child("FollowupSummary", true, false) as Button - _assert(review_button != null, "collapsed follow-up should render a review button") - if review_button != null: - review_button.pressed.emit() - _assert(not bool(player.followup_states["B"].get("collapsed", true)), "review button should expand the resolved follow-up") + _assert(content.find_child("FollowupToggle", true, false) == null, "a resolved follow-up should not render a collapse toggle") + var explanation_text := "" + for child in content.get_children(): + if child is Label: + explanation_text += child.text + _assert(not explanation_text.contains("Why this is"), "a resolved follow-up should not repeat a 'Why this is...' heading") + _assert(not explanation_text.begins_with("Incorrect."), "a resolved follow-up explanation should not repeat the verdict prefix") player.choice_buttons[0].free() player.choice_percent_labels[0].free() player.choice_top_labels[0].free() @@ -226,16 +249,6 @@ func _test_followup_attempt_order_is_stable() -> void: player.free() -func _test_answer_review_collapses_nested_wrong_followups() -> void: - var player = load("res://scripts/ui/question_player.gd").new() - player.followup_states["C"] = {"resolved": true, "collapsed": false, "is_main_correct": false} - player.followup_attempt_order = ["C"] - player._ensure_answer_review_followups() - _assert(bool(player.followup_states["C"].get("collapsed", false)), "resolved distractor remediation should collapse for final review") - _assert(not player.followup_states.has("B"), "the correct answer should not become a distractor-remediation card") - player.free() - - func _test_review_followup_is_optional_and_single_attempt() -> void: var session = root.get_node("BlockSessionService") var prior_questions: Array = session.active_questions.duplicate(true) @@ -344,10 +357,14 @@ func _test_player_runtime_construction() -> void: _assert(player.question_card != null, "question player should construct its branded card") _assert(player.navigator_popup != null, "question player should construct its popup navigator") _assert(player.header_row.get_node_or_null("StepBadge") == null, "question player should not display a redundant STEP 3 badge") - _assert(player.header_row.get_child(0) == player.progress_button, "question progress should be the first header item") + _assert(player.header_row.get_child(0) != player.progress_button, "the brand mark should be the first header item, ahead of question progress") + _assert(player.progress_button.get_parent().get_parent() == player.header_row, "question progress should still live in the header row") + _assert(player.progress_bar != null and player.progress_percent_label != null, "question player should construct a header progress bar and percentage label") _assert(player.clear_highlight_button.icon != null, "clear highlights should use the supplied toolbar artwork") _assert(player.report_button.icon != null, "report question should use the supplied toolbar artwork") _assert(player.labs_button.icon != null, "lab results should use the supplied toolbar artwork") + _assert(player.calculator_button != null and player.calculator_button.icon != null, "every question mode should expose the calculator tool") + _assert(player.calculator_panel != null, "question player should construct the floating calculator once") _assert(player.notes_button != null and player.notes_button.icon != null, "practice questions should expose the notes tool") for icon_button in [player.clear_highlight_button, player.report_button, player.labs_button]: _assert(icon_button.alignment == HORIZONTAL_ALIGNMENT_CENTER and icon_button.icon_alignment == HORIZONTAL_ALIGNMENT_CENTER, "question toolbar artwork should be horizontally centered") @@ -359,17 +376,21 @@ func _test_player_runtime_construction() -> void: _assert(player.report_button.icon == player.REPORT_QUESTION_LIGHT_ICON, "report question should support its light-mode artwork") var toggle_box := player.theme_button.get_theme_stylebox("normal") as StyleBoxFlat _assert(player.theme_button.custom_minimum_size == Vector2(44, 44), "question theme toggle should retain the shared square footprint") - _assert(toggle_box != null and toggle_box.corner_radius_top_left == 22, "question theme toggle should use the shared circular treatment") + _assert(toggle_box != null and toggle_box.corner_radius_top_left == 7, "question theme toggle should match the other toolbar icons' rounded-square treatment") _assert(player.theme_button.icon in [Brand.SUN_ICON, Brand.MOON_ICON], "question theme toggle should use the shared sun or moon icon") _assert(player.choice_badges.size() == 5, "question player should construct an answer badge for every visible choice slot") _assert(player.choice_followup_panels[2].get_parent() == player.choice_rows[2], "distractor remediation should remain nested inside its answer choice") var prior_session_kind: String = root.get_node("BlockSessionService").session_kind - root.get_node("BlockSessionService").session_kind = "simulation" - player._refresh_toolbar_state() - _assert(not player.notes_button.visible, "timed simulations should hide the notes tool") + for session_kind in ["block", "core", "bookmark", "review", "simulation"]: + root.get_node("BlockSessionService").session_kind = session_kind + player._refresh_toolbar_state() + _assert(player.calculator_button.visible, "%s question sessions should expose the calculator tool" % session_kind) root.get_node("BlockSessionService").session_kind = "core" player._refresh_toolbar_state() _assert(player.notes_button.visible, "practice question runs should show the notes tool") + root.get_node("BlockSessionService").session_kind = "simulation" + player._refresh_toolbar_state() + _assert(not player.notes_button.visible, "timed simulations should hide the notes tool") root.get_node("BlockSessionService").session_kind = prior_session_kind player.choice_followup_panels[2].visible = true player.locked_wrong_choice_ids = ["C"] diff --git a/tests/settings_screen_test.gd b/tests/settings_screen_test.gd new file mode 100644 index 0000000..95c178f --- /dev/null +++ b/tests/settings_screen_test.gd @@ -0,0 +1,69 @@ +extends SceneTree + +var failures := 0 + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + var account_profile = root.get_node("AccountProfileService") + var ccs_service = root.get_node("CCSService") + var prior_preferences: Dictionary = account_profile.preferences.duplicate(true) + var prior_ccs_scale: float = ccs_service.text_scale + account_profile.preferences["questionTextScale"] = 1.0 + ccs_service.text_scale = 1.0 + + var host := Control.new() + root.add_child(host) + var screen = load("res://scripts/ui/settings_screen.gd").new() + screen.main = host + host.add_child(screen) + await process_frame + + _assert(screen.question_scale_value_label != null, "settings should expose a question bank text size control") + _assert(screen.ccs_scale_value_label != null, "settings should expose a CCS case text size control") + _assert(screen.question_scale_value_label.text == "100%", "question bank text size should start at 100%") + _assert(screen.ccs_scale_value_label.text == "100%", "CCS case text size should start at 100%") + + _assert(_find_button(screen, "A+", 0) != null, "question bank row should have an A+ button") + _assert(_find_button(screen, "A+", 1) != null, "CCS row should have an A+ button") + + var prior_profile_api_url: String = ProjectSettings.get_setting("step3/profile_api_url", "") + ProjectSettings.set_setting("step3/profile_api_url", "http://127.0.0.1:1") + + # Call the handlers directly rather than emitting Button.pressed - the real + # signal path also fires UISoundService's click sound, whose OGG playback + # doesn't release cleanly before this short-lived headless process exits. + screen._adjust_question_text_scale(0.1) + _assert(is_equal_approx(float(account_profile.preferences.get("questionTextScale", 1.0)), 1.1), "question bank A+ should increase the synced scale") + _assert(screen.question_scale_value_label.text == "110%", "question bank text size label should reflect the new scale") + + screen._adjust_ccs_text_scale(0.1) + _assert(is_equal_approx(ccs_service.text_scale, 1.1), "CCS A+ should increase the shared CCS text scale") + _assert(screen.ccs_scale_value_label.text == "110%", "CCS text size label should reflect the new scale") + + ProjectSettings.set_setting("step3/profile_api_url", prior_profile_api_url) + host.queue_free() + await process_frame + account_profile.preferences = prior_preferences + ccs_service.text_scale = prior_ccs_scale + quit(1 if failures > 0 else 0) + + +func _find_button(root_node: Node, text: String, occurrence: int) -> Button: + var found := 0 + for node in root_node.find_children("*", "Button", true, false): + if node.text == text: + if found == occurrence: + return node + found += 1 + return null + + +func _assert(condition: bool, message: String) -> void: + if condition: + return + failures += 1 + push_error(message) diff --git a/tests/settings_screen_test.gd.uid b/tests/settings_screen_test.gd.uid new file mode 100644 index 0000000..8aedcff --- /dev/null +++ b/tests/settings_screen_test.gd.uid @@ -0,0 +1 @@ +uid://dysbx2e58xwm8 diff --git a/tests/smoke/.gdignore b/tests/smoke/.gdignore new file mode 100644 index 0000000..2bb0422 --- /dev/null +++ b/tests/smoke/.gdignore @@ -0,0 +1 @@ +# Playwright sources and generated reports are not Godot project resources. diff --git a/tests/smoke/Dockerfile b/tests/smoke/Dockerfile new file mode 100644 index 0000000..4ba1d91 --- /dev/null +++ b/tests/smoke/Dockerfile @@ -0,0 +1,8 @@ +FROM mcr.microsoft.com/playwright:v1.61.1-noble + +WORKDIR /work +COPY package.json package-lock.json ./ +RUN npm ci --ignore-scripts --no-audit --no-fund +COPY playwright.config.mjs uat-smoke.spec.mjs ./ + +CMD ["npm", "test"] diff --git a/tests/smoke/package-lock.json b/tests/smoke/package-lock.json new file mode 100644 index 0000000..6809a10 --- /dev/null +++ b/tests/smoke/package-lock.json @@ -0,0 +1,79 @@ +{ + "name": "ranaze-uat-smoke", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ranaze-uat-smoke", + "version": "1.0.0", + "dependencies": { + "@playwright/test": "1.61.1", + "pngjs": "7.0.0" + } + }, + "node_modules/@playwright/test": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz", + "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==", + "dependencies": { + "playwright": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "dependencies": { + "playwright-core": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/pngjs": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz", + "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==", + "engines": { + "node": ">=14.19.0" + } + } + } +} diff --git a/tests/smoke/package.json b/tests/smoke/package.json new file mode 100644 index 0000000..f142720 --- /dev/null +++ b/tests/smoke/package.json @@ -0,0 +1,13 @@ +{ + "name": "ranaze-uat-smoke", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "test": "playwright test" + }, + "dependencies": { + "@playwright/test": "1.61.1", + "pngjs": "7.0.0" + } +} diff --git a/tests/smoke/playwright.config.mjs b/tests/smoke/playwright.config.mjs new file mode 100644 index 0000000..f093cb1 --- /dev/null +++ b/tests/smoke/playwright.config.mjs @@ -0,0 +1,24 @@ +import { defineConfig } from "@playwright/test"; + +const resultsDir = process.env.SMOKE_OUTPUT_DIR || "/work/test-results"; + +export default defineConfig({ + testDir: ".", + testMatch: "uat-smoke.spec.mjs", + fullyParallel: false, + workers: 1, + retries: 0, + timeout: 120_000, + expect: { timeout: 15_000 }, + outputDir: `${resultsDir}/raw`, + reporter: [ + ["line"], + ["junit", { outputFile: `${resultsDir}/junit.xml` }], + ["html", { outputFolder: `${resultsDir}/html`, open: "never" }] + ], + use: { + viewport: { width: 1440, height: 900 }, + screenshot: "only-on-failure", + trace: "retain-on-failure" + } +}); diff --git a/tests/smoke/uat-smoke.spec.mjs b/tests/smoke/uat-smoke.spec.mjs new file mode 100644 index 0000000..bde4da8 --- /dev/null +++ b/tests/smoke/uat-smoke.spec.mjs @@ -0,0 +1,371 @@ +import crypto from "node:crypto"; +import { expect, test } from "@playwright/test"; +import { PNG } from "pngjs"; + +const API_ORIGIN = requiredOrigin("UAT_API_ORIGIN"); +const WEB_ORIGIN = requiredOrigin("UAT_WEB_ORIGIN"); +const EXPECTED_REVISION = (process.env.EXPECTED_REVISION || "").trim(); + +function requiredOrigin(name) { + const value = (process.env[name] || "").trim().replace(/\/$/, ""); + if (!value.startsWith("https://")) throw new Error(`${name} must be an https origin`); + return value; +} + +async function callMultipart(path, formData, token, expected = [200]) { + const response = await fetch(`${API_ORIGIN}${path}`, { + method: "POST", + headers: token ? { Authorization: `Bearer ${token}` } : {}, + body: formData + }); + const text = await response.text(); + let data = null; + if (text) { + try { data = JSON.parse(text); } catch { data = text; } + } + if (!expected.includes(response.status)) { + throw new Error(`POST ${path} returned ${response.status}: ${text.slice(0, 500)}`); + } + return { response, data }; +} + +async function call(path, options = {}) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), options.timeout ?? 20_000); + try { + const response = await fetch(`${API_ORIGIN}${path}`, { + method: options.method || "GET", + headers: { + Accept: "application/json", + ...(options.body === undefined ? {} : { "Content-Type": "application/json" }), + ...(options.token ? { Authorization: `Bearer ${options.token}` } : {}) + }, + body: options.body === undefined ? undefined : JSON.stringify(options.body), + redirect: "manual", + signal: controller.signal + }); + const text = await response.text(); + let data = null; + if (text) { + try { data = JSON.parse(text); } catch { data = text; } + } + const expected = options.expected || [200]; + if (!expected.includes(response.status)) { + throw new Error(`${options.method || "GET"} ${path} returned ${response.status}: ${text.slice(0, 500)}`); + } + return { response, data }; + } finally { + clearTimeout(timeout); + } +} + +function providerIds(payload) { + return (payload?.providers || []).map((provider) => provider.id); +} + +function validateExternalUrl(provider, authUrl) { + const url = new URL(authUrl); + if (provider === "google") { + expect(url.hostname).toBe("accounts.google.com"); + expect(url.searchParams.get("client_id")).toBeTruthy(); + expect(url.searchParams.get("redirect_uri")).toBe(`${API_ORIGIN}/api/Auth/external/google/callback`); + } else { + expect(url.hostname).toBe("steamcommunity.com"); + const returnTo = new URL(url.searchParams.get("openid.return_to")); + expect(returnTo.origin + returnTo.pathname).toBe(`${API_ORIGIN}/api/Auth/external/steam/callback`); + expect(url.searchParams.get("openid.realm")).toBe(API_ORIGIN); + } +} + +test("public API and disposable learner journey", async () => { + const suffix = `${Date.now().toString(36)}${crypto.randomBytes(3).toString("hex")}`; + const username = `smoke_${suffix}`.slice(0, 28); + const email = `${username}@smoke.invalid`; + const password = `Smk!${crypto.randomBytes(18).toString("base64url")}`; + let token = ""; + let refreshToken = ""; + let accountCreated = false; + let activeCcsAttempt = ""; + let mainError = null; + let cleanupError = null; + + try { + const health = await call("/healthz"); + expect(String(health.data).trim()).toBe("ok"); + const ready = await call("/readyz"); + expect(String(ready.data).trim()).toBe("ready"); + + const { data: runtime } = await call("/api/runtime"); + expect(String(runtime.environment).toLowerCase()).toBe("uat"); + if (EXPECTED_REVISION) expect(runtime.buildRevision).toBe(EXPECTED_REVISION); + + const { data: legal } = await call("/api/Auth/legal"); + expect(legal.termsUrl).toBeTruthy(); + expect(legal.privacyUrl).toBeTruthy(); + + const { data: providers } = await call("/api/Auth/external/providers"); + expect(providerIds(providers)).toEqual(expect.arrayContaining(["steam", "google"])); + for (const provider of ["steam", "google"]) { + const { data: start } = await call(`/api/Auth/external/${provider}/start`, { + method: "POST", body: { purpose: "login" } + }); + expect(start.transactionId).toBeTruthy(); + validateExternalUrl(provider, start.authUrl); + } + + await call("/api/Auth/register", { + method: "POST", body: { username, email, password, acceptLegal: true } + }); + accountCreated = true; + + const { data: session } = await call("/api/Auth/login", { + method: "POST", body: { username, password } + }); + token = session.accessToken; + refreshToken = session.refreshToken; + expect(token).toBeTruthy(); + expect(refreshToken).toBeTruthy(); + + const { data: me } = await call("/api/Auth/me", { token }); + expect(me.username).toBe(username); + expect(me.availableProviders).toEqual(expect.arrayContaining(["steam", "google"])); + + const { data: refreshed } = await call("/api/Auth/refresh", { + method: "POST", body: { username, refreshToken } + }); + token = refreshed.accessToken; + expect(token).toBeTruthy(); + + await call("/api/profile/me/preferences", { token }); + await call("/api/dashboard", { token }); + await call("/api/ratings/me", { token }); + + const { data: dynamicStatus } = await call("/api/dynamic-questions/status", { token }); + expect(dynamicStatus.available).toBe(true); + expect(dynamicStatus.publishedObjectives).toBe(5); + const { data: dynamicQuestion } = await call("/api/dynamic-questions/next", { + method: "POST", token, body: { runToken: "" } + }); + expect(dynamicQuestion.generationToken).toBeTruthy(); + expect(dynamicQuestion.runToken).toBeTruthy(); + expect(dynamicQuestion.question.stem.length).toBeGreaterThan(220); + expect(dynamicQuestion.question.choices).toHaveLength(5); + expect(Object.keys(dynamicQuestion.question.choiceExplanations)).toHaveLength(5); + expect(dynamicQuestion.question.reviewFollowups.length).toBeGreaterThan(0); + const dynamicChoice = dynamicQuestion.question.choices[0].id; + const { data: dynamicAttempt } = await call("/api/dynamic-questions/attempt", { + method: "POST", token, body: { + generationToken: dynamicQuestion.generationToken, + choiceId: dynamicChoice, + isSkip: false, + durationSeconds: 5 + } + }); + expect(dynamicAttempt.objectiveId).toBeTruthy(); + + const { data: queue } = await call("/api/review-queue/next", { + method: "POST", token, body: { excludeExternalIds: [], supportsChoicePools: true } + }); + expect(Number.isInteger(queue.pendingCount)).toBeTruthy(); + + const { data: questions } = await call("/api/questions/search", { + method: "POST", + token, + body: { limit: 1, supportsChoicePools: true, requireMedia: false, excludeExternalIds: [] } + }); + expect(Array.isArray(questions)).toBeTruthy(); + expect(questions).toHaveLength(1); + expect(questions[0].externalId).toBeTruthy(); + expect(questions[0].stem).toBeTruthy(); + expect(questions[0].choices.length).toBeGreaterThanOrEqual(5); + expect(questions[0].choices.map((choice) => choice.id)).toContain(questions[0].correctChoiceId); + + const { data: cases } = await call("/api/ccs/cases", { token }); + expect(Array.isArray(cases)).toBeTruthy(); + expect(cases.length).toBeGreaterThan(0); + const { data: attempt } = await call("/api/ccs/attempts", { + method: "POST", token, body: { caseExternalId: "", mode: "timed" } + }); + activeCcsAttempt = attempt.id; + expect(activeCcsAttempt).toBeTruthy(); + expect(attempt.caseExternalId).toBeTruthy(); + expect(attempt.state).toBe("active"); + const { data: medicationOrders } = await call("/api/ccs/orders/search?q=medications&limit=20", { token }); + expect(Array.isArray(medicationOrders)).toBeTruthy(); + expect(medicationOrders.length).toBeGreaterThan(0); + expect(medicationOrders.some((order) => order.category === "medication")).toBeTruthy(); + const { data: testOrders } = await call("/api/ccs/orders/search?q=tests&limit=20", { token }); + expect(Array.isArray(testOrders)).toBeTruthy(); + expect(testOrders.some((order) => ["laboratory", "diagnostic"].includes(order.category))).toBeTruthy(); + const { data: opened } = await call(`/api/ccs/attempts/${activeCcsAttempt}/commands`, { + method: "POST", token, body: { + commandId: `smoke-opening-${suffix}`, expectedRevision: attempt.revision, + type: "acknowledge_opening", code: "", minutes: 0 + } + }); + expect(opened.openingCompleted).toBe(true); + const exam = opened.availableExams[0]; + const { data: examined } = await call(`/api/ccs/attempts/${activeCcsAttempt}/commands/batch`, { + method: "POST", token, body: { + commandId: `smoke-exam-${suffix}`, expectedRevision: opened.revision, + commands: [{ type: "exam", code: exam.code, minutes: 0 }] + } + }); + const { data: finalOrders } = await call(`/api/ccs/attempts/${activeCcsAttempt}/commands`, { + method: "POST", token, body: { + commandId: `smoke-final-${suffix}`, expectedRevision: examined.revision, + type: "end_case", code: "", minutes: 0 + } + }); + expect(finalOrders.state).toBe("final_orders"); + const { data: completedCcs } = await call(`/api/ccs/attempts/${activeCcsAttempt}/commands`, { + method: "POST", token, body: { + commandId: `smoke-complete-${suffix}`, expectedRevision: finalOrders.revision, + type: "exit_case", code: "", minutes: 0 + } + }); + expect(completedCcs.state).toBe("completed"); + activeCcsAttempt = ""; + + // Flashcards: create a deck and card, study and grade it, verify it drops out of the due + // queue, then round-trip it through CSV export/import (exercising the exact-content dedupe). + const { data: deck } = await call("/api/flashcards/decks", { + method: "POST", token, body: { name: `Smoke Deck ${suffix}`, description: "" } + }); + expect(deck.id).toBeTruthy(); + + const { data: note } = await call("/api/flashcards/notes", { + method: "POST", + token, + body: { + deckId: deck.id, stableId: "", type: "basic", + prompt: "Smoke test prompt", answer: "Smoke test answer", extra: "", + tags: ["smoke"], images: [], sourceQuestionExternalId: "" + } + }); + expect(note.id).toBeTruthy(); + + const studyDayStartUtc = new Date().toISOString(); + const { data: firstCard } = await call("/api/flashcards/study/next", { + method: "POST", token, body: { deckId: deck.id, excludeCardIds: [], studyDayStartUtc } + }); + expect(firstCard.note?.id).toBe(note.id); + expect(firstCard.card?.state).toBe("new"); + + const { data: reviewResult } = await call("/api/flashcards/reviews", { + method: "POST", + token, + body: { + reviews: [{ + clientReviewId: crypto.randomUUID(), + cardId: firstCard.card.id, + rating: "good", + expectedRevision: firstCard.card.revision, + reviewedUtc: new Date().toISOString(), + durationSeconds: 5, + studyDayStartUtc + }] + } + }); + expect(reviewResult.results).toHaveLength(1); + expect(reviewResult.results[0].card.state).not.toBe("new"); + expect(new Date(reviewResult.results[0].card.dueUtc).getTime()).toBeGreaterThan(Date.now()); + + const { data: secondCard } = await call("/api/flashcards/study/next", { + method: "POST", token, body: { deckId: deck.id, excludeCardIds: [], studyDayStartUtc } + }); + expect(secondCard.note).toBeFalsy(); + + const { response: csvResponse, data: csvText } = await call("/api/flashcards/export/csv", { token }); + expect(csvResponse.headers.get("content-type") || "").toContain("text/csv"); + expect(String(csvText)).toContain("Smoke test prompt"); + + const csvForm = new FormData(); + csvForm.append("file", new Blob([String(csvText)], { type: "text/csv" }), "flashcards.csv"); + const { data: csvPreview } = await callMultipart("/api/flashcards/import/csv/preview", csvForm, token); + expect(csvPreview.importToken).toBeTruthy(); + expect(csvPreview.skipCount).toBeGreaterThan(0); + const { data: csvCommit } = await call("/api/flashcards/import/commit", { + method: "POST", token, body: { importToken: csvPreview.importToken } + }); + expect(csvCommit.skipped).toBeGreaterThan(0); + + const { data: jsonExport } = await call("/api/flashcards/export/json", { token }); + expect(jsonExport.notes.some((item) => item.note.stableId === note.stableId)).toBeTruthy(); + } catch (error) { + mainError = error; + } finally { + try { + if (accountCreated && !token) { + const login = await call("/api/Auth/login", { + method: "POST", body: { username, password } + }); + token = login.data.accessToken; + } + if (activeCcsAttempt && token) { + await call(`/api/ccs/attempts/${activeCcsAttempt}/abandon`, { + method: "POST", token, expected: [204, 404] + }); + } + if (accountCreated && token) { + await call("/api/Auth/me", { + method: "DELETE", token, body: { password, confirmation: "DELETE" }, expected: [204] + }); + } + } catch (error) { + cleanupError = error; + } + } + + if (mainError && cleanupError) throw new AggregateError([mainError, cleanupError], "Smoke journey and cleanup failed"); + if (mainError) throw mainError; + if (cleanupError) throw cleanupError; +}); + +test("web export boots a nonblank Godot canvas", async ({ page }, testInfo) => { + const consoleErrors = []; + const requestFailures = []; + page.on("pageerror", (error) => consoleErrors.push(error.message)); + page.on("console", (message) => { + if (message.type() === "error") consoleErrors.push(message.text()); + }); + page.on("requestfailed", (request) => { + const url = new URL(request.url()); + const failure = request.failure()?.errorText || "failed"; + // Godot cancels redundant range/download requests once its streamed asset + // loader has the complete payload. A removed loader plus a rendered canvas + // proves those expected ERR_ABORTED requests were not load failures. + if (url.origin === WEB_ORIGIN && failure !== "net::ERR_ABORTED" && + /\.(js|wasm|pck|ttf|png)$/.test(url.pathname)) { + requestFailures.push(`${url.pathname}: ${failure}`); + } + }); + + for (const path of ["/healthz", "/terms.html", "/privacy.html", "/legal/legal_documents.json", "/ranaze-loader-icon-v1.png"]) { + const response = await page.request.get(`${WEB_ORIGIN}${path}`); + expect(response.ok(), `${path} returned ${response.status()}`).toBeTruthy(); + } + + const response = await page.goto(WEB_ORIGIN, { waitUntil: "domcontentloaded", timeout: 30_000 }); + expect(response?.ok()).toBeTruthy(); + await page.locator("#status").waitFor({ state: "detached", timeout: 90_000 }); + const canvas = page.locator("#canvas"); + await expect(canvas).toBeVisible(); + const bounds = await canvas.boundingBox(); + expect(bounds.width).toBeGreaterThan(1000); + expect(bounds.height).toBeGreaterThan(600); + await page.waitForTimeout(1_000); + + const screenshot = await canvas.screenshot({ path: testInfo.outputPath("uat-canvas.png") }); + const png = PNG.sync.read(screenshot); + const colors = new Set(); + for (let y = 0; y < png.height; y += 12) { + for (let x = 0; x < png.width; x += 12) { + const offset = (png.width * y + x) * 4; + colors.add(`${png.data[offset]},${png.data[offset + 1]},${png.data[offset + 2]}`); + } + } + expect(colors.size, "Godot canvas appears blank or uniform").toBeGreaterThan(40); + expect(requestFailures, "critical web assets failed to load").toEqual([]); + expect(consoleErrors, "browser console errors were emitted").toEqual([]); +}); diff --git a/tests/web_responsive_test.gd b/tests/web_responsive_test.gd index e7a46b1..22e4065 100644 --- a/tests/web_responsive_test.gd +++ b/tests/web_responsive_test.gd @@ -28,10 +28,10 @@ func _run() -> void: for row in selector.responsive_card_rows: _assert(row.vertical, "phone-width card content should stack instead of clipping") _assert(selector.mode_view.find_children("*", "RichTextLabel", true, false).is_empty(), "study modes should not render the legacy rich-text simulation note") - var dynamic_icon := selector.mode_view.find_child("DynamicQuestionsIcon", true, false) as TextureRect - _assert(dynamic_icon != null, "dynamic questions should expose its mode icon") - if dynamic_icon != null: - _assert(dynamic_icon.texture.resource_path == "res://assets/icons/lucide/activity.svg", "dynamic questions should use a web-safe vector icon") + var question_bank_icon := selector.mode_view.find_child("QuestionBankIcon", true, false) as TextureRect + _assert(question_bank_icon != null, "question bank should expose its mode icon") + if question_bank_icon != null: + _assert(question_bank_icon.texture.resource_path == "res://assets/icons/lucide/clipboard-list.svg", "question bank should use a web-safe vector icon") selector._show_stage("question_bank") await process_frame _assert(selector.question_bank_launch_view.visible and not selector.mode_view.visible, "direct question bank navigation should show only the focused launch state") diff --git a/tools/export_steam_release.sh b/tools/export_steam_release.sh new file mode 100644 index 0000000..edbe8eb --- /dev/null +++ b/tools/export_steam_release.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# Exports the Windows Desktop build against production endpoints, for Steam +# release. project.godot stays checked in pointing at UAT (us3q.ranaze.com) +# so the Godot editor/debugger keeps hitting UAT - this script patches a +# local copy just for the export, the same way web/Dockerfile patches +# project.godot for the UAT/PRD web builds, then restores the original file +# even if the export fails. +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +PROJECT_FILE="${REPO_ROOT}/project.godot" +BACKUP_FILE="${PROJECT_FILE}.steam-release.bak" +GODOT_BIN="${GODOT_BIN:-/z/Godot/Godot_v4.6.2-stable_win64_console.exe}" +OUTPUT_PATH="${1:-build/windows/RanaZe.exe}" + +cleanup() { + if [ -f "${BACKUP_FILE}" ]; then + mv -f "${BACKUP_FILE}" "${PROJECT_FILE}" + fi +} +trap cleanup EXIT + +cp "${PROJECT_FILE}" "${BACKUP_FILE}" + +BUILD_REVISION="$(git -C "${REPO_ROOT}" rev-parse HEAD)" +sed -i \ + -e "s|deployment_environment=\"development\"|deployment_environment=\"prd\"|" \ + -e "s|build_revision=\"development\"|build_revision=\"${BUILD_REVISION}\"|" \ + -e "s|https://us3q.ranaze.com|https://s3q.ranaze.com|g" \ + "${PROJECT_FILE}" + +mkdir -p "${REPO_ROOT}/$(dirname "${OUTPUT_PATH}")" +"${GODOT_BIN}" --headless --export-release "Windows Desktop" "${OUTPUT_PATH}" --path "${REPO_ROOT}" + +echo "Steam release build exported to ${OUTPUT_PATH} (production endpoints, revision ${BUILD_REVISION})." diff --git a/web/medmind_shell.html b/web/medmind_shell.html index 7d3ffa7..257f86c 100644 --- a/web/medmind_shell.html +++ b/web/medmind_shell.html @@ -319,7 +319,7 @@
- +
RanaZe