Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4bbac27b84 | ||
|
|
1d5d03a4f6 | ||
|
|
efe60f36c5 | ||
|
|
f680024dc3 | ||
|
|
94e0e5dd70 | ||
|
|
74398133f7 | ||
|
|
447f080552 | ||
|
|
ef44347bf6 | ||
|
|
4251885a73 | ||
|
|
34d283e213 | ||
|
|
500477b612 | ||
|
|
46953ee71a | ||
|
|
aa5497c516 | ||
|
|
6b1e41c187 | ||
|
|
eb9e018339 | ||
|
|
bff16cea98 | ||
|
|
5a2d825181 | ||
|
|
7e977e0d30 | ||
|
|
ba8e0ec256 | ||
|
|
7be83661f9 | ||
|
|
bb9f59b3cc | ||
|
|
854c887a1a | ||
|
|
ce4db7cec8 | ||
|
|
191aa685fa | ||
|
|
65ae99e2ce | ||
|
|
b7b290c63b | ||
|
|
c1a71b1192 | ||
|
|
f3a2e65627 | ||
|
|
bca31bd142 | ||
|
|
0e473343d0 | ||
|
|
6df0a88fd4 | ||
|
|
118c215608 | ||
|
|
cd1648fe38 | ||
|
|
f1b290aa73 | ||
|
|
50f54b1bec | ||
|
|
62e65fd22c | ||
|
|
5197234210 | ||
|
|
576b48d581 | ||
|
|
d073f82a76 | ||
|
|
a94cc5188f | ||
|
|
0902cc238a | ||
|
|
974de67032 |
@@ -9,6 +9,7 @@ set -euo pipefail
|
||||
: "${INTERNAL_PURGE_SECRET:?INTERNAL_PURGE_SECRET is required}"
|
||||
: "${STRIPE_SECRET_KEY:?STRIPE_SECRET_KEY is required}"
|
||||
: "${STRIPE_WEBHOOK_SECRET:?STRIPE_WEBHOOK_SECRET is required}"
|
||||
: "${STRIPE_ACCESS_WEBHOOK_SECRET:?STRIPE_ACCESS_WEBHOOK_SECRET is required}"
|
||||
|
||||
NODES="${NODES:-192.168.86.72 192.168.86.73 192.168.86.74}"
|
||||
BUILD_TAG="$(git rev-parse HEAD)"
|
||||
@@ -57,6 +58,7 @@ kubectl create secret generic step3-runtime-secrets \
|
||||
--from-literal=purge-secret="${INTERNAL_PURGE_SECRET}" \
|
||||
--from-literal=stripe-secret-key="${STRIPE_SECRET_KEY}" \
|
||||
--from-literal=stripe-webhook-secret="${STRIPE_WEBHOOK_SECRET}" \
|
||||
--from-literal=stripe-access-webhook-secret="${STRIPE_ACCESS_WEBHOOK_SECRET}" \
|
||||
--from-literal=google-client-id="${GOOGLE_CLIENT_ID:-}" \
|
||||
--from-literal=google-client-secret="${GOOGLE_CLIENT_SECRET:-}" \
|
||||
--dry-run=client -o yaml | kubectl apply -n "${KUBE_NAMESPACE}" -f -
|
||||
|
||||
@@ -4,6 +4,10 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- production
|
||||
paths-ignore:
|
||||
- '**.md'
|
||||
- 'tools/**'
|
||||
- 'tests/**'
|
||||
workflow_dispatch: {}
|
||||
|
||||
concurrency:
|
||||
@@ -37,6 +41,7 @@ jobs:
|
||||
INTERNAL_PURGE_SECRET: ${{ secrets.PRD_INTERNAL_PURGE_SECRET }}
|
||||
STRIPE_SECRET_KEY: ${{ secrets.PRD_STRIPE_SECRET_KEY }}
|
||||
STRIPE_WEBHOOK_SECRET: ${{ secrets.PRD_STRIPE_WEBHOOK_SECRET }}
|
||||
STRIPE_ACCESS_WEBHOOK_SECRET: ${{ secrets.PRD_STRIPE_ACCESS_WEBHOOK_SECRET }}
|
||||
GOOGLE_CLIENT_ID: ${{ secrets.PRD_GOOGLE_CLIENT_ID }}
|
||||
GOOGLE_CLIENT_SECRET: ${{ secrets.PRD_GOOGLE_CLIENT_SECRET }}
|
||||
run: bash .gitea/scripts/deploy-environment.sh
|
||||
|
||||
@@ -4,6 +4,10 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths-ignore:
|
||||
- '**.md'
|
||||
- 'tools/**'
|
||||
- 'tests/**'
|
||||
workflow_dispatch: {}
|
||||
|
||||
concurrency:
|
||||
@@ -38,6 +42,7 @@ jobs:
|
||||
INTERNAL_PURGE_SECRET: ${{ secrets.UAT_INTERNAL_PURGE_SECRET }}
|
||||
STRIPE_SECRET_KEY: ${{ secrets.UAT_STRIPE_SECRET_KEY }}
|
||||
STRIPE_WEBHOOK_SECRET: ${{ secrets.UAT_STRIPE_WEBHOOK_SECRET }}
|
||||
STRIPE_ACCESS_WEBHOOK_SECRET: ${{ secrets.UAT_STRIPE_ACCESS_WEBHOOK_SECRET }}
|
||||
GOOGLE_CLIENT_ID: ${{ secrets.UAT_GOOGLE_CLIENT_ID }}
|
||||
GOOGLE_CLIENT_SECRET: ${{ secrets.UAT_GOOGLE_CLIENT_SECRET }}
|
||||
run: bash .gitea/scripts/deploy-environment.sh
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Promote UAT content to PRD
|
||||
name: Promote ready UAT questions to PRD
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
@@ -13,7 +13,7 @@ on:
|
||||
default: ""
|
||||
|
||||
concurrency:
|
||||
group: step3-content-promotion
|
||||
group: step3-question-promotion
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
@@ -45,26 +45,26 @@ jobs:
|
||||
test "$CONFIRMATION" = "PROMOTE_TO_PRD"
|
||||
fi
|
||||
|
||||
- name: Configure promotion credentials
|
||||
- name: Configure question promotion credentials
|
||||
env:
|
||||
SOURCE_CONNECTION: ${{ secrets.UAT_MONGODB_CONNECTION_STRING }}
|
||||
TARGET_CONNECTION: ${{ secrets.PRD_MONGODB_CONNECTION_STRING }}
|
||||
run: |
|
||||
test -n "$SOURCE_CONNECTION"
|
||||
test -n "$TARGET_CONNECTION"
|
||||
kubectl create secret generic step3-content-promotion \
|
||||
kubectl create secret generic step3-question-promotion \
|
||||
--from-literal=source-connection="$SOURCE_CONNECTION" \
|
||||
--from-literal=target-connection="$TARGET_CONNECTION" \
|
||||
--dry-run=client -o yaml | kubectl apply -n step3-prd -f -
|
||||
|
||||
- name: Run promotion
|
||||
- name: Run ready-question promotion
|
||||
env:
|
||||
MODE: ${{ inputs.mode }}
|
||||
CONFIRMATION: ${{ inputs.confirmation }}
|
||||
run: |
|
||||
IMAGE="$(kubectl get deployment step3-api -n step3-prd -o jsonpath='{.spec.template.spec.containers[0].image}')"
|
||||
REVISION="${IMAGE##*:}"
|
||||
POD="content-promotion-${GITHUB_RUN_NUMBER}"
|
||||
POD="question-promotion-${GITHUB_RUN_NUMBER}"
|
||||
APPLY_ARGS=""
|
||||
if [ "$MODE" = "apply" ]; then
|
||||
APPLY_ARGS="--apply --confirm $CONFIRMATION"
|
||||
@@ -76,7 +76,7 @@ jobs:
|
||||
name: ${POD}
|
||||
namespace: step3-prd
|
||||
labels:
|
||||
app: step3-content-promotion
|
||||
app: step3-question-promotion
|
||||
spec:
|
||||
restartPolicy: Never
|
||||
containers:
|
||||
@@ -86,7 +86,7 @@ jobs:
|
||||
command: ["/bin/sh", "-c"]
|
||||
args:
|
||||
- >-
|
||||
dotnet QuestionsApi.dll promote-content
|
||||
dotnet QuestionsApi.dll promote-questions
|
||||
--source-database ranaze_step3_uat
|
||||
--target-database ranaze_step3_prd
|
||||
--operator "${GITHUB_ACTOR:-gitea-operator}"
|
||||
@@ -96,12 +96,12 @@ jobs:
|
||||
- name: SOURCE_MONGODB_CONNECTION_STRING
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: step3-content-promotion
|
||||
name: step3-question-promotion
|
||||
key: source-connection
|
||||
- name: TARGET_MONGODB_CONNECTION_STRING
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: step3-content-promotion
|
||||
name: step3-question-promotion
|
||||
key: target-connection
|
||||
EOF
|
||||
for attempt in $(seq 1 120); do
|
||||
@@ -115,6 +115,5 @@ jobs:
|
||||
- name: Remove promotion credentials and pod
|
||||
if: always()
|
||||
run: |
|
||||
kubectl delete pod "content-promotion-${GITHUB_RUN_NUMBER}" -n step3-prd --ignore-not-found
|
||||
kubectl delete secret step3-content-promotion -n step3-prd --ignore-not-found
|
||||
|
||||
kubectl delete pod "question-promotion-${GITHUB_RUN_NUMBER}" -n step3-prd --ignore-not-found
|
||||
kubectl delete secret step3-question-promotion -n step3-prd --ignore-not-found
|
||||
|
||||
+20
-9
@@ -18,10 +18,20 @@ The existing `KUBECONFIG` secret is shared. Configure these environment-specific
|
||||
- `UAT_MONGODB_CONNECTION_STRING` and `PRD_MONGODB_CONNECTION_STRING`
|
||||
- `UAT_AUTH_JWT_KEY` and `PRD_AUTH_JWT_KEY` (different values)
|
||||
- `UAT_INTERNAL_PURGE_SECRET` and `PRD_INTERNAL_PURGE_SECRET`
|
||||
- `UAT_STRIPE_SECRET_KEY` and `UAT_STRIPE_WEBHOOK_SECRET` (Stripe sandbox)
|
||||
- `PRD_STRIPE_SECRET_KEY` and `PRD_STRIPE_WEBHOOK_SECRET` (Stripe live mode)
|
||||
- `UAT_STRIPE_SECRET_KEY`, `UAT_STRIPE_WEBHOOK_SECRET`, and
|
||||
`UAT_STRIPE_ACCESS_WEBHOOK_SECRET` (Stripe sandbox)
|
||||
- `PRD_STRIPE_SECRET_KEY`, `PRD_STRIPE_WEBHOOK_SECRET`, and
|
||||
`PRD_STRIPE_ACCESS_WEBHOOK_SECRET` (Stripe live mode)
|
||||
|
||||
Use independent Stripe webhook endpoints for `https://us3q.ranaze.com/api/donations/stripe-webhook` and `https://s3q.ranaze.com/api/donations/stripe-webhook`. No Stripe publishable key is required for hosted Checkout.
|
||||
Configure four independent Stripe webhook endpoints:
|
||||
|
||||
- UAT donations: `https://us3q.ranaze.com/api/donations/stripe-webhook`
|
||||
- UAT access: `https://us3q.ranaze.com/api/access/stripe-webhook`
|
||||
- PRD donations: `https://s3q.ranaze.com/api/donations/stripe-webhook`
|
||||
- PRD access: `https://s3q.ranaze.com/api/access/stripe-webhook`
|
||||
|
||||
Each endpoint has its own signing secret. UAT uses Stripe test mode and PRD uses
|
||||
Stripe live mode. No Stripe publishable key is required for hosted Checkout.
|
||||
|
||||
## Initial UAT database migration
|
||||
|
||||
@@ -57,9 +67,9 @@ The hostname change creates a new browser origin, so testers should expect to si
|
||||
|
||||
1. Create and protect the `production` branch, then merge the tested `main` revision.
|
||||
2. Route `step3.ranaze.com` and `s3q.ranaze.com` to PRD NodePorts.
|
||||
3. Run **Promote UAT content to PRD** in `dry-run` mode and inspect the counts and hash.
|
||||
3. Run **Promote ready UAT questions to PRD** in `dry-run` mode and inspect the insert, update, unchanged, extra, media, and hash values.
|
||||
4. Run it again in `apply` mode with confirmation `PROMOTE_TO_PRD`.
|
||||
5. Verify `ranaze_step3_prd` contains published content and has no user/activity documents.
|
||||
5. Verify `ranaze_step3_prd` contains the published, unflagged UAT questions and has no user/activity documents.
|
||||
6. Register the initial PRD account normally, then promote it manually:
|
||||
|
||||
```javascript
|
||||
@@ -67,13 +77,14 @@ The hostname change creates a new browser origin, so testers should expect to si
|
||||
db.Users.updateOne({Username: "SUPER"}, {$set: {Role: "SUPER"}})
|
||||
```
|
||||
|
||||
7. Configure and test the live Stripe webhook and Steam callback before announcing PRD.
|
||||
7. Configure and test both live Stripe webhooks and the Steam callback before
|
||||
announcing PRD.
|
||||
|
||||
## Content promotion behavior
|
||||
## Question promotion behavior
|
||||
|
||||
The manual workflow runs the deployed PRD API image as a short-lived pod. It copies only published questions, referenced media, current published CCS revisions, and the CCS order catalog. It never copies users, attempts, ratings, statistics, simulations, reports, feedback, notes, or donations.
|
||||
The manual workflow runs the deployed PRD API image as a short-lived pod. It copies only questions that are published and not flagged for review, plus their referenced media. It never copies CCS content, users, attempts, ratings, statistics, simulations, reports, feedback, notes, or donations.
|
||||
|
||||
Promotion is idempotent and does not delete or implicitly unpublish PRD content. Every applied promotion writes a `ContentPromotions` audit document. Corrective unpublishing remains an explicit SUPER/API or database operation.
|
||||
Promotion is idempotent and does not delete or implicitly unpublish PRD content. Its dry run reports inserts, updates, unchanged questions, and production extras. Apply mode verifies the promoted target records and writes a scoped `ContentPromotions` audit document. Corrective unpublishing remains an explicit SUPER/API or database operation.
|
||||
|
||||
## Verification and rollback
|
||||
|
||||
|
||||
@@ -4,6 +4,11 @@ Environment deployment, database migration, and UAT-to-PRD promotion procedures
|
||||
|
||||
Godot-based USMLE Step 3 study trainer with a bundled ASP.NET Core Questions API.
|
||||
|
||||
The gated process for publishing the first Windows alpha through Steam is in
|
||||
[docs/STEAM_ALPHA_RELEASE_CHECKLIST.md](docs/STEAM_ALPHA_RELEASE_CHECKLIST.md).
|
||||
The free-access limits, earned-credit rules, paid tokens, and checkout setup are
|
||||
documented in [docs/ACCESS_ECONOMY.md](docs/ACCESS_ECONOMY.md).
|
||||
|
||||
## Local API
|
||||
|
||||
Run the Questions API:
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
using QuestionsApi.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace QuestionsApi.Tests;
|
||||
|
||||
public sealed class AccessCatalogTests
|
||||
{
|
||||
[Fact]
|
||||
public void FreeEconomy_HasTheReleaseLimits()
|
||||
{
|
||||
Assert.Equal(20, AccessCatalog.FreeQuestionLimit);
|
||||
Assert.Equal(20, AccessCatalog.CreditEarningLimit);
|
||||
Assert.Equal(100, AccessCatalog.DayPassCreditCost);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("access_day_1", 100, AccessGrant.DayToken)]
|
||||
[InlineData("access_week_1", 500, AccessGrant.WeekToken)]
|
||||
[InlineData("access_month_1", 2000, AccessGrant.MonthToken)]
|
||||
[InlineData("exam_token_1", 199, AccessGrant.ExamToken)]
|
||||
[InlineData("qbank_reset_1", 499, AccessGrant.ResetToken)]
|
||||
public void Products_HaveServerControlledPrices(string sku, long cents, string grant)
|
||||
{
|
||||
var product = AccessCatalog.Products[sku];
|
||||
|
||||
Assert.Equal(cents, product.AmountCents);
|
||||
Assert.Equal("usd", product.Currency);
|
||||
Assert.Equal(grant, product.Grant);
|
||||
}
|
||||
}
|
||||
@@ -31,4 +31,129 @@ public sealed class ContentPromotionTests
|
||||
ContentPromotionCommand.HashDocuments([first, second]),
|
||||
ContentPromotionCommand.HashDocuments([second, first]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReadyQuestionEligibilityRequiresPublishedAndUnflagged()
|
||||
{
|
||||
Assert.True(QuestionPromotionCommand.IsEligible(new BsonDocument
|
||||
{
|
||||
["IsPublished"] = true,
|
||||
["FlaggedForReviewUtc"] = BsonNull.Value
|
||||
}));
|
||||
Assert.True(QuestionPromotionCommand.IsEligible(new BsonDocument("IsPublished", true)));
|
||||
Assert.False(QuestionPromotionCommand.IsEligible(new BsonDocument
|
||||
{
|
||||
["IsPublished"] = true,
|
||||
["FlaggedForReviewUtc"] = DateTime.UtcNow
|
||||
}));
|
||||
Assert.False(QuestionPromotionCommand.IsEligible(new BsonDocument("IsPublished", false)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void QuestionMediaIdsSupportStoredApiUrlsAndRawIds()
|
||||
{
|
||||
var first = ObjectId.GenerateNewId();
|
||||
var second = ObjectId.GenerateNewId();
|
||||
var questions = new[]
|
||||
{
|
||||
new BsonDocument
|
||||
{
|
||||
["ExternalId"] = "one",
|
||||
["Media"] = new BsonArray { $"/api/questions/media/{first}", second.ToString() }
|
||||
},
|
||||
new BsonDocument
|
||||
{
|
||||
["ExternalId"] = "two",
|
||||
["Media"] = new BsonArray { $"https://example.test/api/questions/media/{first}" }
|
||||
}
|
||||
};
|
||||
|
||||
var result = QuestionPromotionCommand.CollectMediaIds(questions);
|
||||
|
||||
Assert.Equal(new HashSet<ObjectId> { first, second }, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvalidLocalQuestionMediaReferenceFailsValidation()
|
||||
{
|
||||
var question = new BsonDocument
|
||||
{
|
||||
["ExternalId"] = "bad-media",
|
||||
["Media"] = new BsonArray { "/api/questions/media/not-an-id" }
|
||||
};
|
||||
|
||||
var exception = Assert.Throws<InvalidOperationException>(
|
||||
() => QuestionPromotionCommand.CollectMediaIds([question]));
|
||||
|
||||
Assert.Contains("bad-media", exception.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void QuestionComparisonClassifiesInsertUpdateAndUnchanged()
|
||||
{
|
||||
var source = new[]
|
||||
{
|
||||
Question("insert", "new"),
|
||||
Question("update", "new"),
|
||||
Question("same", "same")
|
||||
};
|
||||
var target = new[]
|
||||
{
|
||||
Question("update", "old"),
|
||||
new BsonDocument
|
||||
{
|
||||
["Stem"] = "same",
|
||||
["ExternalId"] = "same",
|
||||
["_id"] = ObjectId.GenerateNewId()
|
||||
}
|
||||
};
|
||||
|
||||
var result = QuestionPromotionCommand.ClassifyQuestions(source, target);
|
||||
|
||||
Assert.Equal(1, result.Inserted);
|
||||
Assert.Equal(1, result.Updated);
|
||||
Assert.Equal(1, result.Unchanged);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void QuestionHashIgnoresMongoIdsFieldOrderAndInputOrder()
|
||||
{
|
||||
var first = Question("a", "one");
|
||||
first["_id"] = ObjectId.GenerateNewId();
|
||||
var second = Question("b", "two");
|
||||
var equivalentFirst = new BsonDocument
|
||||
{
|
||||
["Stem"] = "one",
|
||||
["_id"] = ObjectId.GenerateNewId(),
|
||||
["ExternalId"] = "a"
|
||||
};
|
||||
|
||||
Assert.Equal(
|
||||
QuestionPromotionCommand.HashQuestions([first, second]),
|
||||
QuestionPromotionCommand.HashQuestions([second, equivalentFirst]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadyQuestionApplyRequiresExplicitProductionConfirmation()
|
||||
{
|
||||
using var output = new StringWriter();
|
||||
|
||||
var result = await QuestionPromotionCommand.RunAsync(
|
||||
[
|
||||
"--source-connection", "mongodb://source.invalid",
|
||||
"--source-database", "uat",
|
||||
"--target-connection", "mongodb://target.invalid",
|
||||
"--target-database", "prd",
|
||||
"--apply"
|
||||
], output);
|
||||
|
||||
Assert.Equal(1, result);
|
||||
Assert.Contains("--confirm PROMOTE_TO_PRD is required", output.ToString());
|
||||
}
|
||||
|
||||
private static BsonDocument Question(string externalId, string stem) => new()
|
||||
{
|
||||
["ExternalId"] = externalId,
|
||||
["Stem"] = stem
|
||||
};
|
||||
}
|
||||
|
||||
@@ -100,7 +100,8 @@ public sealed class DashboardTests
|
||||
HistoryStartedUtc = baselineDate
|
||||
};
|
||||
|
||||
var result = DashboardStore.BuildPerformanceTrend(attempts, rating, today);
|
||||
var result = DashboardStore.BuildPerformanceTrend(
|
||||
attempts, rating, Array.Empty<CcsAttempt>(), new CcsUserRating(), today);
|
||||
|
||||
Assert.Equal(30, result.Count);
|
||||
Assert.Null(result[^4].QuestionBankRating);
|
||||
@@ -113,6 +114,39 @@ public sealed class DashboardTests
|
||||
Assert.Equal(100, result[^1].Accuracy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TrendCarriesCcsRatingForwardIndependentlyOfQuestionBankRating()
|
||||
{
|
||||
var today = new DateTime(2026, 7, 25, 0, 0, 0, DateTimeKind.Utc);
|
||||
var baselineDate = today.AddDays(-2).AddHours(8);
|
||||
var rating = new UserRating();
|
||||
var ccsRating = new CcsUserRating
|
||||
{
|
||||
Elo = 1050,
|
||||
HistoryBaselineElo = 1000,
|
||||
HistoryStartedUtc = baselineDate
|
||||
};
|
||||
var ccsAttempts = new[]
|
||||
{
|
||||
RatedCcsAttempt("one", today.AddDays(-1).AddHours(9), 1030),
|
||||
RatedCcsAttempt("two", today.AddHours(10), 1050)
|
||||
};
|
||||
|
||||
var result = DashboardStore.BuildPerformanceTrend(
|
||||
Array.Empty<QuestionAttempt>(), rating, ccsAttempts, ccsRating, today);
|
||||
|
||||
Assert.Equal(30, result.Count);
|
||||
Assert.Equal(1000, result[^3].CcsRating);
|
||||
Assert.Equal(1030, result[^2].CcsRating);
|
||||
Assert.Equal(1050, result[^1].CcsRating);
|
||||
}
|
||||
|
||||
private static CcsAttempt RatedCcsAttempt(string caseExternalId, DateTime appliedUtc, int userElo) => new()
|
||||
{
|
||||
CaseExternalId = caseExternalId,
|
||||
Rating = new CcsRatingChange { Rated = true, UserElo = userElo, AppliedUtc = appliedUtc }
|
||||
};
|
||||
|
||||
private static QuestionAttempt Attempt(
|
||||
string questionId,
|
||||
bool correct,
|
||||
@@ -134,4 +168,66 @@ public sealed class DashboardTests
|
||||
ExternalId = system,
|
||||
System = system
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void PercentileReturnsNullWithNoUsers()
|
||||
{
|
||||
Assert.Null(DashboardStore.Percentile(0, 0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PercentileComputesTopPercentFromUsersRatedHigher()
|
||||
{
|
||||
// 18 of 100 users rate higher than this user - "top 18%"
|
||||
Assert.Equal(18, DashboardStore.Percentile(18, 100));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PercentileNeverReturnsZero()
|
||||
{
|
||||
// Even the single best-rated user should read as "top 1%", not "top 0%"
|
||||
Assert.Equal(1, DashboardStore.Percentile(0, 100));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0.85, "High")]
|
||||
[InlineData(0.7, "High")]
|
||||
[InlineData(0.55, "Medium")]
|
||||
[InlineData(0.4, "Medium")]
|
||||
[InlineData(0.1, "Low")]
|
||||
public void ReadinessLabelUsesPassProbabilityThresholds(double passProbability, string expected)
|
||||
{
|
||||
Assert.Equal(expected, DashboardStore.ReadinessLabel(passProbability));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectLatestReadinessScoreSkipsSampleSimulationsEvenWhenMostRecent()
|
||||
{
|
||||
var today = new DateTime(2026, 7, 25, 0, 0, 0, DateTimeKind.Utc);
|
||||
var simulations = new[]
|
||||
{
|
||||
CompletedSimulation(isSample: true, completedUtc: today, passProbability: 0.98),
|
||||
CompletedSimulation(isSample: false, completedUtc: today.AddDays(-3), passProbability: 0.12)
|
||||
};
|
||||
|
||||
var score = DashboardStore.SelectLatestReadinessScore(simulations);
|
||||
|
||||
Assert.NotNull(score);
|
||||
Assert.Equal(0.12, score.PassProbability);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectLatestReadinessScoreReturnsNullWhenOnlySamplesExist()
|
||||
{
|
||||
var simulations = new[] { CompletedSimulation(isSample: true, completedUtc: DateTime.UtcNow, passProbability: 0.98) };
|
||||
|
||||
Assert.Null(DashboardStore.SelectLatestReadinessScore(simulations));
|
||||
}
|
||||
|
||||
private static ExamSimulation CompletedSimulation(bool isSample, DateTime completedUtc, double passProbability) => new()
|
||||
{
|
||||
IsSample = isSample,
|
||||
CompletedUtc = completedUtc,
|
||||
Score = new ExamSimulationScore { PassProbability = passProbability }
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using QuestionsApi.Models;
|
||||
using QuestionsApi.Services;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace QuestionsApi.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/access")]
|
||||
[Authorize(Roles = "USER,SUPER")]
|
||||
public sealed class AccessController(
|
||||
AccessStore access,
|
||||
ExamSimulationStore simulations,
|
||||
RatingStore ratings,
|
||||
TimeProvider clock) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Get() =>
|
||||
Ok(await access.GetStatusAsync(UserId(), User.IsInRole("SUPER")));
|
||||
|
||||
[HttpPost("redeem-credits")]
|
||||
public async Task<IActionResult> RedeemCredits(RedeemCreditsRequest request)
|
||||
{
|
||||
if (!ValidOperationId(request.OperationId)) return BadRequest("A valid operationId is required.");
|
||||
var status = await access.RedeemCreditsAsync(UserId(), request.OperationId.Trim());
|
||||
return status is null
|
||||
? Conflict(new { code = "insufficient_credits", message = "100 credits are required to redeem a day pass." })
|
||||
: Ok(status);
|
||||
}
|
||||
|
||||
[HttpPost("activate-token")]
|
||||
public async Task<IActionResult> ActivateToken(ActivateAccessTokenRequest request)
|
||||
{
|
||||
if (!ValidOperationId(request.OperationId)) return BadRequest("A valid operationId is required.");
|
||||
if (request.Token.Trim().ToLowerInvariant() is not ("day" or "week" or "month"))
|
||||
return BadRequest("token must be day, week, or month");
|
||||
var status = await access.ActivateTokenAsync(UserId(), request.OperationId.Trim(), request.Token);
|
||||
return status is null
|
||||
? Conflict(new { code = "token_unavailable", message = "That access token is not available." })
|
||||
: Ok(status);
|
||||
}
|
||||
|
||||
[HttpPost("qbank-reset")]
|
||||
public async Task<IActionResult> ResetQBank(ResetQBankRequest request)
|
||||
{
|
||||
if (request.Confirmation != "RESET_QBANK") return BadRequest("Type RESET_QBANK to confirm.");
|
||||
if (!ValidOperationId(request.OperationId)) return BadRequest("A valid operationId is required.");
|
||||
if (await simulations.FindActiveAsync(UserId()) is not null)
|
||||
return Conflict(new { code = "active_exam", message = "Finish or abandon the active exam simulation before resetting the QBank." });
|
||||
var cycle = await access.ApplyResetAsync(UserId(), request.OperationId.Trim());
|
||||
if (cycle is null)
|
||||
return Conflict(new { code = "reset_token_unavailable", message = "A QBank reset token is required." });
|
||||
await ratings.ResetUserAsync(UserId(), clock.GetUtcNow().UtcDateTime);
|
||||
return Ok(await access.GetStatusAsync(UserId(), User.IsInRole("SUPER")));
|
||||
}
|
||||
|
||||
[HttpPost("steam-checkouts")]
|
||||
public IActionResult SteamCheckout() => Problem(
|
||||
statusCode: StatusCodes.Status503ServiceUnavailable,
|
||||
title: "Steam Wallet checkout is not configured",
|
||||
detail: "Steam purchases will be enabled after the Steamworks application and product catalog are configured.");
|
||||
|
||||
private string UserId() => User.FindFirstValue(ClaimTypes.NameIdentifier)
|
||||
?? User.FindFirstValue(ClaimTypes.Name)
|
||||
?? User.Identity?.Name
|
||||
?? throw new UnauthorizedAccessException();
|
||||
|
||||
private static bool ValidOperationId(string value) =>
|
||||
!string.IsNullOrWhiteSpace(value) && value.Trim().Length <= 100;
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using QuestionsApi.Models;
|
||||
using QuestionsApi.Services;
|
||||
using Stripe;
|
||||
using Stripe.Checkout;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace QuestionsApi.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/access")]
|
||||
public sealed class AccessPurchasesController(
|
||||
AccessPurchaseStore purchases,
|
||||
AccessStore access,
|
||||
IStripeAccessGateway stripe,
|
||||
IConfiguration configuration,
|
||||
ILogger<AccessPurchasesController> logger) : ControllerBase
|
||||
{
|
||||
[HttpPost("web-checkouts")]
|
||||
[Authorize(Roles = "USER,SUPER")]
|
||||
public async Task<IActionResult> CreateCheckout(CreateAccessCheckoutRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!AccessCatalog.Products.TryGetValue(request.Sku.Trim(), out var product))
|
||||
return BadRequest("Unknown access product.");
|
||||
var purchase = await purchases.CreateAsync(UserId(), product);
|
||||
try
|
||||
{
|
||||
var checkout = await stripe.CreateCheckoutAsync(purchase, product, cancellationToken);
|
||||
await purchases.AttachSessionAsync(purchase.Id!, checkout.SessionId);
|
||||
return StatusCode(StatusCodes.Status201Created, new AccessCheckoutResponse
|
||||
{
|
||||
PurchaseId = purchase.Id!,
|
||||
CheckoutUrl = checkout.CheckoutUrl,
|
||||
Sku = product.Sku,
|
||||
AmountCents = product.AmountCents
|
||||
});
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
await purchases.MarkCheckoutFailedAsync(purchase.Id!);
|
||||
logger.LogError(exception, "Unable to create access checkout {PurchaseId}", purchase.Id);
|
||||
return Problem(statusCode: StatusCodes.Status502BadGateway,
|
||||
title: "Access checkout unavailable",
|
||||
detail: "The secure checkout could not be started. Please try again.");
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("purchases/{id}")]
|
||||
[Authorize(Roles = "USER,SUPER")]
|
||||
public async Task<IActionResult> GetPurchase(string id) =>
|
||||
await purchases.GetOwnedAsync(id.Trim(), UserId()) is { } purchase
|
||||
? Ok(AccessPurchaseStore.ToStatus(purchase))
|
||||
: NotFound();
|
||||
|
||||
[HttpPost("stripe-webhook")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> StripeWebhook(CancellationToken cancellationToken)
|
||||
{
|
||||
var webhookSecret = configuration["Stripe:AccessWebhookSecret"];
|
||||
if (string.IsNullOrWhiteSpace(webhookSecret))
|
||||
return Problem(statusCode: 503, title: "Stripe webhook is not configured");
|
||||
using var reader = new StreamReader(Request.Body);
|
||||
var json = await reader.ReadToEndAsync(cancellationToken);
|
||||
Event stripeEvent;
|
||||
try
|
||||
{
|
||||
stripeEvent = EventUtility.ConstructEvent(json, Request.Headers["Stripe-Signature"], webhookSecret);
|
||||
}
|
||||
catch (StripeException exception)
|
||||
{
|
||||
logger.LogWarning(exception, "Rejected access webhook with an invalid signature");
|
||||
return BadRequest("Invalid Stripe signature");
|
||||
}
|
||||
if (stripeEvent.Data.Object is not Session session) return Ok();
|
||||
var status = stripeEvent.Type switch
|
||||
{
|
||||
"checkout.session.completed" when session.PaymentStatus == "paid" => AccessPurchaseStatus.Paid,
|
||||
"checkout.session.async_payment_succeeded" => AccessPurchaseStatus.Paid,
|
||||
"checkout.session.async_payment_failed" => AccessPurchaseStatus.Failed,
|
||||
"checkout.session.expired" => AccessPurchaseStatus.Expired,
|
||||
_ => ""
|
||||
};
|
||||
if (status.Length == 0) return Ok();
|
||||
var purchaseId = session.ClientReferenceId;
|
||||
if (string.IsNullOrWhiteSpace(purchaseId) &&
|
||||
session.Metadata.TryGetValue("access_purchase_id", out var metadataId))
|
||||
purchaseId = metadataId;
|
||||
var purchase = await purchases.ApplyProviderStatusAsync(
|
||||
purchaseId ?? "", session.Id, session.AmountTotal ?? 0, session.Currency ?? "",
|
||||
status, session.PaymentIntentId ?? "", stripeEvent.Id);
|
||||
if (purchase is null) return Ok();
|
||||
if (status == AccessPurchaseStatus.Paid &&
|
||||
AccessCatalog.Products.TryGetValue(purchase.Sku, out var product))
|
||||
{
|
||||
await access.ApplyProductGrantAsync(purchase.UserId, product, $"purchase:{purchase.Id}");
|
||||
await purchases.MarkGrantAppliedAsync(purchase.Id!);
|
||||
}
|
||||
return Ok();
|
||||
}
|
||||
|
||||
private string UserId() => User.FindFirstValue(ClaimTypes.NameIdentifier)
|
||||
?? User.FindFirstValue(ClaimTypes.Name)
|
||||
?? User.Identity?.Name
|
||||
?? throw new UnauthorizedAccessException();
|
||||
}
|
||||
@@ -9,7 +9,7 @@ namespace QuestionsApi.Controllers;
|
||||
[ApiController]
|
||||
[Route("api/exam-simulations")]
|
||||
[Authorize(Roles = "USER,SUPER")]
|
||||
public class ExamSimulationsController(ExamSimulationStore simulations) : ControllerBase
|
||||
public class ExamSimulationsController(ExamSimulationStore simulations, AccessStore access) : ControllerBase
|
||||
{
|
||||
[HttpGet("readiness")]
|
||||
public async Task<IActionResult> Readiness() => Ok(await simulations.GetReadinessAsync(UserId()));
|
||||
@@ -17,12 +17,28 @@ public class ExamSimulationsController(ExamSimulationStore simulations) : Contro
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Create()
|
||||
{
|
||||
var operationId = $"exam-create:{Guid.NewGuid():N}";
|
||||
if (!User.IsInRole("SUPER") && !await access.TryConsumeExamTokenAsync(UserId(), operationId))
|
||||
return StatusCode(StatusCodes.Status402PaymentRequired, new
|
||||
{
|
||||
code = "exam_token_required",
|
||||
message = "An exam simulation token is required."
|
||||
});
|
||||
try
|
||||
{
|
||||
var simulation = await simulations.CreateAsync(UserId());
|
||||
return Ok(await simulations.ToResponseAsync(simulation));
|
||||
}
|
||||
catch (InvalidOperationException exception) { return Conflict(exception.Message); }
|
||||
catch (InvalidOperationException exception)
|
||||
{
|
||||
if (!User.IsInRole("SUPER")) await access.RefundExamTokenAsync(UserId(), operationId);
|
||||
return Conflict(exception.Message);
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (!User.IsInRole("SUPER")) await access.RefundExamTokenAsync(UserId(), operationId);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("preview-results")]
|
||||
|
||||
@@ -19,6 +19,7 @@ public class QuestionsController : ControllerBase
|
||||
private readonly ServiceMetrics _metrics;
|
||||
private readonly QuestionBookmarkStore _bookmarks;
|
||||
private readonly ReviewQueueStore _reviewQueue;
|
||||
private readonly AccessStore _access;
|
||||
|
||||
public QuestionsController(
|
||||
QuestionStore questions,
|
||||
@@ -28,7 +29,8 @@ public class QuestionsController : ControllerBase
|
||||
AnswerStatisticsStore statistics,
|
||||
ServiceMetrics metrics,
|
||||
QuestionBookmarkStore bookmarks,
|
||||
ReviewQueueStore reviewQueue)
|
||||
ReviewQueueStore reviewQueue,
|
||||
AccessStore access)
|
||||
{
|
||||
_questions = questions;
|
||||
_attempts = attempts;
|
||||
@@ -38,6 +40,7 @@ public class QuestionsController : ControllerBase
|
||||
_metrics = metrics;
|
||||
_bookmarks = bookmarks;
|
||||
_reviewQueue = reviewQueue;
|
||||
_access = access;
|
||||
}
|
||||
|
||||
[HttpGet("systems")]
|
||||
@@ -52,6 +55,13 @@ public class QuestionsController : ControllerBase
|
||||
[Authorize(Roles = "USER,SUPER")]
|
||||
public async Task<IActionResult> Search([FromBody] QuestionSearchRequest req)
|
||||
{
|
||||
if (!await _access.CanLoadQuestionAsync(UserId(), User.IsInRole("SUPER")))
|
||||
return StatusCode(StatusCodes.Status403Forbidden, new
|
||||
{
|
||||
code = "qbank_limit_reached",
|
||||
message = "Your free QBank allowance is exhausted. Redeem or activate an access pass to continue.",
|
||||
access = await _access.GetStatusAsync(UserId())
|
||||
});
|
||||
var questions = await _questions.SearchAsync(req, User.IsInRole("SUPER"));
|
||||
var percentages = await _statistics.GetPercentagesByQuestionAsync(questions.Select(q => q.ExternalId));
|
||||
var questionElos = await _ratings.GetQuestionElosAsync(questions.Select(q => q.ExternalId));
|
||||
@@ -117,6 +127,8 @@ public class QuestionsController : ControllerBase
|
||||
return BadRequest("questionExternalId required");
|
||||
if (string.IsNullOrWhiteSpace(req.ChoiceId))
|
||||
return BadRequest("choiceId required");
|
||||
if (string.IsNullOrWhiteSpace(req.CompletionId) || req.CompletionId.Trim().Length > 100)
|
||||
return BadRequest("completionId required and must be 100 characters or fewer");
|
||||
|
||||
var question = await _questions.GetByExternalIdAsync(req.QuestionExternalId.Trim());
|
||||
if (question is null || (!QuestionVisibility.IsVisibleToStudent(question.IsPublished, question.FlaggedForReviewUtc != null) && !User.IsInRole("SUPER")))
|
||||
@@ -146,6 +158,15 @@ public class QuestionsController : ControllerBase
|
||||
?? "";
|
||||
if (string.IsNullOrWhiteSpace(userId))
|
||||
return Unauthorized();
|
||||
var completionId = req.CompletionId.Trim();
|
||||
if (!await _access.ClaimQuestionAsync(userId, completionId, question.ExternalId, User.IsInRole("SUPER")))
|
||||
return StatusCode(StatusCodes.Status403Forbidden, new
|
||||
{
|
||||
code = "qbank_limit_reached",
|
||||
message = "Your free QBank allowance is exhausted.",
|
||||
access = await _access.GetStatusAsync(userId)
|
||||
});
|
||||
var bankCycle = await _access.GetBankCycleAsync(userId);
|
||||
|
||||
var record = await _attempts.RecordFirstAttemptAsync(
|
||||
question.ExternalId,
|
||||
@@ -154,7 +175,9 @@ public class QuestionsController : ControllerBase
|
||||
req.ChoiceId.Trim() == question.CorrectChoiceId,
|
||||
false,
|
||||
presentedChoiceIds,
|
||||
req.DurationSeconds);
|
||||
req.DurationSeconds,
|
||||
bankCycle,
|
||||
completionId);
|
||||
if (record.Inserted)
|
||||
{
|
||||
_metrics.AttemptCreated();
|
||||
@@ -172,6 +195,8 @@ public class QuestionsController : ControllerBase
|
||||
_metrics.AttemptDuplicate();
|
||||
}
|
||||
await _reviewQueue.AddFromFirstAttemptAsync(record.Attempt);
|
||||
if (record.Inserted && !User.IsInRole("SUPER"))
|
||||
await _access.TryAwardCreditAsync(userId, $"qbank-credit:{record.Attempt.Id}");
|
||||
|
||||
var ratingOwner = Guid.NewGuid().ToString("N");
|
||||
var shouldRate = await _attempts.TryAcquireRatingLeaseAsync(
|
||||
@@ -204,7 +229,8 @@ public class QuestionsController : ControllerBase
|
||||
question,
|
||||
await _statistics.GetPercentagesForQuestionAsync(question.ExternalId)),
|
||||
QuestionElo = rating.QuestionElo,
|
||||
Rating = rating
|
||||
Rating = rating,
|
||||
Access = await _access.GetStatusAsync(userId, User.IsInRole("SUPER"))
|
||||
});
|
||||
}
|
||||
|
||||
@@ -252,7 +278,7 @@ public class QuestionsController : ControllerBase
|
||||
|
||||
[HttpPost("{externalId}/skip")]
|
||||
[Authorize(Roles = "USER,SUPER")]
|
||||
public async Task<IActionResult> Skip(string externalId)
|
||||
public async Task<IActionResult> Skip(string externalId, [FromQuery] string completionId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(externalId))
|
||||
return BadRequest("externalId required");
|
||||
@@ -264,13 +290,28 @@ public class QuestionsController : ControllerBase
|
||||
var userId = CurrentUserId();
|
||||
if (string.IsNullOrWhiteSpace(userId))
|
||||
return Unauthorized();
|
||||
if (string.IsNullOrWhiteSpace(completionId) || completionId.Trim().Length > 100)
|
||||
return BadRequest("completionId required and must be 100 characters or fewer");
|
||||
completionId = completionId.Trim();
|
||||
if (!await _access.ClaimQuestionAsync(userId, completionId, question.ExternalId, User.IsInRole("SUPER")))
|
||||
return StatusCode(StatusCodes.Status403Forbidden, new
|
||||
{
|
||||
code = "qbank_limit_reached",
|
||||
message = "Your free QBank allowance is exhausted.",
|
||||
access = await _access.GetStatusAsync(userId)
|
||||
});
|
||||
var bankCycle = await _access.GetBankCycleAsync(userId);
|
||||
|
||||
var record = await _attempts.RecordFirstAttemptAsync(
|
||||
question.ExternalId,
|
||||
userId,
|
||||
"",
|
||||
false,
|
||||
true);
|
||||
true,
|
||||
null,
|
||||
0,
|
||||
bankCycle,
|
||||
completionId);
|
||||
if (record.Inserted)
|
||||
_metrics.AttemptCreated();
|
||||
else
|
||||
@@ -304,7 +345,8 @@ public class QuestionsController : ControllerBase
|
||||
question,
|
||||
await _statistics.GetPercentagesForQuestionAsync(question.ExternalId)),
|
||||
QuestionElo = rating.QuestionElo,
|
||||
Rating = rating
|
||||
Rating = rating,
|
||||
Access = await _access.GetStatusAsync(userId, User.IsInRole("SUPER"))
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Bson.Serialization.Attributes;
|
||||
|
||||
namespace QuestionsApi.Models;
|
||||
|
||||
public static class AccessCatalog
|
||||
{
|
||||
public const int FreeQuestionLimit = 20;
|
||||
public const int CreditEarningLimit = 20;
|
||||
public const int DayPassCreditCost = 100;
|
||||
|
||||
public static readonly IReadOnlyDictionary<string, AccessProduct> Products =
|
||||
new Dictionary<string, AccessProduct>(StringComparer.Ordinal)
|
||||
{
|
||||
["access_day_1"] = new("access_day_1", "One-day QBank token", 100, "usd", AccessGrant.DayToken),
|
||||
["access_week_1"] = new("access_week_1", "Seven-day QBank token", 500, "usd", AccessGrant.WeekToken),
|
||||
["access_month_1"] = new("access_month_1", "Thirty-day QBank token", 2000, "usd", AccessGrant.MonthToken),
|
||||
["exam_token_1"] = new("exam_token_1", "Exam simulation token", 199, "usd", AccessGrant.ExamToken),
|
||||
["qbank_reset_1"] = new("qbank_reset_1", "QBank reset token", 499, "usd", AccessGrant.ResetToken)
|
||||
};
|
||||
}
|
||||
|
||||
public static class AccessGrant
|
||||
{
|
||||
public const string DayToken = "day_token";
|
||||
public const string WeekToken = "week_token";
|
||||
public const string MonthToken = "month_token";
|
||||
public const string ExamToken = "exam_token";
|
||||
public const string ResetToken = "reset_token";
|
||||
}
|
||||
|
||||
public sealed record AccessProduct(string Sku, string Name, long AmountCents, string Currency, string Grant);
|
||||
|
||||
public sealed class AccessEvent
|
||||
{
|
||||
public string Id { get; set; } = "";
|
||||
public string SubjectId { get; set; } = "";
|
||||
public DateTime CreatedUtc { get; set; }
|
||||
}
|
||||
|
||||
public sealed class UserAccessAccount
|
||||
{
|
||||
[BsonId]
|
||||
[BsonRepresentation(BsonType.ObjectId)]
|
||||
public string? Id { get; set; }
|
||||
public string UserId { get; set; } = "";
|
||||
public int Credits { get; set; }
|
||||
public int DayTokens { get; set; }
|
||||
public int WeekTokens { get; set; }
|
||||
public int MonthTokens { get; set; }
|
||||
public int ExamTokens { get; set; }
|
||||
public int ResetTokens { get; set; }
|
||||
public int BankCycle { get; set; }
|
||||
public DateTime? UnlimitedUntilUtc { get; set; }
|
||||
public List<AccessEvent> RecentUsage { get; set; } = [];
|
||||
public List<AccessEvent> RecentCredits { get; set; } = [];
|
||||
public List<string> AppliedOperationIds { get; set; } = [];
|
||||
public int Revision { get; set; }
|
||||
public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public sealed class AccessStatusResponse
|
||||
{
|
||||
public int FreeQuestionLimit { get; set; } = AccessCatalog.FreeQuestionLimit;
|
||||
public int QuestionsUsed { get; set; }
|
||||
public int QuestionsRemaining { get; set; }
|
||||
public DateTime? NextQuestionAvailableUtc { get; set; }
|
||||
public int Credits { get; set; }
|
||||
public int DayPassCreditCost { get; set; } = AccessCatalog.DayPassCreditCost;
|
||||
public int DayTokens { get; set; }
|
||||
public int WeekTokens { get; set; }
|
||||
public int MonthTokens { get; set; }
|
||||
public int ExamTokens { get; set; }
|
||||
public int ResetTokens { get; set; }
|
||||
public int BankCycle { get; set; }
|
||||
public bool Unlimited { get; set; }
|
||||
public DateTime? UnlimitedUntilUtc { get; set; }
|
||||
public List<AccessProduct> Products { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class ActivateAccessTokenRequest
|
||||
{
|
||||
public string Token { get; set; } = "";
|
||||
public string OperationId { get; set; } = "";
|
||||
}
|
||||
|
||||
public sealed class RedeemCreditsRequest
|
||||
{
|
||||
public string OperationId { get; set; } = "";
|
||||
}
|
||||
|
||||
public sealed class ResetQBankRequest
|
||||
{
|
||||
public string Confirmation { get; set; } = "";
|
||||
public string OperationId { get; set; } = "";
|
||||
}
|
||||
|
||||
public sealed class CreateAccessCheckoutRequest
|
||||
{
|
||||
public string Sku { get; set; } = "";
|
||||
}
|
||||
|
||||
public sealed class AccessCheckoutResponse
|
||||
{
|
||||
public string PurchaseId { get; set; } = "";
|
||||
public string CheckoutUrl { get; set; } = "";
|
||||
public string Sku { get; set; } = "";
|
||||
public long AmountCents { get; set; }
|
||||
}
|
||||
|
||||
public static class AccessPurchaseStatus
|
||||
{
|
||||
public const string Pending = "pending";
|
||||
public const string Paid = "paid";
|
||||
public const string Failed = "failed";
|
||||
public const string Expired = "expired";
|
||||
public const string Refunded = "refunded";
|
||||
}
|
||||
|
||||
public sealed class AccessPurchase
|
||||
{
|
||||
[BsonId]
|
||||
[BsonRepresentation(BsonType.ObjectId)]
|
||||
public string? Id { get; set; }
|
||||
public string UserId { get; set; } = "";
|
||||
public string Sku { get; set; } = "";
|
||||
public long AmountCents { get; set; }
|
||||
public string Currency { get; set; } = "usd";
|
||||
public string Status { get; set; } = AccessPurchaseStatus.Pending;
|
||||
public string Provider { get; set; } = "stripe";
|
||||
public string ProviderSessionId { get; set; } = "";
|
||||
public string ProviderPaymentId { get; set; } = "";
|
||||
public List<string> ProviderEventIds { get; set; } = [];
|
||||
public bool GrantApplied { get; set; }
|
||||
public DateTime CreatedUtc { get; set; } = DateTime.UtcNow;
|
||||
public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public sealed class AccessPurchaseStatusResponse
|
||||
{
|
||||
public string PurchaseId { get; set; } = "";
|
||||
public string Sku { get; set; } = "";
|
||||
public long AmountCents { get; set; }
|
||||
public string Currency { get; set; } = "";
|
||||
public string Status { get; set; } = "";
|
||||
}
|
||||
@@ -349,6 +349,8 @@ public class CcsUserRating
|
||||
public string UserId { get; set; } = "";
|
||||
public int Elo { get; set; } = 1000;
|
||||
public int RatedCaseCount { get; set; }
|
||||
public int? HistoryBaselineElo { get; set; }
|
||||
public DateTime? HistoryStartedUtc { get; set; }
|
||||
public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,13 @@ public sealed class DashboardResponse
|
||||
public int CcsCasesCompleted { get; set; }
|
||||
public int FirstAttemptAccuracy { get; set; }
|
||||
public int AccuracySampleSize { get; set; }
|
||||
public int? AccuracyDeltaVsPreviousSample { get; set; }
|
||||
public int? QuestionBankRatingDeltaThisWeek { get; set; }
|
||||
public int? QuestionBankPercentile { get; set; }
|
||||
public int? CcsRatingDeltaThisWeek { get; set; }
|
||||
public int? CcsPercentile { get; set; }
|
||||
public string? ExamReadinessLabel { get; set; }
|
||||
public int? ExamReadinessProjectedPassPercent { get; set; }
|
||||
public int QuestionsThisWeek { get; set; }
|
||||
public int StudyTimeSecondsThisWeek { get; set; }
|
||||
public int CurrentStreak { get; set; }
|
||||
@@ -33,6 +40,7 @@ public sealed class DashboardTrendPoint
|
||||
public int Total { get; set; }
|
||||
public int Accuracy { get; set; }
|
||||
public int? QuestionBankRating { get; set; }
|
||||
public int? CcsRating { get; set; }
|
||||
}
|
||||
|
||||
public sealed class DashboardSystemPerformance
|
||||
|
||||
@@ -11,6 +11,8 @@ public class QuestionAttempt
|
||||
|
||||
public string QuestionExternalId { get; set; } = "";
|
||||
public string UserId { get; set; } = "";
|
||||
public int BankCycle { get; set; }
|
||||
public string CompletionId { get; set; } = "";
|
||||
public string ChoiceId { get; set; } = "";
|
||||
public List<string> PresentedChoiceIds { get; set; } = [];
|
||||
public bool TracksPresentation { get; set; }
|
||||
|
||||
@@ -45,6 +45,7 @@ public class FlagQuestionRequest
|
||||
|
||||
public class FirstQuestionAttemptRequest
|
||||
{
|
||||
public string CompletionId { get; set; } = "";
|
||||
public string QuestionExternalId { get; set; } = "";
|
||||
public string ChoiceId { get; set; } = "";
|
||||
public List<string> PresentedChoiceIds { get; set; } = [];
|
||||
|
||||
@@ -141,6 +141,7 @@ public class FirstQuestionAttemptResponse
|
||||
public Dictionary<string, int> ChoiceSelectionPercentages { get; set; } = [];
|
||||
public int QuestionElo { get; set; } = 1000;
|
||||
public RatingChangeResponse? Rating { get; set; }
|
||||
public AccessStatusResponse? Access { get; set; }
|
||||
}
|
||||
|
||||
public class SkipQuestionResponse
|
||||
@@ -150,6 +151,7 @@ public class SkipQuestionResponse
|
||||
public Dictionary<string, int> ChoiceSelectionPercentages { get; set; } = [];
|
||||
public int QuestionElo { get; set; } = 1000;
|
||||
public RatingChangeResponse? Rating { get; set; }
|
||||
public AccessStatusResponse? Access { get; set; }
|
||||
}
|
||||
|
||||
public static class RatingCategory
|
||||
|
||||
@@ -17,6 +17,12 @@ if (args.FirstOrDefault() == "promote-content")
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.FirstOrDefault() == "promote-questions")
|
||||
{
|
||||
Environment.ExitCode = await QuestionPromotionCommand.RunAsync(args.Skip(1).ToArray(), Console.Out);
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.FirstOrDefault() == "promote-dynamic-questions")
|
||||
{
|
||||
Environment.ExitCode = await DynamicBlueprintCommand.RunAsync(args.Skip(1).ToArray(), Console.Out);
|
||||
@@ -60,6 +66,10 @@ builder.Services.Configure<ForwardedHeadersOptions>(options =>
|
||||
});
|
||||
|
||||
builder.Services.AddSingleton<MongoDatabaseProvider>();
|
||||
builder.Services.AddSingleton(TimeProvider.System);
|
||||
builder.Services.AddSingleton<AccessStore>();
|
||||
builder.Services.AddSingleton<AccessPurchaseStore>();
|
||||
builder.Services.AddSingleton<IStripeAccessGateway, StripeAccessGateway>();
|
||||
builder.Services.AddSingleton<QuestionStore>();
|
||||
builder.Services.AddSingleton<QuestionAttemptStore>();
|
||||
builder.Services.AddSingleton<AnswerStatisticsStore>();
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
using MongoDB.Driver;
|
||||
using QuestionsApi.Models;
|
||||
|
||||
namespace QuestionsApi.Services;
|
||||
|
||||
public sealed class AccessPurchaseStore
|
||||
{
|
||||
private readonly IMongoCollection<AccessPurchase> _purchases;
|
||||
|
||||
public AccessPurchaseStore(MongoDatabaseProvider mongo)
|
||||
{
|
||||
_purchases = mongo.Database.GetCollection<AccessPurchase>("AccessPurchases");
|
||||
_purchases.Indexes.CreateMany([
|
||||
new CreateIndexModel<AccessPurchase>(
|
||||
Builders<AccessPurchase>.IndexKeys.Ascending(value => value.UserId).Descending(value => value.CreatedUtc)),
|
||||
new CreateIndexModel<AccessPurchase>(
|
||||
Builders<AccessPurchase>.IndexKeys.Ascending(value => value.ProviderSessionId),
|
||||
new CreateIndexOptions<AccessPurchase>
|
||||
{
|
||||
Unique = true,
|
||||
PartialFilterExpression = Builders<AccessPurchase>.Filter.Ne(value => value.ProviderSessionId, "")
|
||||
})
|
||||
]);
|
||||
}
|
||||
|
||||
public async Task<AccessPurchase> CreateAsync(string userId, AccessProduct product)
|
||||
{
|
||||
var purchase = new AccessPurchase
|
||||
{
|
||||
UserId = userId,
|
||||
Sku = product.Sku,
|
||||
AmountCents = product.AmountCents,
|
||||
Currency = product.Currency
|
||||
};
|
||||
await _purchases.InsertOneAsync(purchase);
|
||||
return purchase;
|
||||
}
|
||||
|
||||
public async Task<AccessPurchase?> GetOwnedAsync(string id, string userId) =>
|
||||
await _purchases.Find(value => value.Id == id && value.UserId == userId).FirstOrDefaultAsync();
|
||||
|
||||
public async Task<AccessPurchase?> GetAsync(string id) =>
|
||||
await _purchases.Find(value => value.Id == id).FirstOrDefaultAsync();
|
||||
|
||||
public Task AttachSessionAsync(string id, string sessionId) =>
|
||||
_purchases.UpdateOneAsync(
|
||||
value => value.Id == id && value.Status == AccessPurchaseStatus.Pending,
|
||||
Builders<AccessPurchase>.Update
|
||||
.Set(value => value.ProviderSessionId, sessionId)
|
||||
.Set(value => value.UpdatedUtc, DateTime.UtcNow));
|
||||
|
||||
public Task MarkCheckoutFailedAsync(string id) =>
|
||||
_purchases.UpdateOneAsync(
|
||||
value => value.Id == id && value.Status == AccessPurchaseStatus.Pending,
|
||||
Builders<AccessPurchase>.Update
|
||||
.Set(value => value.Status, AccessPurchaseStatus.Failed)
|
||||
.Set(value => value.UpdatedUtc, DateTime.UtcNow));
|
||||
|
||||
public async Task<AccessPurchase?> ApplyProviderStatusAsync(
|
||||
string id, string sessionId, long amountCents, string currency,
|
||||
string status, string paymentId, string eventId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(sessionId)) return null;
|
||||
var filters = Builders<AccessPurchase>.Filter;
|
||||
var filter = filters.Eq(value => value.Id, id) &
|
||||
filters.Eq(value => value.AmountCents, amountCents) &
|
||||
filters.Eq(value => value.Currency, currency.ToLowerInvariant()) &
|
||||
(filters.Eq(value => value.ProviderSessionId, sessionId) |
|
||||
filters.Eq(value => value.ProviderSessionId, "")) &
|
||||
filters.Not(filters.AnyEq(value => value.ProviderEventIds, eventId));
|
||||
var update = Builders<AccessPurchase>.Update
|
||||
.Set(value => value.Status, status)
|
||||
.Set(value => value.ProviderSessionId, sessionId)
|
||||
.Set(value => value.ProviderPaymentId, paymentId)
|
||||
.AddToSet(value => value.ProviderEventIds, eventId)
|
||||
.Set(value => value.UpdatedUtc, DateTime.UtcNow);
|
||||
return await _purchases.FindOneAndUpdateAsync(filter, update,
|
||||
new FindOneAndUpdateOptions<AccessPurchase> { ReturnDocument = ReturnDocument.After });
|
||||
}
|
||||
|
||||
public Task MarkGrantAppliedAsync(string id) =>
|
||||
_purchases.UpdateOneAsync(
|
||||
value => value.Id == id && value.Status == AccessPurchaseStatus.Paid,
|
||||
Builders<AccessPurchase>.Update
|
||||
.Set(value => value.GrantApplied, true)
|
||||
.Set(value => value.UpdatedUtc, DateTime.UtcNow));
|
||||
|
||||
public static AccessPurchaseStatusResponse ToStatus(AccessPurchase purchase) => new()
|
||||
{
|
||||
PurchaseId = purchase.Id ?? "",
|
||||
Sku = purchase.Sku,
|
||||
AmountCents = purchase.AmountCents,
|
||||
Currency = purchase.Currency,
|
||||
Status = purchase.Status
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
using MongoDB.Driver;
|
||||
using QuestionsApi.Models;
|
||||
|
||||
namespace QuestionsApi.Services;
|
||||
|
||||
public sealed class AccessStore
|
||||
{
|
||||
private readonly IMongoCollection<UserAccessAccount> _accounts;
|
||||
private readonly TimeProvider _time;
|
||||
|
||||
public AccessStore(MongoDatabaseProvider mongo, TimeProvider time)
|
||||
{
|
||||
_accounts = mongo.Database.GetCollection<UserAccessAccount>("UserAccessAccounts");
|
||||
_time = time;
|
||||
_accounts.Indexes.CreateOne(new CreateIndexModel<UserAccessAccount>(
|
||||
Builders<UserAccessAccount>.IndexKeys.Ascending(value => value.UserId),
|
||||
new CreateIndexOptions { Unique = true, Name = "uq_access_user" }));
|
||||
}
|
||||
|
||||
public async Task<AccessStatusResponse> GetStatusAsync(string userId, bool bypass = false)
|
||||
{
|
||||
var now = _time.GetUtcNow().UtcDateTime;
|
||||
await EnsureAndCleanAsync(userId, now);
|
||||
var account = await _accounts.Find(value => value.UserId == userId).FirstAsync();
|
||||
return ToStatus(account, now, bypass);
|
||||
}
|
||||
|
||||
public async Task<int> GetBankCycleAsync(string userId)
|
||||
{
|
||||
var now = _time.GetUtcNow().UtcDateTime;
|
||||
await EnsureAndCleanAsync(userId, now);
|
||||
return (await _accounts.Find(value => value.UserId == userId).FirstAsync()).BankCycle;
|
||||
}
|
||||
|
||||
public async Task<bool> CanLoadQuestionAsync(string userId, bool bypass)
|
||||
{
|
||||
if (bypass) return true;
|
||||
var status = await GetStatusAsync(userId);
|
||||
return status.Unlimited || status.QuestionsRemaining > 0;
|
||||
}
|
||||
|
||||
public async Task<bool> ClaimQuestionAsync(string userId, string completionId, string questionExternalId, bool bypass)
|
||||
{
|
||||
if (bypass) return true;
|
||||
var now = _time.GetUtcNow().UtcDateTime;
|
||||
await EnsureAndCleanAsync(userId, now);
|
||||
var filters = Builders<UserAccessAccount>.Filter;
|
||||
var exactClaim = filters.ElemMatch(value => value.RecentUsage,
|
||||
item => item.Id == completionId && item.SubjectId == questionExternalId);
|
||||
if (await _accounts.Find(filters.Eq(value => value.UserId, userId) & exactClaim).AnyAsync())
|
||||
return true;
|
||||
var capacity = new BsonDocumentFilterDefinition<UserAccessAccount>(new MongoDB.Bson.BsonDocument(
|
||||
"$expr", new MongoDB.Bson.BsonDocument("$lt", new MongoDB.Bson.BsonArray
|
||||
{
|
||||
new MongoDB.Bson.BsonDocument("$size", "$RecentUsage"),
|
||||
AccessCatalog.FreeQuestionLimit
|
||||
})));
|
||||
var filter = filters.Eq(value => value.UserId, userId) &
|
||||
filters.Not(filters.ElemMatch(value => value.RecentUsage, item => item.Id == completionId)) &
|
||||
(filters.Gt(value => value.UnlimitedUntilUtc, now) |
|
||||
capacity);
|
||||
var update = Builders<UserAccessAccount>.Update
|
||||
.AddToSet(value => value.RecentUsage, new AccessEvent
|
||||
{
|
||||
Id = completionId,
|
||||
SubjectId = questionExternalId,
|
||||
CreatedUtc = now
|
||||
})
|
||||
.Set(value => value.UpdatedUtc, now)
|
||||
.Inc(value => value.Revision, 1);
|
||||
if ((await _accounts.UpdateOneAsync(filter, update)).MatchedCount == 1)
|
||||
return true;
|
||||
return await _accounts.Find(filters.Eq(value => value.UserId, userId) & exactClaim).AnyAsync();
|
||||
}
|
||||
|
||||
public async Task<bool> TryAwardCreditAsync(string userId, string operationId)
|
||||
{
|
||||
var now = _time.GetUtcNow().UtcDateTime;
|
||||
await EnsureAndCleanAsync(userId, now);
|
||||
var filters = Builders<UserAccessAccount>.Filter;
|
||||
var capacity = new BsonDocumentFilterDefinition<UserAccessAccount>(new MongoDB.Bson.BsonDocument(
|
||||
"$expr", new MongoDB.Bson.BsonDocument("$lt", new MongoDB.Bson.BsonArray
|
||||
{
|
||||
new MongoDB.Bson.BsonDocument("$size", "$RecentCredits"),
|
||||
AccessCatalog.CreditEarningLimit
|
||||
})));
|
||||
var filter = filters.Eq(value => value.UserId, userId) &
|
||||
filters.Not(filters.AnyEq(value => value.AppliedOperationIds, operationId)) &
|
||||
capacity;
|
||||
var update = Builders<UserAccessAccount>.Update
|
||||
.Inc(value => value.Credits, 1)
|
||||
.AddToSet(value => value.AppliedOperationIds, operationId)
|
||||
.Push(value => value.RecentCredits, new AccessEvent { Id = operationId, CreatedUtc = now })
|
||||
.Set(value => value.UpdatedUtc, now)
|
||||
.Inc(value => value.Revision, 1);
|
||||
return (await _accounts.UpdateOneAsync(filter, update)).ModifiedCount == 1;
|
||||
}
|
||||
|
||||
public Task<AccessStatusResponse?> RedeemCreditsAsync(string userId, string operationId) =>
|
||||
SpendAndExtendAsync(userId, operationId, AccessCatalog.DayPassCreditCost, null, TimeSpan.FromHours(24));
|
||||
|
||||
public Task<AccessStatusResponse?> ActivateTokenAsync(string userId, string operationId, string token) =>
|
||||
token.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"day" => SpendAndExtendAsync(userId, operationId, 0, nameof(UserAccessAccount.DayTokens), TimeSpan.FromHours(24)),
|
||||
"week" => SpendAndExtendAsync(userId, operationId, 0, nameof(UserAccessAccount.WeekTokens), TimeSpan.FromHours(168)),
|
||||
"month" => SpendAndExtendAsync(userId, operationId, 0, nameof(UserAccessAccount.MonthTokens), TimeSpan.FromHours(720)),
|
||||
_ => Task.FromResult<AccessStatusResponse?>(null)
|
||||
};
|
||||
|
||||
public async Task<bool> TryConsumeExamTokenAsync(string userId, string operationId)
|
||||
{
|
||||
var now = _time.GetUtcNow().UtcDateTime;
|
||||
await EnsureAndCleanAsync(userId, now);
|
||||
var filters = Builders<UserAccessAccount>.Filter;
|
||||
var filter = filters.Eq(value => value.UserId, userId) &
|
||||
filters.Gt(value => value.ExamTokens, 0) &
|
||||
filters.Not(filters.AnyEq(value => value.AppliedOperationIds, operationId));
|
||||
var update = Builders<UserAccessAccount>.Update
|
||||
.Inc(value => value.ExamTokens, -1)
|
||||
.AddToSet(value => value.AppliedOperationIds, operationId)
|
||||
.Set(value => value.UpdatedUtc, now)
|
||||
.Inc(value => value.Revision, 1);
|
||||
return (await _accounts.UpdateOneAsync(filter, update)).ModifiedCount == 1;
|
||||
}
|
||||
|
||||
public async Task RefundExamTokenAsync(string userId, string operationId)
|
||||
{
|
||||
var now = _time.GetUtcNow().UtcDateTime;
|
||||
var filter = Builders<UserAccessAccount>.Filter.Eq(value => value.UserId, userId) &
|
||||
Builders<UserAccessAccount>.Filter.AnyEq(value => value.AppliedOperationIds, operationId);
|
||||
await _accounts.UpdateOneAsync(filter, Builders<UserAccessAccount>.Update
|
||||
.Inc(value => value.ExamTokens, 1)
|
||||
.Pull(value => value.AppliedOperationIds, operationId)
|
||||
.Set(value => value.UpdatedUtc, now));
|
||||
}
|
||||
|
||||
public async Task<int?> ApplyResetAsync(string userId, string operationId)
|
||||
{
|
||||
var now = _time.GetUtcNow().UtcDateTime;
|
||||
await EnsureAndCleanAsync(userId, now);
|
||||
var filters = Builders<UserAccessAccount>.Filter;
|
||||
var filter = filters.Eq(value => value.UserId, userId) &
|
||||
filters.Gt(value => value.ResetTokens, 0) &
|
||||
filters.Not(filters.AnyEq(value => value.AppliedOperationIds, operationId));
|
||||
var updated = await _accounts.FindOneAndUpdateAsync(
|
||||
filter,
|
||||
Builders<UserAccessAccount>.Update
|
||||
.Inc(value => value.ResetTokens, -1)
|
||||
.Inc(value => value.BankCycle, 1)
|
||||
.AddToSet(value => value.AppliedOperationIds, operationId)
|
||||
.Set(value => value.UpdatedUtc, now)
|
||||
.Inc(value => value.Revision, 1),
|
||||
new FindOneAndUpdateOptions<UserAccessAccount> { ReturnDocument = ReturnDocument.After });
|
||||
return updated?.BankCycle;
|
||||
}
|
||||
|
||||
public async Task<bool> ApplyProductGrantAsync(string userId, AccessProduct product, string operationId)
|
||||
{
|
||||
var now = _time.GetUtcNow().UtcDateTime;
|
||||
await EnsureAndCleanAsync(userId, now);
|
||||
var filters = Builders<UserAccessAccount>.Filter;
|
||||
var filter = filters.Eq(value => value.UserId, userId) &
|
||||
filters.Not(filters.AnyEq(value => value.AppliedOperationIds, operationId));
|
||||
var update = Builders<UserAccessAccount>.Update
|
||||
.AddToSet(value => value.AppliedOperationIds, operationId)
|
||||
.Set(value => value.UpdatedUtc, now)
|
||||
.Inc(value => value.Revision, 1);
|
||||
update = product.Grant switch
|
||||
{
|
||||
AccessGrant.DayToken => update.Inc(value => value.DayTokens, 1),
|
||||
AccessGrant.WeekToken => update.Inc(value => value.WeekTokens, 1),
|
||||
AccessGrant.MonthToken => update.Inc(value => value.MonthTokens, 1),
|
||||
AccessGrant.ExamToken => update.Inc(value => value.ExamTokens, 1),
|
||||
AccessGrant.ResetToken => update.Inc(value => value.ResetTokens, 1),
|
||||
_ => update
|
||||
};
|
||||
return (await _accounts.UpdateOneAsync(filter, update)).ModifiedCount == 1;
|
||||
}
|
||||
|
||||
private async Task<AccessStatusResponse?> SpendAndExtendAsync(
|
||||
string userId, string operationId, int credits, string? tokenProperty, TimeSpan duration)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(operationId)) return null;
|
||||
var now = _time.GetUtcNow().UtcDateTime;
|
||||
await EnsureAndCleanAsync(userId, now);
|
||||
var filters = Builders<UserAccessAccount>.Filter;
|
||||
var filter = filters.Eq(value => value.UserId, userId) &
|
||||
filters.Not(filters.AnyEq(value => value.AppliedOperationIds, operationId));
|
||||
if (credits > 0) filter &= filters.Gte(value => value.Credits, credits);
|
||||
filter &= tokenProperty switch
|
||||
{
|
||||
nameof(UserAccessAccount.DayTokens) => filters.Gt(value => value.DayTokens, 0),
|
||||
nameof(UserAccessAccount.WeekTokens) => filters.Gt(value => value.WeekTokens, 0),
|
||||
nameof(UserAccessAccount.MonthTokens) => filters.Gt(value => value.MonthTokens, 0),
|
||||
_ => FilterDefinition<UserAccessAccount>.Empty
|
||||
};
|
||||
var account = await _accounts.Find(filter).FirstOrDefaultAsync();
|
||||
if (account is null) return null;
|
||||
var start = account.UnlimitedUntilUtc is { } expiry && expiry > now ? expiry : now;
|
||||
var update = Builders<UserAccessAccount>.Update
|
||||
.Set(value => value.UnlimitedUntilUtc, start.Add(duration))
|
||||
.AddToSet(value => value.AppliedOperationIds, operationId)
|
||||
.Set(value => value.UpdatedUtc, now)
|
||||
.Inc(value => value.Revision, 1);
|
||||
if (credits > 0) update = update.Inc(value => value.Credits, -credits);
|
||||
update = tokenProperty switch
|
||||
{
|
||||
nameof(UserAccessAccount.DayTokens) => update.Inc(value => value.DayTokens, -1),
|
||||
nameof(UserAccessAccount.WeekTokens) => update.Inc(value => value.WeekTokens, -1),
|
||||
nameof(UserAccessAccount.MonthTokens) => update.Inc(value => value.MonthTokens, -1),
|
||||
_ => update
|
||||
};
|
||||
var applied = await _accounts.UpdateOneAsync(
|
||||
filter & filters.Eq(value => value.Revision, account.Revision), update);
|
||||
return applied.ModifiedCount == 1 ? await GetStatusAsync(userId) : null;
|
||||
}
|
||||
|
||||
private async Task EnsureAndCleanAsync(string userId, DateTime now)
|
||||
{
|
||||
await _accounts.UpdateOneAsync(
|
||||
value => value.UserId == userId,
|
||||
Builders<UserAccessAccount>.Update
|
||||
.SetOnInsert(value => value.UserId, userId)
|
||||
.SetOnInsert(value => value.UpdatedUtc, now),
|
||||
new UpdateOptions { IsUpsert = true });
|
||||
var cutoff = now.AddHours(-24);
|
||||
await _accounts.UpdateOneAsync(
|
||||
value => value.UserId == userId,
|
||||
Builders<UserAccessAccount>.Update
|
||||
.PullFilter(value => value.RecentUsage, item => item.CreatedUtc <= cutoff)
|
||||
.PullFilter(value => value.RecentCredits, item => item.CreatedUtc <= cutoff));
|
||||
}
|
||||
|
||||
private static AccessStatusResponse ToStatus(UserAccessAccount account, DateTime now, bool bypass)
|
||||
{
|
||||
var unlimited = bypass || account.UnlimitedUntilUtc > now;
|
||||
var used = account.RecentUsage.Count;
|
||||
return new AccessStatusResponse
|
||||
{
|
||||
QuestionsUsed = used,
|
||||
QuestionsRemaining = unlimited ? AccessCatalog.FreeQuestionLimit : Math.Max(0, AccessCatalog.FreeQuestionLimit - used),
|
||||
NextQuestionAvailableUtc = !unlimited && used >= AccessCatalog.FreeQuestionLimit
|
||||
? account.RecentUsage.MinBy(value => value.CreatedUtc)?.CreatedUtc.AddHours(24)
|
||||
: null,
|
||||
Credits = account.Credits,
|
||||
DayTokens = account.DayTokens,
|
||||
WeekTokens = account.WeekTokens,
|
||||
MonthTokens = account.MonthTokens,
|
||||
ExamTokens = account.ExamTokens,
|
||||
ResetTokens = account.ResetTokens,
|
||||
BankCycle = account.BankCycle,
|
||||
Unlimited = unlimited,
|
||||
UnlimitedUntilUtc = account.UnlimitedUntilUtc,
|
||||
Products = AccessCatalog.Products.Values.ToList()
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,12 @@ public sealed class CcsRatingStore
|
||||
return new CcsRatingProfileResponse { Elo = rating.Elo, RatedCaseCount = rating.RatedCaseCount };
|
||||
}
|
||||
|
||||
public async Task<CcsUserRating> GetOrInitializeHistoryAsync(string userId, DateTime nowUtc)
|
||||
{
|
||||
var rating = await GetOrCreateUserAsync(userId);
|
||||
return await EnsureHistoryBaselineAsync(rating, nowUtc);
|
||||
}
|
||||
|
||||
public async Task<CcsRatingChange> ApplyAsync(CcsAttempt attempt)
|
||||
{
|
||||
var actual = Math.Clamp((attempt.Score?.Percent ?? 0) / 100.0, 0, 1);
|
||||
@@ -52,6 +58,7 @@ public sealed class CcsRatingStore
|
||||
}
|
||||
|
||||
var user = await GetOrCreateUserAsync(attempt.UserId);
|
||||
user = await EnsureHistoryBaselineAsync(user, DateTime.UtcNow);
|
||||
var caseRating = await GetOrCreateCaseAsync(attempt.CaseExternalId);
|
||||
var expected = ExpectedScore(user.Elo, caseRating.Elo);
|
||||
var userDelta = (int)Math.Round(UserK(user.RatedCaseCount) * (actual - expected), MidpointRounding.AwayFromZero);
|
||||
@@ -137,6 +144,26 @@ public sealed class CcsRatingStore
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<CcsUserRating> EnsureHistoryBaselineAsync(CcsUserRating rating, DateTime nowUtc)
|
||||
{
|
||||
if (rating.HistoryBaselineElo.HasValue && rating.HistoryStartedUtc.HasValue)
|
||||
return rating;
|
||||
|
||||
var result = await _users.UpdateOneAsync(
|
||||
value => value.UserId == rating.UserId && value.HistoryStartedUtc == null,
|
||||
Builders<CcsUserRating>.Update
|
||||
.Set(value => value.HistoryBaselineElo, rating.Elo)
|
||||
.Set(value => value.HistoryStartedUtc, nowUtc));
|
||||
if (result.ModifiedCount > 0)
|
||||
{
|
||||
rating.HistoryBaselineElo = rating.Elo;
|
||||
rating.HistoryStartedUtc = nowUtc;
|
||||
return rating;
|
||||
}
|
||||
|
||||
return await _users.Find(value => value.UserId == rating.UserId).FirstAsync();
|
||||
}
|
||||
|
||||
private async Task<CcsCaseRating> UpdateCaseAsync(CcsCaseRating current, int delta)
|
||||
{
|
||||
while (true)
|
||||
|
||||
@@ -19,6 +19,9 @@ public sealed class DashboardStore
|
||||
private readonly FlashcardStudyService _flashcards;
|
||||
private readonly FlashcardScheduleStore _flashcardSchedules;
|
||||
private readonly RatingStore _ratingStore;
|
||||
private readonly CcsRatingStore _ccsRatingStore;
|
||||
private readonly AccessStore _accessStore;
|
||||
private readonly IMongoCollection<UserRating> _ratings;
|
||||
|
||||
public static readonly string[] Step3Systems =
|
||||
[
|
||||
@@ -45,7 +48,9 @@ public sealed class DashboardStore
|
||||
DynamicQuestionStore dynamicQuestions,
|
||||
FlashcardStudyService flashcards,
|
||||
FlashcardScheduleStore flashcardSchedules,
|
||||
RatingStore ratingStore)
|
||||
RatingStore ratingStore,
|
||||
CcsRatingStore ccsRatingStore,
|
||||
AccessStore accessStore)
|
||||
{
|
||||
_attempts = mongo.Database.GetCollection<QuestionAttempt>("QuestionAttempts");
|
||||
_ccsRatings = mongo.Database.GetCollection<CcsUserRating>("CcsUserRatings");
|
||||
@@ -54,12 +59,15 @@ public sealed class DashboardStore
|
||||
_simulations = mongo.Database.GetCollection<ExamSimulation>("ExamSimulations");
|
||||
_bookmarks = mongo.Database.GetCollection<QuestionBookmark>("QuestionBookmarks");
|
||||
_notes = mongo.Database.GetCollection<UserNotepad>("UserNotepads");
|
||||
_ratings = mongo.Database.GetCollection<UserRating>("UserRatings");
|
||||
_questions = questions;
|
||||
_reviewQueue = reviewQueue;
|
||||
_dynamicQuestions = dynamicQuestions;
|
||||
_flashcards = flashcards;
|
||||
_flashcardSchedules = flashcardSchedules;
|
||||
_ratingStore = ratingStore;
|
||||
_ccsRatingStore = ccsRatingStore;
|
||||
_accessStore = accessStore;
|
||||
}
|
||||
|
||||
public async Task<DashboardResponse> GetAsync(string userId, DateTime nowUtc)
|
||||
@@ -67,26 +75,33 @@ public sealed class DashboardStore
|
||||
var today = nowUtc.Date;
|
||||
var trendStart = today.AddDays(-29);
|
||||
var weekStart = today.AddDays(-6);
|
||||
var attemptsTask = _attempts.Find(value => value.UserId == userId).ToListAsync();
|
||||
var bankCycleTask = _accessStore.GetBankCycleAsync(userId);
|
||||
var bankCycle = await bankCycleTask;
|
||||
var attemptsTask = _attempts.Find(value => value.UserId == userId && value.BankCycle == bankCycle).ToListAsync();
|
||||
var ratingTask = _ratingStore.GetOrInitializeHistoryAsync(userId, nowUtc);
|
||||
var ccsRatingTask = _ccsRatings.Find(value => value.UserId == userId).FirstOrDefaultAsync();
|
||||
var ccsRatingTask = _ccsRatingStore.GetOrInitializeHistoryAsync(userId, nowUtc);
|
||||
var ccsAttemptsTask = _ccsAttempts.Find(value => value.UserId == userId && value.CreatedUtc >= trendStart).ToListAsync();
|
||||
var ccsAllAttemptsTask = _ccsAttempts.Find(value => value.UserId == userId).ToListAsync();
|
||||
var completedCcsCountTask = _ccsAttempts.CountDocumentsAsync(value => value.UserId == userId
|
||||
&& value.State == CcsAttemptStates.Completed && value.CompletedUtc.HasValue);
|
||||
var simulationsTask = _simulations.Find(value => value.UserId == userId && value.CreatedUtc >= trendStart).ToListAsync();
|
||||
var simulationsTask = _simulations.Find(value => value.UserId == userId && !value.IsSample && value.CreatedUtc >= trendStart).ToListAsync();
|
||||
var completedSimulationsTask = _simulations.Find(value => value.UserId == userId
|
||||
&& value.State == ExamSimulationStates.Completed && value.Score != null)
|
||||
.SortByDescending(value => value.CompletedUtc).ToListAsync();
|
||||
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, flashcardCountsTask,
|
||||
await Task.WhenAll(attemptsTask, ratingTask, ccsRatingTask, ccsAttemptsTask, ccsAllAttemptsTask, completedCcsCountTask,
|
||||
simulationsTask, completedSimulationsTask, bookmarkCountTask, notepadTask, reviewAttemptsTask, flashcardCountsTask,
|
||||
flashcardReviewsTask, dynamicReviewsTask);
|
||||
|
||||
var attempts = attemptsTask.Result;
|
||||
var answeredAttempts = attempts.Where(value => !value.IsSkip).ToList();
|
||||
var recentAnswered = answeredAttempts.OrderByDescending(value => value.CreatedUtc).Take(100).ToList();
|
||||
var previousHundred = answeredAttempts.OrderByDescending(value => value.CreatedUtc).Skip(100).Take(100).ToList();
|
||||
var questionMap = await _questions.GetByExternalIdsAsync(attempts.Select(value => value.QuestionExternalId));
|
||||
var ccsAttempts = ccsAttemptsTask.Result;
|
||||
var simulations = simulationsTask.Result;
|
||||
@@ -94,16 +109,39 @@ public sealed class DashboardStore
|
||||
await _reviewQueue.EnsureUserQueueAsync(userId);
|
||||
var pendingReviewCount = await _reviewQueue.CountPendingAsync(userId);
|
||||
|
||||
var qbankElo = ratingTask.Result.OverallElo;
|
||||
var ccsElo = ccsRatingTask.Result.Elo;
|
||||
var qbankHigherTask = _ratings.CountDocumentsAsync(value => value.OverallElo > qbankElo);
|
||||
var qbankTotalTask = _ratings.CountDocumentsAsync(FilterDefinition<UserRating>.Empty);
|
||||
var ccsHigherTask = _ccsRatings.CountDocumentsAsync(value => value.Elo > ccsElo);
|
||||
var ccsTotalTask = _ccsRatings.CountDocumentsAsync(FilterDefinition<CcsUserRating>.Empty);
|
||||
await Task.WhenAll(qbankHigherTask, qbankTotalTask, ccsHigherTask, ccsTotalTask);
|
||||
|
||||
var latestScore = SelectLatestReadinessScore(completedSimulationsTask.Result);
|
||||
var accuracySampleSize = recentAnswered.Count;
|
||||
var firstAttemptAccuracy = Percentage(recentAnswered.Count(value => value.IsCorrect), recentAnswered.Count);
|
||||
var performanceTrend = BuildPerformanceTrend(attempts, ratingTask.Result, ccsAllAttemptsTask.Result, ccsRatingTask.Result, today);
|
||||
var weekAgoTrend = performanceTrend[^8];
|
||||
|
||||
var response = new DashboardResponse
|
||||
{
|
||||
QuestionBankRating = ratingTask.Result.OverallElo,
|
||||
QuestionBankRating = qbankElo,
|
||||
RatedQuestionCount = ratingTask.Result.RatedQuestionCount,
|
||||
CcsRating = ccsRatingTask.Result?.Elo ?? 1000,
|
||||
RatedCcsCaseCount = ccsRatingTask.Result?.RatedCaseCount ?? 0,
|
||||
CcsRating = ccsElo,
|
||||
RatedCcsCaseCount = ccsRatingTask.Result.RatedCaseCount,
|
||||
QuestionsCompleted = answeredAttempts.Count,
|
||||
CcsCasesCompleted = checked((int)completedCcsCountTask.Result),
|
||||
AccuracySampleSize = recentAnswered.Count,
|
||||
FirstAttemptAccuracy = Percentage(recentAnswered.Count(value => value.IsCorrect), recentAnswered.Count),
|
||||
AccuracySampleSize = accuracySampleSize,
|
||||
FirstAttemptAccuracy = firstAttemptAccuracy,
|
||||
AccuracyDeltaVsPreviousSample = previousHundred.Count == 0
|
||||
? null
|
||||
: firstAttemptAccuracy - Percentage(previousHundred.Count(value => value.IsCorrect), previousHundred.Count),
|
||||
QuestionBankRatingDeltaThisWeek = weekAgoTrend.QuestionBankRating is null ? null : qbankElo - weekAgoTrend.QuestionBankRating,
|
||||
QuestionBankPercentile = Percentile(qbankHigherTask.Result, qbankTotalTask.Result),
|
||||
CcsRatingDeltaThisWeek = weekAgoTrend.CcsRating is null ? null : ccsElo - weekAgoTrend.CcsRating,
|
||||
CcsPercentile = Percentile(ccsHigherTask.Result, ccsTotalTask.Result),
|
||||
ExamReadinessLabel = latestScore is null ? null : ReadinessLabel(latestScore.PassProbability),
|
||||
ExamReadinessProjectedPassPercent = latestScore is null ? null : (int)Math.Round(latestScore.PassProbability * 100),
|
||||
QuestionsThisWeek = answeredAttempts.Count(value => value.CreatedUtc >= weekStart),
|
||||
BookmarkCount = checked((int)bookmarkCountTask.Result),
|
||||
NoteCount = CountNotes(notepadTask.Result?.Content),
|
||||
@@ -114,7 +152,7 @@ public sealed class DashboardStore
|
||||
FlashcardsReviewedThisWeek = flashcardCountsTask.Result.ReviewedThisWeek
|
||||
};
|
||||
|
||||
response.PerformanceTrend = BuildPerformanceTrend(attempts, ratingTask.Result, today);
|
||||
response.PerformanceTrend = performanceTrend;
|
||||
response.Systems = BuildSystemPerformance(answeredAttempts, questionMap);
|
||||
|
||||
response.WeeklyActivity = Enumerable.Range(0, 7).Select(offset =>
|
||||
@@ -148,6 +186,22 @@ public sealed class DashboardStore
|
||||
public static int Percentage(int correct, int total) =>
|
||||
total <= 0 ? 0 : (int)Math.Round(correct * 100.0 / total, MidpointRounding.AwayFromZero);
|
||||
|
||||
public static int? Percentile(long usersRatedHigher, long totalUsers) =>
|
||||
totalUsers <= 0 ? null : Math.Max(1, (int)Math.Round(usersRatedHigher * 100.0 / totalUsers, MidpointRounding.AwayFromZero));
|
||||
|
||||
// Thresholds are a reasonable default for labeling an existing, tested
|
||||
// pass-probability estimate (ExamSimulationScoring.Calculate) - tune freely,
|
||||
// this isn't a validated clinical cutoff.
|
||||
public static string ReadinessLabel(double passProbability) =>
|
||||
passProbability >= 0.7 ? "High" : passProbability >= 0.4 ? "Medium" : "Low";
|
||||
|
||||
// completedSimulationsByRecency must already be sorted newest-first (by CompletedUtc).
|
||||
// Sample simulations (QA-only, seeded via the SUPER-role "preview results" tooling with
|
||||
// scripted answers) never reflect real performance, so they're skipped here rather than
|
||||
// in the Mongo filter - keeping the "which one counts" decision unit-testable.
|
||||
public static ExamSimulationScore? SelectLatestReadinessScore(IEnumerable<ExamSimulation> completedSimulationsByRecency) =>
|
||||
completedSimulationsByRecency.FirstOrDefault(value => !value.IsSample)?.Score;
|
||||
|
||||
public static int CalculateCurrentStreak(IEnumerable<DateTime> activeDates, DateTime todayUtc)
|
||||
{
|
||||
var dates = activeDates.Select(value => value.Date).ToHashSet();
|
||||
@@ -165,9 +219,12 @@ public sealed class DashboardStore
|
||||
public static List<DashboardTrendPoint> BuildPerformanceTrend(
|
||||
IEnumerable<QuestionAttempt> attempts,
|
||||
UserRating rating,
|
||||
IEnumerable<CcsAttempt> ccsAttempts,
|
||||
CcsUserRating ccsRating,
|
||||
DateTime todayUtc)
|
||||
{
|
||||
var attemptList = attempts.ToList();
|
||||
var ccsAttemptList = ccsAttempts.ToList();
|
||||
var trendStart = todayUtc.Date.AddDays(-29);
|
||||
var ratingEvents = new List<(DateTime AtUtc, int Elo)>();
|
||||
if (rating.HistoryBaselineElo.HasValue && rating.HistoryStartedUtc.HasValue)
|
||||
@@ -177,8 +234,18 @@ public sealed class DashboardStore
|
||||
.Select(value => (value.RatingAppliedUtc!.Value, value.UserOverallEloAfter!.Value)));
|
||||
ratingEvents.Sort((left, right) => left.AtUtc.CompareTo(right.AtUtc));
|
||||
|
||||
var ccsRatingEvents = new List<(DateTime AtUtc, int Elo)>();
|
||||
if (ccsRating.HistoryBaselineElo.HasValue && ccsRating.HistoryStartedUtc.HasValue)
|
||||
ccsRatingEvents.Add((ccsRating.HistoryStartedUtc.Value, ccsRating.HistoryBaselineElo.Value));
|
||||
ccsRatingEvents.AddRange(ccsAttemptList
|
||||
.Where(value => value.Rating is { AppliedUtc: not null })
|
||||
.Select(value => (value.Rating!.AppliedUtc!.Value, value.Rating!.UserElo)));
|
||||
ccsRatingEvents.Sort((left, right) => left.AtUtc.CompareTo(right.AtUtc));
|
||||
|
||||
int? currentRating = null;
|
||||
int? currentCcsRating = null;
|
||||
var eventIndex = 0;
|
||||
var ccsEventIndex = 0;
|
||||
var result = new List<DashboardTrendPoint>();
|
||||
foreach (var offset in Enumerable.Range(0, 30))
|
||||
{
|
||||
@@ -189,6 +256,11 @@ public sealed class DashboardStore
|
||||
currentRating = ratingEvents[eventIndex].Elo;
|
||||
eventIndex++;
|
||||
}
|
||||
while (ccsEventIndex < ccsRatingEvents.Count && ccsRatingEvents[ccsEventIndex].AtUtc < nextDate)
|
||||
{
|
||||
currentCcsRating = ccsRatingEvents[ccsEventIndex].Elo;
|
||||
ccsEventIndex++;
|
||||
}
|
||||
|
||||
var daily = attemptList
|
||||
.Where(value => !value.IsSkip && value.CreatedUtc >= date && value.CreatedUtc < nextDate)
|
||||
@@ -200,7 +272,8 @@ public sealed class DashboardStore
|
||||
Correct = correct,
|
||||
Total = daily.Count,
|
||||
Accuracy = Percentage(correct, daily.Count),
|
||||
QuestionBankRating = currentRating
|
||||
QuestionBankRating = currentRating,
|
||||
CcsRating = currentCcsRating
|
||||
});
|
||||
}
|
||||
return result;
|
||||
|
||||
@@ -15,12 +15,20 @@ public class QuestionAttemptStore
|
||||
{
|
||||
_col = mongo.Database.GetCollection<QuestionAttempt>("QuestionAttempts");
|
||||
|
||||
try
|
||||
{
|
||||
_col.Indexes.DropOne("QuestionExternalId_1_UserId_1");
|
||||
}
|
||||
catch (MongoCommandException exception) when (exception.Code is 26 or 27)
|
||||
{
|
||||
}
|
||||
var firstAttemptIndex = Builders<QuestionAttempt>.IndexKeys
|
||||
.Ascending(a => a.QuestionExternalId)
|
||||
.Ascending(a => a.UserId);
|
||||
.Ascending(a => a.UserId)
|
||||
.Ascending(a => a.BankCycle);
|
||||
_col.Indexes.CreateOne(new CreateIndexModel<QuestionAttempt>(
|
||||
firstAttemptIndex,
|
||||
new CreateIndexOptions { Unique = true }));
|
||||
new CreateIndexOptions { Unique = true, Name = "uq_question_user_cycle" }));
|
||||
|
||||
var questionIndex = Builders<QuestionAttempt>.IndexKeys.Ascending(a => a.QuestionExternalId);
|
||||
_col.Indexes.CreateOne(new CreateIndexModel<QuestionAttempt>(questionIndex));
|
||||
@@ -38,12 +46,16 @@ public class QuestionAttemptStore
|
||||
bool isCorrect,
|
||||
bool isSkip = false,
|
||||
IReadOnlyCollection<string>? presentedChoiceIds = null,
|
||||
int durationSeconds = 0)
|
||||
int durationSeconds = 0,
|
||||
int bankCycle = 0,
|
||||
string completionId = "")
|
||||
{
|
||||
var attempt = new QuestionAttempt
|
||||
{
|
||||
QuestionExternalId = questionExternalId,
|
||||
UserId = userId,
|
||||
BankCycle = bankCycle,
|
||||
CompletionId = completionId,
|
||||
ChoiceId = choiceId,
|
||||
PresentedChoiceIds = presentedChoiceIds?.ToList() ?? [],
|
||||
TracksPresentation = presentedChoiceIds is { Count: > 0 },
|
||||
@@ -63,7 +75,7 @@ public class QuestionAttemptStore
|
||||
catch (MongoWriteException ex) when (ex.WriteError.Category == ServerErrorCategory.DuplicateKey)
|
||||
{
|
||||
var existing = await _col
|
||||
.Find(a => a.QuestionExternalId == questionExternalId && a.UserId == userId)
|
||||
.Find(a => a.QuestionExternalId == questionExternalId && a.UserId == userId && a.BankCycle == bankCycle)
|
||||
.FirstAsync();
|
||||
return new QuestionAttemptRecordResult { Inserted = false, Attempt = existing };
|
||||
}
|
||||
@@ -75,10 +87,13 @@ public class QuestionAttemptStore
|
||||
Builders<QuestionAttempt>.Filter.Gte(attempt => attempt.CreatedUtc, sinceUtc))
|
||||
.ToListAsync();
|
||||
|
||||
public Task<List<string>> GetUserQuestionIdsAsync(string userId) =>
|
||||
public Task<List<string>> GetUserQuestionIdsAsync(string userId, int? bankCycle = null) =>
|
||||
_col.Distinct(
|
||||
attempt => attempt.QuestionExternalId,
|
||||
Builders<QuestionAttempt>.Filter.Eq(attempt => attempt.UserId, userId))
|
||||
Builders<QuestionAttempt>.Filter.Eq(attempt => attempt.UserId, userId) &
|
||||
(bankCycle is null
|
||||
? FilterDefinition<QuestionAttempt>.Empty
|
||||
: Builders<QuestionAttempt>.Filter.Eq(attempt => attempt.BankCycle, bankCycle.Value)))
|
||||
.ToListAsync();
|
||||
|
||||
public IMongoCollection<QuestionAttempt> Collection => _col;
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Driver;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace QuestionsApi.Services;
|
||||
|
||||
public static class QuestionPromotionCommand
|
||||
{
|
||||
public const string Confirmation = "PROMOTE_TO_PRD";
|
||||
|
||||
public static async Task<int> RunAsync(string[] args, TextWriter output)
|
||||
{
|
||||
try
|
||||
{
|
||||
var options = Parse(args);
|
||||
if (options.Apply && options.Confirmation != Confirmation)
|
||||
throw new ArgumentException($"--confirm {Confirmation} is required with --apply");
|
||||
if (options.SourceDatabase.Equals(options.TargetDatabase, StringComparison.OrdinalIgnoreCase)
|
||||
&& options.SourceConnection == options.TargetConnection)
|
||||
throw new ArgumentException("source and target databases must be different");
|
||||
|
||||
var source = new MongoClient(options.SourceConnection).GetDatabase(options.SourceDatabase);
|
||||
var target = new MongoClient(options.TargetConnection).GetDatabase(options.TargetDatabase);
|
||||
await source.RunCommandAsync<BsonDocument>(new BsonDocument("ping", 1));
|
||||
await target.RunCommandAsync<BsonDocument>(new BsonDocument("ping", 1));
|
||||
|
||||
var questionFilter = Builders<BsonDocument>.Filter.Eq("IsPublished", true)
|
||||
& Builders<BsonDocument>.Filter.Eq("FlaggedForReviewUtc", BsonNull.Value);
|
||||
var questions = await source.GetCollection<BsonDocument>("Questions")
|
||||
.Find(questionFilter).ToListAsync();
|
||||
var duplicateIds = questions
|
||||
.GroupBy(ExternalId, StringComparer.Ordinal)
|
||||
.Where(group => group.Count() > 1)
|
||||
.Select(group => group.Key)
|
||||
.ToList();
|
||||
if (duplicateIds.Count > 0)
|
||||
throw new InvalidOperationException($"duplicate eligible ExternalIds: {string.Join(", ", duplicateIds)}");
|
||||
|
||||
var mediaIds = CollectMediaIds(questions);
|
||||
var media = mediaIds.Count == 0
|
||||
? []
|
||||
: await source.GetCollection<BsonDocument>("QuestionMediaAssets")
|
||||
.Find(Builders<BsonDocument>.Filter.In("_id", mediaIds)).ToListAsync();
|
||||
if (media.Count != mediaIds.Count)
|
||||
throw new InvalidOperationException($"{mediaIds.Count - media.Count} referenced question media assets are missing");
|
||||
|
||||
var sourceIds = questions.Select(ExternalId).ToHashSet(StringComparer.Ordinal);
|
||||
var targetCollection = target.GetCollection<BsonDocument>("Questions");
|
||||
var targetQuestions = sourceIds.Count == 0
|
||||
? []
|
||||
: await targetCollection.Find(
|
||||
Builders<BsonDocument>.Filter.In("ExternalId", sourceIds)).ToListAsync();
|
||||
var targetTotal = await targetCollection.CountDocumentsAsync(FilterDefinition<BsonDocument>.Empty);
|
||||
var comparison = ClassifyQuestions(questions, targetQuestions);
|
||||
var summary = new PromotionSummary(
|
||||
questions.Count,
|
||||
media.Count,
|
||||
comparison.Inserted,
|
||||
comparison.Updated,
|
||||
comparison.Unchanged,
|
||||
checked((int)Math.Max(0, targetTotal - targetQuestions.Count)),
|
||||
HashQuestions(questions));
|
||||
|
||||
await WriteSummaryAsync(output, options.Apply ? "apply" : "dry-run", options, summary);
|
||||
if (!options.Apply)
|
||||
return 0;
|
||||
|
||||
await UpsertMediaAsync(target, media);
|
||||
await UpsertQuestionsAsync(target, questions);
|
||||
|
||||
var verifiedQuestions = sourceIds.Count == 0
|
||||
? []
|
||||
: await targetCollection.Find(
|
||||
Builders<BsonDocument>.Filter.In("ExternalId", sourceIds)).ToListAsync();
|
||||
var verification = ClassifyQuestions(questions, verifiedQuestions);
|
||||
if (verification.Inserted != 0 || verification.Updated != 0)
|
||||
throw new InvalidOperationException(
|
||||
$"target verification failed: {verification.Inserted} missing and {verification.Updated} mismatched questions");
|
||||
var verifiedMedia = mediaIds.Count == 0
|
||||
? 0
|
||||
: checked((int)await target.GetCollection<BsonDocument>("QuestionMediaAssets")
|
||||
.CountDocumentsAsync(Builders<BsonDocument>.Filter.In("_id", mediaIds)));
|
||||
if (verifiedMedia != mediaIds.Count)
|
||||
throw new InvalidOperationException(
|
||||
$"target verification failed: {mediaIds.Count - verifiedMedia} media assets are missing");
|
||||
|
||||
await target.GetCollection<BsonDocument>("ContentPromotions").InsertOneAsync(new BsonDocument
|
||||
{
|
||||
["PromotionId"] = Guid.NewGuid().ToString("N"),
|
||||
["AppliedUtc"] = DateTime.UtcNow,
|
||||
["Operator"] = options.Operator,
|
||||
["BuildRevision"] = options.BuildRevision,
|
||||
["SourceDatabase"] = options.SourceDatabase,
|
||||
["TargetDatabase"] = options.TargetDatabase,
|
||||
["Scope"] = "questions",
|
||||
["Eligibility"] = "published_unflagged",
|
||||
["Questions"] = summary.EligibleQuestions,
|
||||
["Inserted"] = summary.Inserted,
|
||||
["Updated"] = summary.Updated,
|
||||
["Unchanged"] = summary.Unchanged,
|
||||
["MediaAssets"] = summary.MediaAssets,
|
||||
["TargetExtras"] = summary.TargetExtras,
|
||||
["ContentHash"] = summary.ContentHash
|
||||
});
|
||||
await output.WriteLineAsync("Question promotion completed and verified.");
|
||||
return 0;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
await output.WriteLineAsync($"Question promotion failed: {exception.Message}");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
internal static bool IsEligible(BsonDocument question) =>
|
||||
question.GetValue("IsPublished", false).ToBoolean()
|
||||
&& (!question.TryGetValue("FlaggedForReviewUtc", out var flagged) || flagged.IsBsonNull);
|
||||
|
||||
internal static HashSet<ObjectId> CollectMediaIds(IEnumerable<BsonDocument> questions)
|
||||
{
|
||||
var ids = new HashSet<ObjectId>();
|
||||
foreach (var question in questions)
|
||||
{
|
||||
if (!question.TryGetValue("Media", out var raw) || !raw.IsBsonArray)
|
||||
continue;
|
||||
foreach (var value in raw.AsBsonArray)
|
||||
{
|
||||
if (!value.IsString)
|
||||
continue;
|
||||
var reference = value.AsString.Trim();
|
||||
var candidate = reference.TrimEnd('/').Split('/').LastOrDefault() ?? "";
|
||||
if (ObjectId.TryParse(reference, out var direct))
|
||||
ids.Add(direct);
|
||||
else if (ObjectId.TryParse(candidate, out var fromPath))
|
||||
ids.Add(fromPath);
|
||||
else if (reference.Contains("/api/questions/media/", StringComparison.OrdinalIgnoreCase))
|
||||
throw new InvalidOperationException(
|
||||
$"question {ExternalId(question)} has an invalid local media reference: {reference}");
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
internal static QuestionComparison ClassifyQuestions(
|
||||
IEnumerable<BsonDocument> sourceQuestions,
|
||||
IEnumerable<BsonDocument> targetQuestions)
|
||||
{
|
||||
var target = targetQuestions.ToDictionary(ExternalId, StringComparer.Ordinal);
|
||||
var inserted = 0;
|
||||
var updated = 0;
|
||||
var unchanged = 0;
|
||||
foreach (var source in sourceQuestions)
|
||||
{
|
||||
var externalId = ExternalId(source);
|
||||
if (!target.TryGetValue(externalId, out var existing))
|
||||
inserted++;
|
||||
else if (EquivalentQuestion(source, existing))
|
||||
unchanged++;
|
||||
else
|
||||
updated++;
|
||||
}
|
||||
return new QuestionComparison(inserted, updated, unchanged);
|
||||
}
|
||||
|
||||
internal static string HashQuestions(IEnumerable<BsonDocument> questions)
|
||||
{
|
||||
using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
|
||||
foreach (var document in questions.OrderBy(ExternalId, StringComparer.Ordinal))
|
||||
hash.AppendData(Encoding.UTF8.GetBytes(CanonicalQuestion(document).ToJson()));
|
||||
return Convert.ToHexString(hash.GetHashAndReset()).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static bool EquivalentQuestion(BsonDocument source, BsonDocument target) =>
|
||||
CanonicalQuestion(source).Equals(CanonicalQuestion(target));
|
||||
|
||||
private static BsonDocument CanonicalQuestion(BsonDocument source) =>
|
||||
Canonicalize(new BsonDocument(source.Where(element => element.Name != "_id"))).AsBsonDocument;
|
||||
|
||||
private static BsonValue Canonicalize(BsonValue value)
|
||||
{
|
||||
if (value.IsBsonDocument)
|
||||
return new BsonDocument(value.AsBsonDocument
|
||||
.OrderBy(element => element.Name, StringComparer.Ordinal)
|
||||
.Select(element => new BsonElement(element.Name, Canonicalize(element.Value))));
|
||||
if (value.IsBsonArray)
|
||||
return new BsonArray(value.AsBsonArray.Select(Canonicalize));
|
||||
return value;
|
||||
}
|
||||
|
||||
private static string ExternalId(BsonDocument document) =>
|
||||
document.GetValue("ExternalId", "").AsString;
|
||||
|
||||
private static async Task UpsertQuestionsAsync(IMongoDatabase target, IEnumerable<BsonDocument> questions)
|
||||
{
|
||||
var collection = target.GetCollection<BsonDocument>("Questions");
|
||||
foreach (var source in questions)
|
||||
{
|
||||
var replacement = new BsonDocument(source.Where(element => element.Name != "_id"));
|
||||
await collection.ReplaceOneAsync(
|
||||
Builders<BsonDocument>.Filter.Eq("ExternalId", source["ExternalId"]),
|
||||
replacement,
|
||||
new ReplaceOptions { IsUpsert = true });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task UpsertMediaAsync(IMongoDatabase target, IEnumerable<BsonDocument> media)
|
||||
{
|
||||
var collection = target.GetCollection<BsonDocument>("QuestionMediaAssets");
|
||||
foreach (var source in media)
|
||||
await collection.ReplaceOneAsync(
|
||||
Builders<BsonDocument>.Filter.Eq("_id", source["_id"]),
|
||||
source,
|
||||
new ReplaceOptions { IsUpsert = true });
|
||||
}
|
||||
|
||||
private static Task WriteSummaryAsync(
|
||||
TextWriter output,
|
||||
string mode,
|
||||
PromotionOptions options,
|
||||
PromotionSummary summary) =>
|
||||
output.WriteLineAsync(JsonSerializer.Serialize(new
|
||||
{
|
||||
mode,
|
||||
scope = "questions",
|
||||
eligibility = "published_unflagged",
|
||||
source = options.SourceDatabase,
|
||||
target = options.TargetDatabase,
|
||||
eligibleQuestions = summary.EligibleQuestions,
|
||||
mediaAssets = summary.MediaAssets,
|
||||
inserted = summary.Inserted,
|
||||
updated = summary.Updated,
|
||||
unchanged = summary.Unchanged,
|
||||
targetExtras = summary.TargetExtras,
|
||||
contentHash = summary.ContentHash
|
||||
}));
|
||||
|
||||
private static PromotionOptions Parse(IReadOnlyList<string> args)
|
||||
{
|
||||
string Required(string name, string environmentName)
|
||||
{
|
||||
var index = args.IndexOf($"--{name}");
|
||||
var value = index >= 0 && index + 1 < args.Count
|
||||
? args[index + 1]
|
||||
: Environment.GetEnvironmentVariable(environmentName);
|
||||
return string.IsNullOrWhiteSpace(value)
|
||||
? throw new ArgumentException($"--{name} is required")
|
||||
: value;
|
||||
}
|
||||
string Optional(string name, string fallback)
|
||||
{
|
||||
var index = args.IndexOf($"--{name}");
|
||||
return index >= 0 && index + 1 < args.Count ? args[index + 1] : fallback;
|
||||
}
|
||||
return new PromotionOptions(
|
||||
Required("source-connection", "SOURCE_MONGODB_CONNECTION_STRING"),
|
||||
Required("source-database", "SOURCE_MONGODB_DATABASE"),
|
||||
Required("target-connection", "TARGET_MONGODB_CONNECTION_STRING"),
|
||||
Required("target-database", "TARGET_MONGODB_DATABASE"),
|
||||
args.Contains("--apply"),
|
||||
Optional("confirm", ""),
|
||||
Optional("operator", "unknown"),
|
||||
Optional("build-revision", "unknown"));
|
||||
}
|
||||
|
||||
private sealed record PromotionOptions(
|
||||
string SourceConnection,
|
||||
string SourceDatabase,
|
||||
string TargetConnection,
|
||||
string TargetDatabase,
|
||||
bool Apply,
|
||||
string Confirmation,
|
||||
string Operator,
|
||||
string BuildRevision);
|
||||
|
||||
internal sealed record QuestionComparison(int Inserted, int Updated, int Unchanged);
|
||||
internal sealed record PromotionSummary(
|
||||
int EligibleQuestions,
|
||||
int MediaAssets,
|
||||
int Inserted,
|
||||
int Updated,
|
||||
int Unchanged,
|
||||
int TargetExtras,
|
||||
string ContentHash);
|
||||
}
|
||||
@@ -41,6 +41,18 @@ public class RatingStore
|
||||
return await EnsureHistoryBaselineAsync(rating, nowUtc);
|
||||
}
|
||||
|
||||
public Task ResetUserAsync(string userId, DateTime nowUtc) =>
|
||||
_users.UpdateOneAsync(
|
||||
rating => rating.UserId == userId,
|
||||
Builders<UserRating>.Update
|
||||
.Set(rating => rating.OverallElo, BaseElo)
|
||||
.Set(rating => rating.RatedQuestionCount, 0)
|
||||
.Set(rating => rating.CategoryElos, DefaultCategoryElos())
|
||||
.Set(rating => rating.HistoryBaselineElo, null)
|
||||
.Set(rating => rating.HistoryStartedUtc, null)
|
||||
.Set(rating => rating.UpdatedUtc, nowUtc),
|
||||
new UpdateOptions { IsUpsert = false });
|
||||
|
||||
public async Task<Dictionary<string, int>> GetQuestionElosAsync(IEnumerable<string> externalIds)
|
||||
{
|
||||
var ids = externalIds.Where(id => !string.IsNullOrWhiteSpace(id)).Distinct().ToList();
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
using QuestionsApi.Models;
|
||||
using Stripe;
|
||||
using Stripe.Checkout;
|
||||
|
||||
namespace QuestionsApi.Services;
|
||||
|
||||
public interface IStripeAccessGateway
|
||||
{
|
||||
Task<StripeCheckoutResult> CreateCheckoutAsync(AccessPurchase purchase, AccessProduct product, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed class StripeAccessGateway(IConfiguration configuration) : IStripeAccessGateway
|
||||
{
|
||||
public async Task<StripeCheckoutResult> CreateCheckoutAsync(
|
||||
AccessPurchase purchase, AccessProduct product, CancellationToken cancellationToken)
|
||||
{
|
||||
var secretKey = configuration["Stripe:SecretKey"];
|
||||
if (string.IsNullOrWhiteSpace(secretKey)) throw new InvalidOperationException("Stripe secret key is not configured.");
|
||||
var successUrl = configuration["Stripe:AccessSuccessUrl"]
|
||||
?? "https://doctor.ranaze.com/donation-complete.html?session_id={CHECKOUT_SESSION_ID}";
|
||||
var cancelUrl = configuration["Stripe:AccessCancelUrl"]
|
||||
?? "https://doctor.ranaze.com/donation-canceled.html";
|
||||
var metadata = new Dictionary<string, string>
|
||||
{
|
||||
["access_purchase_id"] = purchase.Id ?? "",
|
||||
["access_sku"] = product.Sku,
|
||||
["user_id"] = purchase.UserId
|
||||
};
|
||||
var service = new SessionService(new StripeClient(secretKey));
|
||||
var session = await service.CreateAsync(new SessionCreateOptions
|
||||
{
|
||||
Mode = "payment",
|
||||
SuccessUrl = successUrl,
|
||||
CancelUrl = cancelUrl,
|
||||
ClientReferenceId = purchase.Id,
|
||||
CustomerCreation = "always",
|
||||
BillingAddressCollection = "auto",
|
||||
AutomaticTax = new SessionAutomaticTaxOptions { Enabled = true },
|
||||
Metadata = metadata,
|
||||
PaymentIntentData = new SessionPaymentIntentDataOptions { Metadata = metadata },
|
||||
LineItems =
|
||||
[
|
||||
new SessionLineItemOptions
|
||||
{
|
||||
Quantity = 1,
|
||||
PriceData = new SessionLineItemPriceDataOptions
|
||||
{
|
||||
Currency = product.Currency,
|
||||
UnitAmount = product.AmountCents,
|
||||
ProductData = new SessionLineItemPriceDataProductDataOptions
|
||||
{
|
||||
Name = product.Name,
|
||||
Description = "A manually activated RanaZe access entitlement."
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}, null, cancellationToken);
|
||||
if (string.IsNullOrWhiteSpace(session.Id) || string.IsNullOrWhiteSpace(session.Url))
|
||||
throw new InvalidOperationException("Stripe did not return a checkout URL.");
|
||||
return new StripeCheckoutResult(session.Id, session.Url);
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,8 @@ public sealed class UserProfileStore
|
||||
"Step3Outcomes", "QuestionReports", "CcsCaseReports", "AppFeedback", "UserStudyProfiles",
|
||||
"ExternalIdentities", "ExternalAuthTransactions",
|
||||
"FlashcardDecks", "FlashcardNotes", "FlashcardSchedules", "FlashcardReviewLogs",
|
||||
"FlashcardMediaAssets", "FlashcardSettings", "FlashcardImportPreviews"
|
||||
"FlashcardMediaAssets", "FlashcardSettings", "FlashcardImportPreviews",
|
||||
"UserAccessAccounts", "AccessPurchases"
|
||||
];
|
||||
|
||||
public UserProfileStore(MongoDatabaseProvider mongo)
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
"AnswerStatistics": { "ReconcileSeconds": 300 },
|
||||
"Stripe": {
|
||||
"SuccessUrl": "https://doctor.ranaze.com/donation-complete.html?session_id={CHECKOUT_SESSION_ID}",
|
||||
"CancelUrl": "https://doctor.ranaze.com/donation-canceled.html"
|
||||
"CancelUrl": "https://doctor.ranaze.com/donation-canceled.html",
|
||||
"AccessSuccessUrl": "https://doctor.ranaze.com/donation-complete.html?session_id={CHECKOUT_SESSION_ID}",
|
||||
"AccessCancelUrl": "https://doctor.ranaze.com/donation-canceled.html"
|
||||
},
|
||||
"Jwt": { "Key": "SuperUltraSecureJwtKeyWithAtLeast32Chars!!", "Issuer": "promiscuity", "Audience": "promiscuity-auth-api" },
|
||||
"PasswordReset": {
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
"AnswerStatistics": { "ReconcileSeconds": 300 },
|
||||
"Stripe": {
|
||||
"SuccessUrl": "https://doctor.ranaze.com/donation-complete.html?session_id={CHECKOUT_SESSION_ID}",
|
||||
"CancelUrl": "https://doctor.ranaze.com/donation-canceled.html"
|
||||
"CancelUrl": "https://doctor.ranaze.com/donation-canceled.html",
|
||||
"AccessSuccessUrl": "https://doctor.ranaze.com/donation-complete.html?session_id={CHECKOUT_SESSION_ID}",
|
||||
"AccessCancelUrl": "https://doctor.ranaze.com/donation-canceled.html"
|
||||
},
|
||||
"Steam": {
|
||||
"Realm": "https://s3q.ranaze.com",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 84 KiB |
@@ -0,0 +1,14 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#ffffff"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M12 7v14" />
|
||||
<path d="M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 373 B |
@@ -0,0 +1,43 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://bgv42gr8xm8rf"
|
||||
path="res://.godot/imported/book-open.svg-683be4c8f98553ef842aedfa9c35759c.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/icons/lucide/book-open.svg"
|
||||
dest_files=["res://.godot/imported/book-open.svg-683be4c8f98553ef842aedfa9c35759c.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
svg/scale=1.0
|
||||
editor/scale_with_editor_scale=false
|
||||
editor/convert_colors_with_editor_theme=false
|
||||
@@ -0,0 +1,13 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#ffffff"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M11.525 2.295a.53.53 0 0 1 .95 0l2.31 4.679a2.123 2.123 0 0 0 1.595 1.16l5.166.756a.53.53 0 0 1 .294.904l-3.736 3.638a2.123 2.123 0 0 0-.611 1.878l.882 5.14a.53.53 0 0 1-.771.56l-4.618-2.428a2.122 2.122 0 0 0-1.973 0L6.396 21.01a.53.53 0 0 1-.77-.56l.881-5.139a2.122 2.122 0 0 0-.611-1.879L2.16 9.795a.53.53 0 0 1 .294-.906l5.165-.755a2.122 2.122 0 0 0 1.597-1.16z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 583 B |
@@ -0,0 +1,43 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://dty3x2w0mjrv3"
|
||||
path="res://.godot/imported/star.svg-5cf15f039032ad2da3c91090b75025e6.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/icons/lucide/star.svg"
|
||||
dest_files=["res://.godot/imported/star.svg-5cf15f039032ad2da3c91090b75025e6.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
svg/scale=1.0
|
||||
editor/scale_with_editor_scale=false
|
||||
editor/convert_colors_with_editor_theme=false
|
||||
@@ -0,0 +1,82 @@
|
||||
# Access economy
|
||||
|
||||
RanaZe is free to install and use on the public web client and on Steam. An
|
||||
account is required. The server is authoritative for quotas, credits, token
|
||||
inventory, prices, and grants.
|
||||
|
||||
## QBank limits and credits
|
||||
|
||||
- Standard QBank use includes 20 completed questions in a rolling 24-hour
|
||||
window. An answer or skip consumes one slot.
|
||||
- An eligible first answer in the current QBank cycle earns one credit,
|
||||
regardless of correctness. Skips and repeat answers earn no credit.
|
||||
- A user can earn at most 20 credits in a rolling 24-hour window, including
|
||||
while an unlimited pass is active.
|
||||
- 100 earned credits can be redeemed for 24 hours of unlimited standard QBank
|
||||
use. Credits cannot be purchased.
|
||||
- Paid access tokens stay in inventory until manually activated. Activation
|
||||
extends from the later of the current time or the current pass expiration.
|
||||
|
||||
Other study tools remain free. An unlimited QBank pass changes only the standard
|
||||
QBank completion limit.
|
||||
|
||||
## Product catalog
|
||||
|
||||
| SKU | Grant | Price |
|
||||
| --- | --- | ---: |
|
||||
| `access_day_1` | one manually activated 24-hour QBank token | $1.00 |
|
||||
| `access_week_1` | one manually activated 168-hour QBank token | $5.00 |
|
||||
| `access_month_1` | one manually activated 720-hour QBank token | $20.00 |
|
||||
| `exam_token_1` | one exam simulation | $1.99 |
|
||||
| `qbank_reset_1` | one QBank reset | $4.99 |
|
||||
|
||||
A reset requires explicit confirmation and is blocked while an exam simulation
|
||||
is active. It starts a new personal QBank cycle, resets personal QBank rating
|
||||
and current-cycle performance, and makes questions eligible as new samples.
|
||||
Existing global aggregate samples are not removed. Bookmarks, notes, and review
|
||||
content remain.
|
||||
|
||||
Existing accounts begin with zero credits and no tokens. Historical attempts
|
||||
are not backfilled into credits.
|
||||
|
||||
## Web checkout
|
||||
|
||||
Web purchases use Stripe-hosted Checkout. Configure:
|
||||
|
||||
```text
|
||||
Stripe__SecretKey
|
||||
Stripe__AccessWebhookSecret
|
||||
Stripe__AccessSuccessUrl
|
||||
Stripe__AccessCancelUrl
|
||||
```
|
||||
|
||||
Create separate access webhook endpoints:
|
||||
|
||||
- UAT test mode: `https://us3q.ranaze.com/api/access/stripe-webhook`
|
||||
- production live mode: `https://s3q.ranaze.com/api/access/stripe-webhook`
|
||||
|
||||
Each endpoint has a distinct signing secret. Store them as
|
||||
`UAT_STRIPE_ACCESS_WEBHOOK_SECRET` and
|
||||
`PRD_STRIPE_ACCESS_WEBHOOK_SECRET` in Gitea. Subscribe both endpoints to
|
||||
`checkout.session.completed`, `checkout.session.async_payment_succeeded`,
|
||||
`checkout.session.async_payment_failed`, and `checkout.session.expired`.
|
||||
|
||||
Prices and grants are selected from the API catalog; the client never supplies
|
||||
an amount. Webhook signatures, expected amount, currency, session, and purchase
|
||||
ownership are verified before an idempotent grant is applied.
|
||||
|
||||
## Steam checkout
|
||||
|
||||
The Windows Steam export has the `steam` feature flag and routes purchase
|
||||
requests to the disabled Steam checkout endpoint. It does not expose Stripe
|
||||
checkout. The endpoint intentionally returns 503 until the Steamworks AppID,
|
||||
Microtransactions product catalog, server credentials, callbacks,
|
||||
reconciliation, refund handling, and sandbox tests are implemented.
|
||||
|
||||
## Operational checks
|
||||
|
||||
Before release, verify all products in Stripe test mode and then live mode.
|
||||
Exercise duplicate webhook delivery and concurrent quota submissions. Confirm
|
||||
that data export/deletion includes `UserAccessAccounts` and `AccessPurchases`.
|
||||
Monitor rejected checkouts, webhook verification failures, unmatched payments,
|
||||
grant failures, and quota conflicts.
|
||||
@@ -0,0 +1,275 @@
|
||||
# Steam alpha release checklist
|
||||
|
||||
Use this as the go/no-go record for the first external Windows alpha of
|
||||
**RanaZe: Step 3 Trainer**. Put the owner, completion date, and a link to the
|
||||
evidence beside each checked item. An unchecked **BLOCKER** means do not admit
|
||||
testers.
|
||||
|
||||
## Release decision
|
||||
|
||||
- [x] **BLOCKER — Choose the release vehicle.** **Decision: release the app
|
||||
free on Steam and keep the existing public web client available. Both clients
|
||||
use the same account access model. QBank passes and study tokens are optional
|
||||
in-app purchases; Steam purchases remain disabled until Steamworks
|
||||
Microtransactions are configured.**
|
||||
- [ ] Record the decision, target date, tester count, supported countries,
|
||||
tester-selection method, feedback channel, and person with final go/no-go
|
||||
authority.
|
||||
- [ ] Define alpha success and stop conditions: minimum completed sessions,
|
||||
acceptable crash/error rate, critical defect threshold, support response
|
||||
time, server-capacity ceiling, and the date results will be reviewed.
|
||||
- [ ] Define the advertised alpha scope. List working features, known
|
||||
limitations, expected resets or breaking changes, and features explicitly
|
||||
excluded from this test.
|
||||
|
||||
Decision: Free Steam alpha plus the existing public web client; shared freemium access model
|
||||
Target date/time and time zone: ____________________
|
||||
Release owner: ____________________
|
||||
Build commit SHA: ____________________
|
||||
Steam base AppID: ____________________
|
||||
Steam Playtest AppID (if used): ____________________
|
||||
|
||||
## Steamworks account and application
|
||||
|
||||
- [ ] **BLOCKER — Complete Steamworks onboarding.** Verify the legal business
|
||||
name, bank and tax details, identity verification, Steam Distribution
|
||||
Agreement, and team permissions.
|
||||
- [ ] Pay and activate the Steam Direct app credit for the base application
|
||||
(currently USD 100 per product, subject to tax).
|
||||
- [ ] Create the base application and save its AppID above.
|
||||
- [ ] For a Playtest, create the child Playtest app from **Associated Packages
|
||||
& DLC**, choose its public name carefully, and save its AppID above. The
|
||||
public Playtest name cannot be changed after release.
|
||||
- [ ] Restrict Steamworks permissions to the people who need them; require
|
||||
Steam Guard and make sure at least two trusted people can handle a release.
|
||||
- [ ] Decide whether the main store page will be public. A Playtest can instead
|
||||
use keys without making signups or the store page public, but testers should
|
||||
not be promised confidentiality merely because access is limited.
|
||||
|
||||
## Content, legal, and privacy clearance
|
||||
|
||||
- [ ] **BLOCKER — Complete the repository's outstanding originality review for
|
||||
every released question and explanation.** Retain reviewer, date, sources,
|
||||
revisions, and disposition.
|
||||
- [ ] **BLOCKER — Complete the outstanding copyright/license audit for every
|
||||
image-bearing question.** Remove or replace anything whose reuse rights
|
||||
cannot be established.
|
||||
- [ ] **BLOCKER — Complete the duplicate-question review and authoritative
|
||||
reference verification described in `README.md`.** Release only published,
|
||||
unflagged, cleared content.
|
||||
- [ ] Review all other shipped assets—branding, fonts, icons, audio, code,
|
||||
screenshots, trailer footage, and third-party notices—and retain a license
|
||||
inventory.
|
||||
- [ ] Have qualified counsel/ownership review the Terms of Service, Privacy
|
||||
Policy, educational/medical disclaimer, trademark claims, refund/customer
|
||||
terms, and the handling of study notes or other user-entered data.
|
||||
- [ ] Confirm the Steam store description and screenshots never imply medical
|
||||
advice, clinical certification, guaranteed exam performance, or affiliation
|
||||
with USMLE, NBME, FSMB, or a licensing authority.
|
||||
- [ ] Complete Steam's Content Survey honestly and make its answers consistent
|
||||
with both the build and store page.
|
||||
- [ ] Verify `[email protected]` is monitored and that account data export,
|
||||
permanent deletion, provider unlinking, and privacy requests work end to end.
|
||||
- [ ] Publish and test the production legal pages:
|
||||
`https://step3.ranaze.com/terms.html` and
|
||||
`https://step3.ranaze.com/privacy.html`.
|
||||
|
||||
## Production service readiness
|
||||
|
||||
- [ ] **BLOCKER — Complete the initial PRD launch and question promotion in
|
||||
`ENVIRONMENTS.md`, including dry run, approval, apply, and post-promotion
|
||||
verification.**
|
||||
- [ ] Confirm PRD uses a unique JWT key, PRD Mongo database, production host
|
||||
names, production legal versions, and production secrets; no UAT data or
|
||||
credentials may be present.
|
||||
- [ ] Configure and test the live Steam callbacks, including:
|
||||
`https://s3q.ranaze.com/api/Auth/steam/callback` and
|
||||
`https://s3q.ranaze.com/api/Auth/external/steam/callback`.
|
||||
- [ ] From a clean Steam account, test create-account, link-existing-account,
|
||||
sign-out/sign-in, expired/cancelled authentication, unlinking, and account
|
||||
deletion. Confirm the app never asks for or receives a Steam password.
|
||||
- [ ] **BLOCKER — Configure and test Stripe access purchases on the web** at
|
||||
both `https://us3q.ranaze.com/api/access/stripe-webhook` in test mode and
|
||||
`https://s3q.ranaze.com/api/access/stripe-webhook` in live mode. Use distinct
|
||||
signing secrets, subscribe the required Checkout events, and test all five
|
||||
fixed-price products. Keep the separate contribution flow only if wanted.
|
||||
- [ ] **BLOCKER — Keep Stripe checkout unavailable in the Steam build.** Before
|
||||
enabling purchases there, configure Steamworks Microtransactions, product
|
||||
IDs, callbacks, reconciliation, refunds, and sandbox tests. Confirm the
|
||||
desktop export carries the `steam` feature flag.
|
||||
- [ ] Exercise production readiness endpoints and dashboards, and configure
|
||||
alerts for API unavailability, elevated 5xx responses, authentication
|
||||
failures, database capacity, and certificate expiry.
|
||||
- [ ] Load-test the API at the planned tester count plus headroom. Document a
|
||||
tester-admission cap and who can stop new admissions.
|
||||
- [ ] Take and restore-test a production database backup. Record retention,
|
||||
rollback, incident-response, and credential-rotation procedures.
|
||||
|
||||
## Windows release build
|
||||
|
||||
- [ ] Install the required Godot 4.6.x release export templates and confirm the
|
||||
pinned engine version used for the release.
|
||||
- [ ] Update all visible versions from `pre-alpha`/`0.1.0` to the approved alpha
|
||||
version in `project.godot`, `export_presets.cfg`, the UI, and release notes.
|
||||
- [ ] Decide whether to disable the Windows console wrapper
|
||||
(`debug/export_console_wrapper=1`) for tester-facing builds.
|
||||
- [ ] Decide whether to Authenticode-sign `RanaZe.exe`; if signing, protect the
|
||||
certificate, timestamp the executable, and verify the signature.
|
||||
- [ ] Export only from the reviewed release commit, with a clean working tree,
|
||||
using Git Bash:
|
||||
|
||||
```bash
|
||||
GODOT_BIN=/absolute/path/to/Godot_v4.6.x-stable_win64_console.exe \
|
||||
./tools/export_steam_release.sh build/windows/RanaZe.exe
|
||||
```
|
||||
|
||||
- [ ] **BLOCKER — Verify the export reports `deployment_environment="prd"`,
|
||||
the exact release commit in `build_revision`, and only `s3q.ranaze.com`
|
||||
production API endpoints.**
|
||||
- [ ] Confirm the upload directory contains `RanaZe.exe`, `RanaZe.pck`, and all
|
||||
runtime dependencies, but no source, secrets, `.env` files, export backup,
|
||||
debug symbols not intended for testers, or test data.
|
||||
- [ ] Virus-scan the staged files and archive a manifest containing SHA-256
|
||||
hashes, build command, Godot version, commit SHA, build time, and builder.
|
||||
|
||||
## SteamPipe and Steam configuration
|
||||
|
||||
- [ ] Download the current Steamworks SDK. The SDK is required to upload with
|
||||
SteamPipe; integrating the Steamworks runtime API is optional for this app's
|
||||
existing browser-based Steam OpenID flow.
|
||||
- [ ] Create the Windows depot and default package for the selected AppID.
|
||||
- [ ] Create version-controlled SteamPipe app/depot VDF templates with AppIDs
|
||||
but no Steam credentials. Point the depot content root at the staged Windows
|
||||
directory.
|
||||
- [ ] Upload through `steamcmd`, add a human-readable build description with
|
||||
the alpha version and commit SHA, and first assign it to a password-protected
|
||||
`alpha` branch.
|
||||
- [ ] Configure the Windows launch option to run `RanaZe.exe` from the depot
|
||||
root. Verify OS is Windows and architecture is 64-bit.
|
||||
- [ ] Set supported language(s) to match what is actually available in the
|
||||
build. Do not claim interface, audio, or subtitle support that has not been
|
||||
tested.
|
||||
- [ ] Configure install/uninstall behavior and required redistributables, if
|
||||
any. Confirm uninstall does not remove cloud/server account data and that
|
||||
locally generated sensitive files are understood.
|
||||
- [ ] If using a Playtest, upload and configure the build on the **Playtest
|
||||
AppID**, not merely the base game's depot.
|
||||
- [ ] Test installation, launch, update, rollback, file verification, uninstall,
|
||||
and reinstall entirely through the Steam client—never only by running the
|
||||
local export.
|
||||
|
||||
## Store and tester communication
|
||||
|
||||
- [ ] Prepare truthful short and long descriptions, developer/publisher names,
|
||||
tags, platform/language support, system requirements, support contact, and
|
||||
privacy-policy URL.
|
||||
- [ ] Create required store, library, community, and client graphical assets at
|
||||
Steam's current dimensions. Use actual alpha screenshots and clearly label
|
||||
footage or features that are not representative of the current build.
|
||||
- [ ] Check all asset rights and Steam's graphical asset rules. Localize text
|
||||
appearing in graphical assets for every language the game claims to support.
|
||||
- [ ] For Early Access only, complete the Early Access questionnaire, describe
|
||||
the current state and update plan without promises the team cannot guarantee,
|
||||
set a price justified by the current build, and use required Early Access
|
||||
branding wherever keys are sold.
|
||||
- [ ] Draft tester instructions covering scope, known issues, medical
|
||||
disclaimer, feedback/bug-report format, log/screenshot handling, privacy,
|
||||
support hours, update/reset expectations, and test end date.
|
||||
- [ ] Draft the launch announcement, first patch notes, outage message, and
|
||||
alpha-ending message before admitting testers.
|
||||
|
||||
## Release-candidate acceptance
|
||||
|
||||
- [ ] Promote the exact uploaded build to the protected `alpha` branch and have
|
||||
at least two people test it independently on clean Windows 10 and Windows 11
|
||||
user profiles.
|
||||
- [ ] Test at 1920×1080 plus a smaller supported resolution and at 100%, 125%,
|
||||
and 150% Windows scaling. Check keyboard/mouse navigation, text clipping,
|
||||
external browser transitions, focus return, and readable error states.
|
||||
- [ ] Run the complete learning journey: registration and legal consent,
|
||||
practice question, randomized choices, answer/explanation/reference,
|
||||
dashboard, review queue, notes/bookmarks, flashcards, CCS, feedback/report,
|
||||
data export, and account deletion.
|
||||
- [ ] Run the access journey on web: questions 1–20, rolling-window exhaustion,
|
||||
one credit per eligible first attempt, no credit for skips/repeats, the
|
||||
20-credit earning cap, 100-credit redemption, purchase and manual activation
|
||||
of each pass, exam-token consumption, reset confirmation, and retained
|
||||
bookmarks/notes after reset.
|
||||
- [ ] Test offline start, connection loss during a session, API timeout, expired
|
||||
token, server maintenance, malformed response, and recovery without corrupting
|
||||
learner progress.
|
||||
- [ ] Run automated API/Godot tests and the production-equivalent smoke suite;
|
||||
attach results to the release record. Do not rely only on the web UAT smoke
|
||||
suite for the Windows Steam build.
|
||||
- [ ] Verify every feature and claim shown on the store page exists in the
|
||||
submitted build.
|
||||
- [ ] Triage all findings. Zero unresolved critical issues; every accepted
|
||||
non-critical issue has an owner, rationale, workaround, and target release.
|
||||
- [ ] Obtain written sign-off from engineering, content/medical review, legal/
|
||||
privacy, operations, and the release owner.
|
||||
|
||||
## Valve review and scheduling
|
||||
|
||||
- [ ] Complete the selected app's Steamworks store-presence checklist and submit
|
||||
it for review. Valve advises allowing at least seven business days even though
|
||||
reviews commonly take three to five business days.
|
||||
- [ ] After store presence is submitted, complete the game-build checklist,
|
||||
place a mostly final build on the default branch for Valve, and submit the
|
||||
build for review.
|
||||
- [ ] Resolve every Valve review note and repeat the relevant acceptance tests
|
||||
against any changed build or store material.
|
||||
- [ ] If publishing the main Coming Soon page, allow at least the current
|
||||
Steam-required visibility period before an Early Access/full release. Confirm
|
||||
the exact eligible release date shown in Steamworks rather than calculating
|
||||
it from memory.
|
||||
- [ ] For a Playtest, choose limited or open signup, batch size/countries, or
|
||||
key distribution. Never charge for Playtest access or sell Playtest keys.
|
||||
- [ ] Schedule the release while the release owner, backend operator, and
|
||||
support contact are available for the initial monitoring window.
|
||||
|
||||
## Launch day
|
||||
|
||||
- [ ] Freeze unrelated PRD and client changes; confirm the approved commit,
|
||||
Steam build ID, depot manifest, store copy, tester count, and rollback build.
|
||||
- [ ] Re-run public health/readiness checks, login, one question flow, feedback,
|
||||
legal links, database backup status, alerts, and Steam callback.
|
||||
- [ ] Set the approved build live on the intended Steam branch.
|
||||
- [ ] For a Playtest, set the app **Playable**, then admit only the planned
|
||||
first batch or enable the approved signup mode.
|
||||
- [ ] Install and launch from a tester account with no developer permissions.
|
||||
Verify download size, executable, version/SHA, production environment, and
|
||||
end-to-end authentication.
|
||||
- [ ] Publish tester instructions/announcement and open the staffed feedback
|
||||
channel.
|
||||
- [ ] Record release time, Steam build ID, admitted tester count, incidents,
|
||||
decisions, and changes in the release log.
|
||||
|
||||
## Monitoring, rollback, and alpha close
|
||||
|
||||
- [ ] Monitor API/error/auth rates, capacity, reports, support inbox, Steam
|
||||
discussions, and content-safety feedback at the defined cadence.
|
||||
- [ ] Acknowledge critical medical-accuracy or copyright reports immediately;
|
||||
unpublish affected content while it is reviewed.
|
||||
- [ ] Stop admissions for capacity or support overload. Set the Playtest to
|
||||
**Not Playable** or roll back its live branch when a stop condition is met,
|
||||
and communicate the impact to testers.
|
||||
- [ ] Patch from a reviewed commit, repeat affected acceptance tests, upload to
|
||||
the protected branch, test through Steam, then promote the exact build.
|
||||
- [ ] At alpha end, announce the deadline, stop signups, communicate account/
|
||||
progress retention, and set the Playtest to **Not Playable** when appropriate.
|
||||
- [ ] Export metrics without exposing personal data; triage feedback into owned
|
||||
work; hold a retrospective; document the next test's entry criteria.
|
||||
- [ ] Retain build manifests, approvals, test evidence, incidents, content
|
||||
review records, and the final release log according to the team's policy.
|
||||
|
||||
## Official Steam references
|
||||
|
||||
- [Steam Playtest](https://partner.steamgames.com/doc/features/playtest)
|
||||
- [Steam release process](https://partner.steamgames.com/doc/store/releasing)
|
||||
- [Steam review process](https://partner.steamgames.com/doc/store/review_process)
|
||||
- [Uploading to Steam / SteamPipe](https://partner.steamgames.com/doc/sdk/uploading)
|
||||
- [Steam Direct fee](https://partner.steamgames.com/doc/gettingstarted/appfee)
|
||||
- [Steamworks onboarding](https://partner.steamgames.com/doc/gettingstarted/onboarding)
|
||||
- [Content Survey](https://partner.steamgames.com/doc/gettingstarted/contentsurvey)
|
||||
- [Early Access](https://partner.steamgames.com/doc/store/earlyaccess)
|
||||
- [Store graphical assets](https://partner.steamgames.com/doc/store/assets/standard)
|
||||
@@ -7,9 +7,9 @@ Contributions are not tax-deductible.
|
||||
## Stripe test-mode setup
|
||||
|
||||
1. In the Stripe Dashboard, switch to test mode.
|
||||
2. Create a webhook endpoint at:
|
||||
2. Create the UAT test-mode webhook endpoint at:
|
||||
|
||||
`https://s3q.ranaze.com/api/donations/stripe-webhook`
|
||||
`https://us3q.ranaze.com/api/donations/stripe-webhook`
|
||||
|
||||
3. Subscribe the endpoint to these events:
|
||||
|
||||
@@ -22,10 +22,12 @@ Contributions are not tax-deductible.
|
||||
5. Copy a test secret API key (`sk_test_...`).
|
||||
6. Add both values as Gitea Actions secrets in the API repository:
|
||||
|
||||
- `STRIPE_SECRET_KEY`
|
||||
- `STRIPE_WEBHOOK_SECRET`
|
||||
- `UAT_STRIPE_SECRET_KEY`
|
||||
- `UAT_STRIPE_WEBHOOK_SECRET`
|
||||
|
||||
The deployment workflow writes these values to the Kubernetes secret `medmind-stripe`. Never commit either value to the repository.
|
||||
The deployment workflow writes these values to the environment's
|
||||
`step3-runtime-secrets` Kubernetes secret. Never commit either value to the
|
||||
repository.
|
||||
|
||||
## Stripe account settings
|
||||
|
||||
@@ -47,9 +49,10 @@ The client-visible status endpoint returns only the donation ID, amount, currenc
|
||||
|
||||
## Live-mode cutover
|
||||
|
||||
1. Create the same webhook endpoint in Stripe live mode.
|
||||
2. Replace `STRIPE_SECRET_KEY` with the live secret key (`sk_live_...`).
|
||||
3. Replace `STRIPE_WEBHOOK_SECRET` with the live endpoint's signing secret.
|
||||
1. Create `https://s3q.ranaze.com/api/donations/stripe-webhook` in Stripe live
|
||||
mode.
|
||||
2. Set `PRD_STRIPE_SECRET_KEY` to the live secret key (`sk_live_...`).
|
||||
3. Set `PRD_STRIPE_WEBHOOK_SECRET` to that endpoint's signing secret.
|
||||
4. Trigger a deployment and complete a small real contribution.
|
||||
5. Confirm the payment, receipt, webhook delivery, and `paid` status before announcing availability.
|
||||
|
||||
|
||||
@@ -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="steam"
|
||||
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}'"
|
||||
|
||||
@@ -56,6 +56,11 @@ spec:
|
||||
secretKeyRef:
|
||||
name: step3-runtime-secrets
|
||||
key: stripe-webhook-secret
|
||||
- name: Stripe__AccessWebhookSecret
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: step3-runtime-secrets
|
||||
key: stripe-access-webhook-secret
|
||||
- name: Google__ClientId
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
|
||||
@@ -29,5 +29,7 @@ configMapGenerator:
|
||||
- Legal__PrivacyUrl=https://step3.ranaze.com/privacy.html
|
||||
- Stripe__SuccessUrl=https://step3.ranaze.com/donation-complete.html?session_id={CHECKOUT_SESSION_ID}
|
||||
- Stripe__CancelUrl=https://step3.ranaze.com/donation-canceled.html
|
||||
- Stripe__AccessSuccessUrl=https://step3.ranaze.com/donation-complete.html?session_id={CHECKOUT_SESSION_ID}
|
||||
- Stripe__AccessCancelUrl=https://step3.ranaze.com/donation-canceled.html
|
||||
generatorOptions:
|
||||
disableNameSuffixHash: true
|
||||
|
||||
@@ -26,5 +26,7 @@ configMapGenerator:
|
||||
- Legal__PrivacyUrl=https://ustep3.ranaze.com/privacy.html
|
||||
- Stripe__SuccessUrl=https://ustep3.ranaze.com/donation-complete.html?session_id={CHECKOUT_SESSION_ID}
|
||||
- Stripe__CancelUrl=https://ustep3.ranaze.com/donation-canceled.html
|
||||
- Stripe__AccessSuccessUrl=https://ustep3.ranaze.com/donation-complete.html?session_id={CHECKOUT_SESSION_ID}
|
||||
- Stripe__AccessCancelUrl=https://ustep3.ranaze.com/donation-canceled.html
|
||||
generatorOptions:
|
||||
disableNameSuffixHash: true
|
||||
|
||||
+7
-1
@@ -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://assets/branding/ranaze-mark.png"
|
||||
boot_splash/fullsize=false
|
||||
boot_splash/use_filter=true
|
||||
|
||||
[autoload]
|
||||
|
||||
@@ -39,6 +43,7 @@ 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"
|
||||
AccessService="*res://scripts/autoload/access_service.gd"
|
||||
|
||||
[display]
|
||||
|
||||
@@ -60,7 +65,7 @@ textures/vram_compression/import_etc2_astc=true
|
||||
|
||||
[step3]
|
||||
|
||||
deployment_environment="development"
|
||||
deployment_environment="uat"
|
||||
questions_api_url="https://us3q.ranaze.com/api/questions/search"
|
||||
question_reports_api_url="https://us3q.ranaze.com/api/question-reports"
|
||||
app_feedback_api_url="https://us3q.ranaze.com/api/app-feedback"
|
||||
@@ -68,6 +73,7 @@ notes_api_url="https://us3q.ranaze.com/api/notes"
|
||||
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"
|
||||
access_api_url="https://us3q.ranaze.com/api/access"
|
||||
review_queue_api_url="https://us3q.ranaze.com/api/review-queue"
|
||||
flashcards_api_url="https://us3q.ranaze.com/api/flashcards"
|
||||
build_revision="development"
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
extends Node
|
||||
|
||||
const EndpointConfig := preload("res://scripts/autoload/api_endpoint_config.gd")
|
||||
const DEFAULT_URL := "https://us3q.ranaze.com/api/access"
|
||||
const REQUEST_TIMEOUT_SECONDS := 30.0
|
||||
const POLL_INTERVAL_SECONDS := 3.0
|
||||
const POLL_LIMIT := 200
|
||||
const CHECKOUT_STORAGE_KEY := "ranaze_access_checkout_url"
|
||||
|
||||
signal status_changed(status: Dictionary)
|
||||
signal operation_succeeded(message: String)
|
||||
signal operation_failed(message: String)
|
||||
signal checkout_started(purchase_id: String, checkout_url: String)
|
||||
|
||||
var api_url := DEFAULT_URL
|
||||
var status: Dictionary = {}
|
||||
var status_request: HTTPRequest
|
||||
var action_request: HTTPRequest
|
||||
var purchase_request: HTTPRequest
|
||||
var poll_timer: Timer
|
||||
var pending_action := ""
|
||||
var request_token := ""
|
||||
var active_purchase_id := ""
|
||||
var poll_count := 0
|
||||
var checkout_popup_opened := false
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
api_url = EndpointConfig.resolve(
|
||||
str(ProjectSettings.get_setting("step3/access_api_url", DEFAULT_URL)),
|
||||
"/api/access")
|
||||
status_request = _request_node(_on_status_completed)
|
||||
action_request = _request_node(_on_action_completed)
|
||||
purchase_request = _request_node(_on_purchase_status_completed)
|
||||
poll_timer = Timer.new()
|
||||
poll_timer.wait_time = POLL_INTERVAL_SECONDS
|
||||
poll_timer.timeout.connect(_poll_purchase)
|
||||
add_child(poll_timer)
|
||||
AuthState.auth_changed.connect(_on_auth_changed)
|
||||
if AuthState.is_logged_in:
|
||||
refresh()
|
||||
|
||||
|
||||
func refresh() -> void:
|
||||
if not AuthState.can_send_authenticated_request() or status_request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED:
|
||||
return
|
||||
request_token = AuthState.access_token
|
||||
status_request.request(api_url, PackedStringArray(["Authorization: Bearer %s" % request_token]))
|
||||
|
||||
|
||||
func redeem_credits() -> void:
|
||||
_send_action("redeem", "/redeem-credits", {"operationId": _operation_id("credit-pass")})
|
||||
|
||||
|
||||
func activate_token(token: String) -> void:
|
||||
_send_action("activate", "/activate-token", {
|
||||
"token": token,
|
||||
"operationId": _operation_id("activate-%s" % token)
|
||||
})
|
||||
|
||||
|
||||
func reset_qbank() -> void:
|
||||
_send_action("reset", "/qbank-reset", {
|
||||
"confirmation": "RESET_QBANK",
|
||||
"operationId": _operation_id("qbank-reset")
|
||||
})
|
||||
|
||||
|
||||
func begin_checkout(sku: String) -> void:
|
||||
if OS.has_feature("steam"):
|
||||
_send_action("steam_checkout", "/steam-checkouts", {"sku": sku})
|
||||
return
|
||||
if OS.has_feature("web") and not _prepare_browser_window():
|
||||
operation_failed.emit("Checkout needs a popup. Allow popups for this site, then try again.")
|
||||
return
|
||||
_send_action("checkout", "/web-checkouts", {"sku": sku})
|
||||
|
||||
|
||||
func apply_embedded_status(value: Variant) -> void:
|
||||
if typeof(value) != TYPE_DICTIONARY:
|
||||
return
|
||||
status = value.duplicate(true)
|
||||
status_changed.emit(status)
|
||||
|
||||
|
||||
func has_unlimited_access() -> bool:
|
||||
return bool(status.get("unlimited", false))
|
||||
|
||||
|
||||
func questions_remaining() -> int:
|
||||
return int(status.get("questionsRemaining", 20))
|
||||
|
||||
|
||||
func _send_action(action: String, path: String, payload: Dictionary) -> void:
|
||||
if pending_action != "":
|
||||
operation_failed.emit("Another access request is still in progress.")
|
||||
return
|
||||
if not AuthState.can_send_authenticated_request():
|
||||
operation_failed.emit("Log in before managing access.")
|
||||
return
|
||||
pending_action = action
|
||||
request_token = AuthState.access_token
|
||||
var error := action_request.request(
|
||||
"%s%s" % [api_url.rstrip("/"), path],
|
||||
PackedStringArray([
|
||||
"Content-Type: application/json",
|
||||
"Authorization: Bearer %s" % request_token
|
||||
]),
|
||||
HTTPClient.METHOD_POST,
|
||||
JSON.stringify(payload))
|
||||
if error != OK:
|
||||
pending_action = ""
|
||||
operation_failed.emit("The access request could not be started.")
|
||||
|
||||
|
||||
func _on_status_completed(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
|
||||
if response_code == 401:
|
||||
AuthState.handle_unauthorized(request_token)
|
||||
if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300:
|
||||
return
|
||||
var parsed: Variant = JSON.parse_string(body.get_string_from_utf8())
|
||||
apply_embedded_status(parsed)
|
||||
|
||||
|
||||
func _on_action_completed(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
|
||||
var action := pending_action
|
||||
pending_action = ""
|
||||
if response_code == 401:
|
||||
AuthState.handle_unauthorized(request_token)
|
||||
if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300:
|
||||
_close_browser_window()
|
||||
operation_failed.emit(_failure_message("Access request failed.", response_code, body))
|
||||
return
|
||||
var parsed: Variant = JSON.parse_string(body.get_string_from_utf8())
|
||||
if action == "checkout":
|
||||
if typeof(parsed) != TYPE_DICTIONARY:
|
||||
_close_browser_window()
|
||||
operation_failed.emit("Checkout returned an invalid response.")
|
||||
return
|
||||
active_purchase_id = str(parsed.get("purchaseId", ""))
|
||||
var checkout_url := str(parsed.get("checkoutUrl", ""))
|
||||
if active_purchase_id.is_empty() or not checkout_url.begins_with("https://"):
|
||||
_close_browser_window()
|
||||
operation_failed.emit("Checkout returned an invalid link.")
|
||||
return
|
||||
_open_checkout_url(checkout_url)
|
||||
checkout_started.emit(active_purchase_id, checkout_url)
|
||||
poll_count = 0
|
||||
poll_timer.start()
|
||||
_poll_purchase()
|
||||
return
|
||||
if typeof(parsed) == TYPE_DICTIONARY:
|
||||
apply_embedded_status(parsed)
|
||||
operation_succeeded.emit({
|
||||
"redeem": "Your 24-hour QBank pass is active.",
|
||||
"activate": "Your access token was activated.",
|
||||
"reset": "Your QBank has been reset for a new cycle."
|
||||
}.get(action, "Access updated."))
|
||||
|
||||
|
||||
func _poll_purchase() -> void:
|
||||
if active_purchase_id.is_empty() or not AuthState.can_send_authenticated_request():
|
||||
return
|
||||
if purchase_request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED:
|
||||
return
|
||||
poll_count += 1
|
||||
if poll_count > POLL_LIMIT:
|
||||
poll_timer.stop()
|
||||
return
|
||||
request_token = AuthState.access_token
|
||||
purchase_request.request(
|
||||
"%s/purchases/%s" % [api_url.rstrip("/"), active_purchase_id.uri_encode()],
|
||||
PackedStringArray(["Authorization: Bearer %s" % request_token]))
|
||||
|
||||
|
||||
func _on_purchase_status_completed(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
|
||||
if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300:
|
||||
return
|
||||
var parsed: Variant = JSON.parse_string(body.get_string_from_utf8())
|
||||
if typeof(parsed) != TYPE_DICTIONARY:
|
||||
return
|
||||
match str(parsed.get("status", "")):
|
||||
"paid":
|
||||
poll_timer.stop()
|
||||
_close_browser_window()
|
||||
active_purchase_id = ""
|
||||
operation_succeeded.emit("Purchase confirmed. Your token is ready to activate.")
|
||||
refresh()
|
||||
"failed", "expired", "refunded":
|
||||
poll_timer.stop()
|
||||
_close_browser_window()
|
||||
active_purchase_id = ""
|
||||
operation_failed.emit("The purchase was not completed.")
|
||||
|
||||
|
||||
func _request_node(callback: Callable) -> HTTPRequest:
|
||||
var node := HTTPRequest.new()
|
||||
node.timeout = REQUEST_TIMEOUT_SECONDS
|
||||
node.request_completed.connect(callback)
|
||||
add_child(node)
|
||||
return node
|
||||
|
||||
|
||||
func _operation_id(prefix: String) -> String:
|
||||
return "%s-%d-%d" % [prefix, Time.get_ticks_usec(), randi()]
|
||||
|
||||
|
||||
func _open_checkout_url(url: String) -> void:
|
||||
if OS.has_feature("web"):
|
||||
JavaScriptBridge.eval(
|
||||
"var ranazeUrl=%s;window.localStorage.setItem('%s',ranazeUrl);if(window.ranazeAccessWindow&&!window.ranazeAccessWindow.closed){window.ranazeAccessWindow.location.replace(ranazeUrl);window.ranazeAccessWindow.focus();}else{window.location.assign(ranazeUrl);}" %
|
||||
[JSON.stringify(url), CHECKOUT_STORAGE_KEY], true)
|
||||
else:
|
||||
OS.shell_open(url)
|
||||
|
||||
|
||||
func _prepare_browser_window() -> bool:
|
||||
checkout_popup_opened = bool(JavaScriptBridge.eval(
|
||||
"window.ranazeAccessWindow=window.open('about:blank','ranaze_access','popup=yes,width=620,height=760,resizable=yes,scrollbars=yes');window.ranazeAccessWindow!==null;",
|
||||
true))
|
||||
return checkout_popup_opened
|
||||
|
||||
|
||||
func _close_browser_window() -> void:
|
||||
if OS.has_feature("web") and checkout_popup_opened:
|
||||
JavaScriptBridge.eval(
|
||||
"window.localStorage.removeItem('%s');if(window.ranazeAccessWindow&&!window.ranazeAccessWindow.closed){window.ranazeAccessWindow.close();}window.ranazeAccessWindow=null;window.focus();" %
|
||||
CHECKOUT_STORAGE_KEY, true)
|
||||
checkout_popup_opened = false
|
||||
|
||||
|
||||
func _failure_message(prefix: String, response_code: int, body: PackedByteArray) -> String:
|
||||
var parsed: Variant = JSON.parse_string(body.get_string_from_utf8())
|
||||
if typeof(parsed) == TYPE_DICTIONARY:
|
||||
var message := str(parsed.get("message", parsed.get("detail", ""))).strip_edges()
|
||||
if not message.is_empty():
|
||||
return message
|
||||
return "%s Status %d." % [prefix, response_code]
|
||||
|
||||
|
||||
func _on_auth_changed() -> void:
|
||||
if AuthState.is_logged_in:
|
||||
refresh()
|
||||
else:
|
||||
status = {}
|
||||
active_purchase_id = ""
|
||||
poll_timer.stop()
|
||||
_close_browser_window()
|
||||
status_changed.emit(status)
|
||||
@@ -0,0 +1 @@
|
||||
uid://ch2scupdmgjpj
|
||||
@@ -28,10 +28,61 @@ 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:
|
||||
|
||||
@@ -19,6 +19,7 @@ signal question_rating_updated(question_id: String, question_elo: int, rating: D
|
||||
signal question_skipped(question_id: String, correct_choice_id: String, question_elo: int, rating: Dictionary)
|
||||
signal question_report_submitted(question_id: String, report_id: String)
|
||||
signal question_report_failed(question_id: String, message: String)
|
||||
signal access_required(message: String)
|
||||
|
||||
var questions: Array = []
|
||||
var request: HTTPRequest
|
||||
@@ -157,6 +158,14 @@ func _on_api_questions_loaded(result: int, response_code: int, _headers: PackedS
|
||||
if response_code == 401:
|
||||
AuthState.handle_unauthorized(questions_request_token)
|
||||
if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300:
|
||||
if response_code == 403:
|
||||
var denied: Variant = JSON.parse_string(body.get_string_from_utf8())
|
||||
if typeof(denied) == TYPE_DICTIONARY and str(denied.get("code", "")) == "qbank_limit_reached":
|
||||
AccessService.apply_embedded_status(denied.get("access", {}))
|
||||
var denied_message := str(denied.get("message", "Your free QBank allowance is exhausted."))
|
||||
_set_load_error(denied_message, false)
|
||||
access_required.emit(denied_message)
|
||||
return
|
||||
var message := "The connection to the question service was interrupted."
|
||||
if response_code > 0:
|
||||
message = "Questions API request failed with status %d." % response_code
|
||||
@@ -400,6 +409,7 @@ func _enqueue_attempt(kind: String, question_id: String, choice_id: String, pres
|
||||
"choice_id": choice_id,
|
||||
"presented_choice_ids": presented_choice_ids.duplicate(),
|
||||
"duration_seconds": maxi(0, duration_seconds),
|
||||
"completion_id": _new_completion_id(question_id),
|
||||
"retry_count": 0
|
||||
})
|
||||
_save_attempt_queue()
|
||||
@@ -421,6 +431,10 @@ func _process_attempt_queue() -> void:
|
||||
return
|
||||
|
||||
var queued: Dictionary = attempt_queue[active_attempt_index]
|
||||
if str(queued.get("completion_id", "")).is_empty():
|
||||
queued["completion_id"] = _new_completion_id(str(queued.get("question_id", "")))
|
||||
attempt_queue[active_attempt_index] = queued
|
||||
_save_attempt_queue()
|
||||
attempt_request_token = AuthState.access_token
|
||||
var headers := PackedStringArray([
|
||||
"Content-Type: application/json",
|
||||
@@ -429,13 +443,18 @@ func _process_attempt_queue() -> void:
|
||||
var question_id := str(queued.get("question_id", ""))
|
||||
var url := question_attempt_api_url
|
||||
var body := JSON.stringify({
|
||||
"completionId": str(queued.get("completion_id", "")),
|
||||
"questionExternalId": question_id,
|
||||
"choiceId": str(queued.get("choice_id", "")),
|
||||
"presentedChoiceIds": queued.get("presented_choice_ids", []),
|
||||
"durationSeconds": int(queued.get("duration_seconds", 0))
|
||||
})
|
||||
if str(queued.get("kind", "answer")) == "skip":
|
||||
url = "%s/%s/skip" % [question_api_base_url.rstrip("/"), question_id.uri_encode()]
|
||||
url = "%s/%s/skip?completionId=%s" % [
|
||||
question_api_base_url.rstrip("/"),
|
||||
question_id.uri_encode(),
|
||||
str(queued.get("completion_id", "")).uri_encode()
|
||||
]
|
||||
body = ""
|
||||
|
||||
attempt_request_active = true
|
||||
@@ -474,7 +493,14 @@ func _on_first_attempt_recorded(result: int, response_code: int, _headers: Packe
|
||||
if response_code == 401:
|
||||
AuthState.handle_unauthorized(attempt_request_token)
|
||||
active_attempt_index = -1
|
||||
elif response_code in [400, 403, 404]:
|
||||
elif response_code == 403:
|
||||
var denied: Variant = JSON.parse_string(body.get_string_from_utf8())
|
||||
if typeof(denied) == TYPE_DICTIONARY:
|
||||
AccessService.apply_embedded_status(denied.get("access", {}))
|
||||
access_required.emit(str(denied.get("message", "Your free QBank allowance is exhausted.")))
|
||||
_remove_active_attempt()
|
||||
_process_attempt_queue()
|
||||
elif response_code in [400, 404]:
|
||||
push_warning("Discarding invalid question attempt with status %d." % response_code)
|
||||
_remove_active_attempt()
|
||||
_process_attempt_queue()
|
||||
@@ -492,9 +518,14 @@ func _on_first_attempt_recorded(result: int, response_code: int, _headers: Packe
|
||||
_apply_skip_response(parsed)
|
||||
else:
|
||||
_apply_first_attempt_response(parsed)
|
||||
AccessService.apply_embedded_status(parsed.get("access", {}))
|
||||
_process_attempt_queue()
|
||||
|
||||
|
||||
func _new_completion_id(question_id: String) -> String:
|
||||
return "%s-%d-%d" % [question_id.sha256_text().left(12), Time.get_ticks_usec(), randi()]
|
||||
|
||||
|
||||
func _apply_first_attempt_response(parsed: Dictionary) -> void:
|
||||
|
||||
var question_id := str(parsed.get("questionExternalId", parsed.get("question_external_id", "")))
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
extends MarginContainer
|
||||
|
||||
const Brand := preload("res://scripts/ui/medmind_style.gd")
|
||||
|
||||
var main: Control
|
||||
var summary: Label
|
||||
var quota: Label
|
||||
var status_label: Label
|
||||
var token_labels: Dictionary = {}
|
||||
var action_buttons: Array[Button] = []
|
||||
var reset_dialog: ConfirmationDialog
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||
_build_ui()
|
||||
AccessService.status_changed.connect(_render)
|
||||
AccessService.operation_succeeded.connect(_on_success)
|
||||
AccessService.operation_failed.connect(_on_failure)
|
||||
AccessService.checkout_started.connect(_on_checkout_started)
|
||||
UIThemeService.theme_changed.connect(refresh_theme_controls)
|
||||
AccessService.refresh()
|
||||
|
||||
|
||||
func on_show(_payload: Variant = null) -> void:
|
||||
AccessService.refresh()
|
||||
_render(AccessService.status)
|
||||
|
||||
|
||||
func _build_ui() -> void:
|
||||
var scroll := ScrollContainer.new()
|
||||
scroll.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||
scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
|
||||
add_child(scroll)
|
||||
var page := VBoxContainer.new()
|
||||
page.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
page.add_theme_constant_override("separation", 20)
|
||||
scroll.add_child(page)
|
||||
|
||||
var title := Label.new()
|
||||
title.text = "Credits & Access"
|
||||
title.add_theme_font_override("font", Brand.font)
|
||||
title.add_theme_font_size_override("font_size", 36)
|
||||
page.add_child(title)
|
||||
var intro := Label.new()
|
||||
intro.text = "Study 20 QBank questions in a rolling 24-hour window. Eligible first attempts earn credits; 100 credits unlocks unlimited QBank access for 24 hours."
|
||||
intro.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
intro.add_theme_font_override("font", Brand.font)
|
||||
intro.add_theme_font_size_override("font_size", 17)
|
||||
page.add_child(intro)
|
||||
|
||||
var overview := _panel()
|
||||
page.add_child(overview)
|
||||
var overview_content := VBoxContainer.new()
|
||||
overview_content.add_theme_constant_override("separation", 10)
|
||||
overview.add_child(overview_content)
|
||||
summary = _label("", 24)
|
||||
quota = _label("", 17)
|
||||
overview_content.add_child(summary)
|
||||
overview_content.add_child(quota)
|
||||
var redeem := _button("Redeem 100 credits for 24 hours", AccessService.redeem_credits)
|
||||
overview_content.add_child(redeem)
|
||||
|
||||
var token_grid := GridContainer.new()
|
||||
token_grid.columns = 3
|
||||
token_grid.add_theme_constant_override("h_separation", 16)
|
||||
token_grid.add_theme_constant_override("v_separation", 16)
|
||||
page.add_child(token_grid)
|
||||
_add_access_card(token_grid, "day", "1 day", "$1.00", "24 hours")
|
||||
_add_access_card(token_grid, "week", "1 week", "$5.00", "168 hours")
|
||||
_add_access_card(token_grid, "month", "1 month", "$20.00", "30 days")
|
||||
|
||||
var extras := _panel()
|
||||
page.add_child(extras)
|
||||
var extras_content := VBoxContainer.new()
|
||||
extras_content.add_theme_constant_override("separation", 12)
|
||||
extras.add_child(extras_content)
|
||||
extras_content.add_child(_label("Study tools", 24))
|
||||
token_labels["exam"] = _label("", 16)
|
||||
extras_content.add_child(token_labels["exam"])
|
||||
extras_content.add_child(_button("Buy exam simulation token — $1.99",
|
||||
func(): AccessService.begin_checkout("exam_token_1")))
|
||||
token_labels["reset"] = _label("", 16)
|
||||
extras_content.add_child(token_labels["reset"])
|
||||
extras_content.add_child(_button("Buy QBank reset token — $4.99",
|
||||
func(): AccessService.begin_checkout("qbank_reset_1")))
|
||||
extras_content.add_child(_button("Use QBank reset token", _confirm_reset))
|
||||
|
||||
status_label = _label("", 15)
|
||||
status_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
page.add_child(status_label)
|
||||
|
||||
reset_dialog = ConfirmationDialog.new()
|
||||
reset_dialog.title = "Reset QBank progress?"
|
||||
reset_dialog.dialog_text = "This starts a new QBank cycle. Questions become unused and credit-eligible again. Historical aggregate samples, bookmarks, notes, and review content remain."
|
||||
reset_dialog.ok_button_text = "Reset QBank"
|
||||
reset_dialog.confirmed.connect(AccessService.reset_qbank)
|
||||
add_child(reset_dialog)
|
||||
refresh_theme_controls()
|
||||
|
||||
|
||||
func _add_access_card(parent: GridContainer, token: String, heading: String, price: String, duration: String) -> void:
|
||||
var panel := _panel()
|
||||
panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
parent.add_child(panel)
|
||||
var content := VBoxContainer.new()
|
||||
content.add_theme_constant_override("separation", 10)
|
||||
panel.add_child(content)
|
||||
content.add_child(_label(heading, 24))
|
||||
content.add_child(_label("%s of unlimited QBank access" % duration, 15))
|
||||
token_labels[token] = _label("", 15)
|
||||
content.add_child(token_labels[token])
|
||||
content.add_child(_button("Buy token — %s" % price,
|
||||
func(): AccessService.begin_checkout("access_%s_1" % token)))
|
||||
content.add_child(_button("Activate token",
|
||||
func(): AccessService.activate_token(token)))
|
||||
|
||||
|
||||
func _render(value: Dictionary) -> void:
|
||||
if summary == null:
|
||||
return
|
||||
var credits := int(value.get("credits", 0))
|
||||
var unlimited := bool(value.get("unlimited", false))
|
||||
var expiry := str(value.get("unlimitedUntilUtc", ""))
|
||||
summary.text = "%d credits • %s" % [
|
||||
credits,
|
||||
("Unlimited QBank active until %s" % expiry) if unlimited else "Free QBank access"
|
||||
]
|
||||
quota.text = "%d of %d free questions remaining in the rolling window" % [
|
||||
int(value.get("questionsRemaining", 20)),
|
||||
int(value.get("freeQuestionLimit", 20))
|
||||
]
|
||||
token_labels.get("day", _label()).text = "%d available" % int(value.get("dayTokens", 0))
|
||||
token_labels.get("week", _label()).text = "%d available" % int(value.get("weekTokens", 0))
|
||||
token_labels.get("month", _label()).text = "%d available" % int(value.get("monthTokens", 0))
|
||||
token_labels.get("exam", _label()).text = "Exam tokens: %d" % int(value.get("examTokens", 0))
|
||||
token_labels.get("reset", _label()).text = "QBank reset tokens: %d" % int(value.get("resetTokens", 0))
|
||||
|
||||
|
||||
func _confirm_reset() -> void:
|
||||
reset_dialog.popup_centered(Vector2i(620, 260))
|
||||
|
||||
|
||||
func _on_success(message: String) -> void:
|
||||
status_label.text = message
|
||||
status_label.add_theme_color_override("font_color", Brand.SUCCESS)
|
||||
|
||||
|
||||
func _on_failure(message: String) -> void:
|
||||
status_label.text = message
|
||||
status_label.add_theme_color_override("font_color", Color("ef4444"))
|
||||
|
||||
|
||||
func _on_checkout_started(_purchase_id: String, _url: String) -> void:
|
||||
status_label.text = "Complete checkout in the secure payment window."
|
||||
|
||||
|
||||
func refresh_theme_controls() -> void:
|
||||
for button in action_buttons:
|
||||
var dark := UIThemeService.is_dark_mode()
|
||||
button.add_theme_font_override("font", Brand.font)
|
||||
button.add_theme_color_override("font_color", Brand.text_color(dark))
|
||||
button.add_theme_stylebox_override("normal", Brand.secondary_button_box(dark))
|
||||
button.add_theme_stylebox_override("hover", Brand.secondary_button_box(dark, true))
|
||||
button.add_theme_stylebox_override("pressed", Brand.secondary_button_box(dark, true))
|
||||
|
||||
|
||||
func _panel() -> PanelContainer:
|
||||
var panel := PanelContainer.new()
|
||||
panel.add_theme_stylebox_override("panel", Brand.panel_box(UIThemeService.is_dark_mode()))
|
||||
return panel
|
||||
|
||||
|
||||
func _label(text := "", size := 16) -> Label:
|
||||
var label := Label.new()
|
||||
label.text = text
|
||||
label.add_theme_font_override("font", Brand.font)
|
||||
label.add_theme_font_size_override("font_size", size)
|
||||
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
return label
|
||||
|
||||
|
||||
func _button(text: String, callback: Callable) -> Button:
|
||||
var button := Button.new()
|
||||
button.text = text
|
||||
button.custom_minimum_size = Vector2(0, 48)
|
||||
button.pressed.connect(callback)
|
||||
action_buttons.append(button)
|
||||
return button
|
||||
@@ -0,0 +1 @@
|
||||
uid://bgkgwx6ds6cjn
|
||||
@@ -0,0 +1,13 @@
|
||||
extends Control
|
||||
|
||||
var ring_color := Color.WHITE
|
||||
|
||||
|
||||
func set_ring_color(next_color: Color) -> void:
|
||||
ring_color = next_color
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
var radius: float = minf(size.x, size.y) * 0.5 - 1.0
|
||||
draw_arc(size * 0.5, radius, 0.0, TAU, 48, ring_color, 1.2, true)
|
||||
@@ -0,0 +1 @@
|
||||
uid://cnnscljyarxaa
|
||||
+178
-51
@@ -3,12 +3,15 @@ extends Control
|
||||
const Brand := preload("res://scripts/ui/medmind_style.gd")
|
||||
const TrendChart := preload("res://scripts/ui/dashboard_chart.gd")
|
||||
const ActivityChart := preload("res://scripts/ui/dashboard_activity_chart.gd")
|
||||
const DashboardSparkline := preload("res://scripts/ui/dashboard_sparkline.gd")
|
||||
const DashboardSegmentBar := preload("res://scripts/ui/dashboard_segment_bar.gd")
|
||||
const DashboardInfoBadge := preload("res://scripts/ui/dashboard_info_badge.gd")
|
||||
|
||||
var main: Control
|
||||
var scroll: ScrollContainer
|
||||
var content: VBoxContainer
|
||||
var status_label: Label
|
||||
var metric_grid: GridContainer
|
||||
var stat_card_grid: GridContainer
|
||||
var analytics_grid: GridContainer
|
||||
var lower_grid: GridContainer
|
||||
var trend_chart: Control
|
||||
@@ -22,6 +25,11 @@ var recent_list: VBoxContainer
|
||||
var weekly_summary: VBoxContainer
|
||||
var metric_values: Dictionary = {}
|
||||
var metric_details: Dictionary = {}
|
||||
var metric_trends: Dictionary = {}
|
||||
var qbank_sparkline: Control
|
||||
var ccs_sparkline: Control
|
||||
var readiness_segment_bar: Control
|
||||
var accuracy_progress_bar: ProgressBar
|
||||
var pending_review_launch := false
|
||||
var review_queue_service: Variant
|
||||
var trend_mode := "rating"
|
||||
@@ -54,6 +62,7 @@ func _build() -> void:
|
||||
child.free()
|
||||
metric_values.clear()
|
||||
metric_details.clear()
|
||||
metric_trends.clear()
|
||||
scroll = ScrollContainer.new()
|
||||
scroll.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||
scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
|
||||
@@ -66,15 +75,35 @@ func _build() -> void:
|
||||
status_label = _label("", 14, Brand.muted_color(UIThemeService.is_dark_mode()))
|
||||
status_label.visible = false
|
||||
content.add_child(status_label)
|
||||
metric_grid = GridContainer.new()
|
||||
metric_grid.columns = 4
|
||||
metric_grid.add_theme_constant_override("h_separation", 0)
|
||||
metric_grid.add_theme_constant_override("v_separation", 8)
|
||||
content.add_child(_card(metric_grid, 12))
|
||||
_add_metric("qbank", "Question Bank Rating", "chart-no-axes-column-increasing", Brand.ROYAL_BLUE)
|
||||
_add_metric("ccs", "CCS Rating", "stethoscope", Brand.PURPLE)
|
||||
_add_metric("accuracy", "First-Attempt Accuracy", "activity", Brand.ROYAL_BLUE)
|
||||
_add_metric("time", "Study Time", "clock-3", Brand.PURPLE)
|
||||
stat_card_grid = GridContainer.new()
|
||||
stat_card_grid.columns = 4
|
||||
stat_card_grid.add_theme_constant_override("h_separation", 12)
|
||||
stat_card_grid.add_theme_constant_override("v_separation", 12)
|
||||
content.add_child(stat_card_grid)
|
||||
|
||||
qbank_sparkline = DashboardSparkline.new()
|
||||
qbank_sparkline.custom_minimum_size = Vector2(0, 34)
|
||||
_add_stat_card("qbank", "Question Bank Rating", "book-open", Brand.ROYAL_BLUE, qbank_sparkline,
|
||||
"Your Elo-style rating from Question Bank answers, weighted by each question's difficulty.")
|
||||
|
||||
ccs_sparkline = DashboardSparkline.new()
|
||||
ccs_sparkline.custom_minimum_size = Vector2(0, 34)
|
||||
_add_stat_card("ccs", "CCS Rating", "stethoscope", Brand.SUCCESS, ccs_sparkline,
|
||||
"Your Elo-style rating from timed CCS case performance, weighted by each case's difficulty.")
|
||||
|
||||
readiness_segment_bar = DashboardSegmentBar.new()
|
||||
readiness_segment_bar.custom_minimum_size = Vector2(0, 10)
|
||||
_add_stat_card("readiness", "Exam Readiness", "chart-no-axes-column-increasing", Brand.PURPLE, readiness_segment_bar,
|
||||
"Predicted pass probability from your most recent completed exam simulation.")
|
||||
|
||||
accuracy_progress_bar = ProgressBar.new()
|
||||
accuracy_progress_bar.show_percentage = false
|
||||
accuracy_progress_bar.min_value = 0.0
|
||||
accuracy_progress_bar.max_value = 100.0
|
||||
accuracy_progress_bar.custom_minimum_size = Vector2(0, 10)
|
||||
_style_progress(accuracy_progress_bar, Brand.AMBER)
|
||||
_add_stat_card("accuracy", "Average Score", "star", Brand.AMBER, accuracy_progress_bar,
|
||||
"Your first-attempt accuracy across your most recent 100 answered questions.")
|
||||
|
||||
analytics_grid = GridContainer.new()
|
||||
analytics_grid.columns = 2
|
||||
@@ -163,10 +192,15 @@ func _rebuild(_is_dark: bool = UIThemeService.is_dark_mode()) -> void:
|
||||
func _render() -> void:
|
||||
var data: Dictionary = DashboardService.data
|
||||
status_label.visible = false
|
||||
_set_metric("qbank", str(int(data.get("questionBankRating", 1000))), "%d rated questions" % int(data.get("ratedQuestionCount", 0)))
|
||||
_set_metric("ccs", str(int(data.get("ccsRating", 1000))), "%d rated cases" % int(data.get("ratedCcsCaseCount", 0)))
|
||||
_set_metric("accuracy", "%d%%" % int(data.get("firstAttemptAccuracy", 0)), "Last %d questions" % int(data.get("accuracySampleSize", 0)))
|
||||
_set_metric("time", _format_duration(int(data.get("studyTimeSecondsThisWeek", 0))), "This week")
|
||||
var trend: Array = data.get("performanceTrend", [])
|
||||
_render_rating_card("qbank", qbank_sparkline, Brand.ROYAL_BLUE, data.get("questionBankRating", 1000),
|
||||
data.get("questionBankRatingDeltaThisWeek"), data.get("questionBankPercentile"), "%d rated questions" % int(data.get("ratedQuestionCount", 0)),
|
||||
_trend_series(trend, "questionBankRating"))
|
||||
_render_rating_card("ccs", ccs_sparkline, Brand.SUCCESS, data.get("ccsRating", 1000),
|
||||
data.get("ccsRatingDeltaThisWeek"), data.get("ccsPercentile"), "%d rated cases" % int(data.get("ratedCcsCaseCount", 0)),
|
||||
_trend_series(trend, "ccsRating"))
|
||||
_render_readiness_card(data)
|
||||
_render_accuracy_card(data)
|
||||
trend_chart.set_series(data.get("performanceTrend", []), trend_mode, UIThemeService.is_dark_mode())
|
||||
_render_systems(data.get("systems", []))
|
||||
_render_focus(data.get("systems", []))
|
||||
@@ -177,6 +211,71 @@ func _render() -> void:
|
||||
_render_recent(data.get("recentActivity", []))
|
||||
|
||||
|
||||
func _trend_series(trend: Array, field: String) -> Array:
|
||||
var series: Array = []
|
||||
for point in trend:
|
||||
var value = point.get(field)
|
||||
if value != null:
|
||||
series.append(float(value))
|
||||
return series
|
||||
|
||||
|
||||
func _render_rating_card(key: String, sparkline: Control, accent: Color, rating_value: Variant, delta_value: Variant, percentile_value: Variant, fallback_caption: String, series: Array) -> void:
|
||||
var dark := UIThemeService.is_dark_mode()
|
||||
var rating := int(rating_value)
|
||||
var trend_text := ""
|
||||
var trend_color := Brand.muted_color(dark)
|
||||
var caption := fallback_caption
|
||||
if delta_value != null:
|
||||
var delta := int(delta_value)
|
||||
var arrow := "↗" if delta >= 0 else "↘"
|
||||
var sign := "+" if delta >= 0 else ""
|
||||
trend_text = "%s %s%d this week" % [arrow, sign, delta]
|
||||
trend_color = Brand.SUCCESS if delta >= 0 else Brand.MAGENTA
|
||||
if percentile_value != null:
|
||||
caption = "Top %d%% of users" % int(percentile_value)
|
||||
_set_stat_card(key, str(rating), trend_text, trend_color, caption)
|
||||
if series.size() < 2:
|
||||
sparkline.set_values([float(rating)], accent, Vector2(rating - 10.0, rating + 10.0))
|
||||
else:
|
||||
var low: float = series.min()
|
||||
var high: float = series.max()
|
||||
var spread := maxf(10.0, (high - low) * 0.5)
|
||||
sparkline.set_values(series, accent, Vector2(low - spread, high + spread))
|
||||
|
||||
|
||||
func _render_readiness_card(data: Dictionary) -> void:
|
||||
var dark := UIThemeService.is_dark_mode()
|
||||
var label_value = data.get("examReadinessLabel")
|
||||
if label_value == null:
|
||||
_set_stat_card("readiness", "--", "", Brand.muted_color(dark), "Complete a simulation to see this")
|
||||
readiness_segment_bar.set_progress(0, 4, Brand.muted_color(dark), dark)
|
||||
return
|
||||
var label := str(label_value)
|
||||
var pass_percent := int(data.get("examReadinessProjectedPassPercent", 0))
|
||||
var status_color := Brand.SUCCESS if label == "High" else (Brand.AMBER if label == "Medium" else Brand.MAGENTA)
|
||||
var caption := "You're on track!" if label != "Low" else "Keep practicing"
|
||||
_set_stat_card("readiness", label, "Projected pass: %d%%" % pass_percent, status_color, caption)
|
||||
(metric_values.get("readiness") as Label).add_theme_color_override("font_color", status_color)
|
||||
readiness_segment_bar.set_progress(int(round(pass_percent / 100.0 * 4.0)), 4, status_color, dark)
|
||||
|
||||
|
||||
func _render_accuracy_card(data: Dictionary) -> void:
|
||||
var dark := UIThemeService.is_dark_mode()
|
||||
var accuracy := int(data.get("firstAttemptAccuracy", 0))
|
||||
var delta_value = data.get("accuracyDeltaVsPreviousSample")
|
||||
var trend_text := ""
|
||||
var trend_color := Brand.muted_color(dark)
|
||||
if delta_value != null:
|
||||
var delta := int(delta_value)
|
||||
var arrow := "↗" if delta >= 0 else "↘"
|
||||
var sign := "+" if delta >= 0 else ""
|
||||
trend_text = "%s %s%d%% vs last 100" % [arrow, sign, delta]
|
||||
trend_color = Brand.SUCCESS if delta >= 0 else Brand.MAGENTA
|
||||
_set_stat_card("accuracy", "%d%%" % accuracy, trend_text, trend_color, "Last %d questions" % int(data.get("accuracySampleSize", 0)))
|
||||
accuracy_progress_bar.value = accuracy
|
||||
|
||||
|
||||
func _render_systems(systems_value: Array) -> void:
|
||||
_clear(system_list)
|
||||
var systems := systems_value.duplicate(true)
|
||||
@@ -268,14 +367,16 @@ 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)
|
||||
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"))
|
||||
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"))
|
||||
if Brand.is_uat_environment():
|
||||
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"))
|
||||
|
||||
@@ -319,38 +420,70 @@ func _render_recent(items: Array) -> void:
|
||||
recent_list.add_child(row)
|
||||
|
||||
|
||||
func _add_metric(key: String, title: String, icon_name: String, accent: Color) -> void:
|
||||
var cell := HBoxContainer.new()
|
||||
cell.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
cell.custom_minimum_size = Vector2(230, 96)
|
||||
cell.add_theme_constant_override("separation", 12)
|
||||
func _info_badge(dark: bool, tooltip: String) -> Control:
|
||||
var badge := DashboardInfoBadge.new()
|
||||
badge.custom_minimum_size = Vector2(16, 16)
|
||||
badge.size_flags_vertical = Control.SIZE_SHRINK_BEGIN
|
||||
badge.mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
badge.tooltip_text = tooltip
|
||||
badge.set_ring_color(Brand.muted_color(dark))
|
||||
var glyph := _label("i", 10, Brand.muted_color(dark), true)
|
||||
glyph.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||
glyph.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
glyph.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||||
glyph.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
badge.add_child(glyph)
|
||||
return badge
|
||||
|
||||
|
||||
func _add_stat_card(key: String, title: String, icon_name: String, accent: Color, bottom_visual: Control, info_tooltip: String) -> void:
|
||||
var dark := UIThemeService.is_dark_mode()
|
||||
var card_content := VBoxContainer.new()
|
||||
card_content.custom_minimum_size = Vector2(220, 0)
|
||||
card_content.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
card_content.add_theme_constant_override("separation", 8)
|
||||
|
||||
var header := HBoxContainer.new()
|
||||
header.add_theme_constant_override("separation", 10)
|
||||
card_content.add_child(header)
|
||||
var icon_panel := PanelContainer.new()
|
||||
icon_panel.custom_minimum_size = Vector2(48, 48)
|
||||
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(UIThemeService.is_dark_mode(), accent, 24))
|
||||
icon_panel.add_theme_stylebox_override("panel", Brand.icon_circle_box(dark, accent, 20, true))
|
||||
var icon := TextureRect.new()
|
||||
icon.texture = load("res://assets/icons/lucide/%s.svg" % icon_name)
|
||||
icon.custom_minimum_size = Vector2(22, 22)
|
||||
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)
|
||||
cell.add_child(icon_panel)
|
||||
var copy := VBoxContainer.new()
|
||||
copy.size_flags_vertical = Control.SIZE_SHRINK_CENTER
|
||||
copy.add_child(_label(title, 15, Brand.text_color(UIThemeService.is_dark_mode())))
|
||||
var value := _label("--", 29, Brand.text_color(UIThemeService.is_dark_mode()), true)
|
||||
copy.add_child(value)
|
||||
var detail := _label("Loading", 13, Brand.muted_color(UIThemeService.is_dark_mode()))
|
||||
copy.add_child(detail)
|
||||
header.add_child(icon_panel)
|
||||
var title_label := _label(title, 14, Brand.text_color(dark))
|
||||
title_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
title_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
header.add_child(title_label)
|
||||
header.add_child(_info_badge(dark, info_tooltip))
|
||||
|
||||
var value := _label("--", 28, Brand.text_color(dark), true)
|
||||
card_content.add_child(value)
|
||||
var trend := _label("", 13, Brand.muted_color(dark))
|
||||
card_content.add_child(trend)
|
||||
card_content.add_child(bottom_visual)
|
||||
var caption := _label("Loading", 12, Brand.muted_color(dark))
|
||||
card_content.add_child(caption)
|
||||
|
||||
metric_values[key] = value
|
||||
metric_details[key] = detail
|
||||
cell.add_child(copy)
|
||||
metric_grid.add_child(cell)
|
||||
metric_trends[key] = trend
|
||||
metric_details[key] = caption
|
||||
stat_card_grid.add_child(_card(card_content, 16))
|
||||
|
||||
|
||||
func _set_metric(key: String, value: String, detail: String) -> void:
|
||||
func _set_stat_card(key: String, value: String, trend_text: String, trend_color: Color, caption: String) -> void:
|
||||
(metric_values.get(key) as Label).text = value
|
||||
(metric_details.get(key) as Label).text = detail
|
||||
var trend_label: Label = metric_trends.get(key)
|
||||
if trend_label != null:
|
||||
trend_label.text = trend_text
|
||||
trend_label.add_theme_color_override("font_color", trend_color)
|
||||
(metric_details.get(key) as Label).text = caption
|
||||
|
||||
|
||||
func _review_action(text: String, icon_name: String, accent: Color, route: String) -> Button:
|
||||
@@ -532,10 +665,10 @@ func _show_error(message: String) -> void:
|
||||
|
||||
|
||||
func _update_responsive() -> void:
|
||||
if metric_grid == null:
|
||||
if stat_card_grid == null:
|
||||
return
|
||||
var compact := size.x < 1050.0
|
||||
metric_grid.columns = 2 if compact else 4
|
||||
stat_card_grid.columns = 2 if compact else 4
|
||||
analytics_grid.columns = 1 if compact else 2
|
||||
lower_grid.columns = 1 if compact else 2
|
||||
if trend_chart != null:
|
||||
@@ -548,12 +681,6 @@ func _clear(container: Container) -> void:
|
||||
child.free()
|
||||
|
||||
|
||||
func _format_duration(total_seconds: int) -> String:
|
||||
var hours := int(floor(float(total_seconds) / 3600.0))
|
||||
var minutes := int(floor(float(total_seconds % 3600) / 60.0))
|
||||
return "%dh %02dm" % [hours, minutes]
|
||||
|
||||
|
||||
func _relative_time(iso_value: String) -> String:
|
||||
var cleaned := iso_value.trim_suffix("Z")
|
||||
var occurred := Time.get_unix_time_from_datetime_string(cleaned)
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
extends Control
|
||||
|
||||
const Brand := preload("res://scripts/ui/medmind_style.gd")
|
||||
const GAP := 6.0
|
||||
const CORNER_RADIUS := 4
|
||||
|
||||
var filled_segments := 0
|
||||
var total_segments := 4
|
||||
var accent := Color.WHITE
|
||||
var dark := true
|
||||
|
||||
|
||||
func set_progress(next_filled: int, next_total: int, next_accent: Color, dark_mode: bool) -> void:
|
||||
filled_segments = next_filled
|
||||
total_segments = maxi(1, next_total)
|
||||
accent = next_accent
|
||||
dark = dark_mode
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
var segment_width: float = (size.x - GAP * float(total_segments - 1)) / float(total_segments)
|
||||
if segment_width <= 0.0:
|
||||
return
|
||||
var filled_box := StyleBoxFlat.new()
|
||||
filled_box.bg_color = accent
|
||||
filled_box.set_corner_radius_all(CORNER_RADIUS)
|
||||
var track_box := StyleBoxFlat.new()
|
||||
track_box.bg_color = Brand.muted_color(dark).lerp(Brand.background_color(dark), 0.5)
|
||||
track_box.set_corner_radius_all(CORNER_RADIUS)
|
||||
for index in range(total_segments):
|
||||
var x: float = index * (segment_width + GAP)
|
||||
var rect := Rect2(x, 0.0, segment_width, size.y)
|
||||
draw_style_box(filled_box if index < filled_segments else track_box, rect)
|
||||
@@ -0,0 +1 @@
|
||||
uid://duq4hsl7b72g2
|
||||
@@ -0,0 +1,29 @@
|
||||
extends Control
|
||||
|
||||
var values: PackedFloat32Array = []
|
||||
var accent := Color.WHITE
|
||||
var value_range := Vector2(0.0, 100.0)
|
||||
|
||||
|
||||
func set_values(next_values: Array, next_accent: Color, next_range := Vector2(0.0, 100.0)) -> void:
|
||||
values = PackedFloat32Array(next_values)
|
||||
accent = next_accent
|
||||
value_range = next_range
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
if values.size() < 2:
|
||||
if values.size() == 1:
|
||||
draw_circle(Vector2(size.x - 4.0, size.y * 0.5), 3.0, accent)
|
||||
return
|
||||
var span: float = maxf(0.0001, value_range.y - value_range.x)
|
||||
var points := PackedVector2Array()
|
||||
var count: int = values.size()
|
||||
for index in range(count):
|
||||
var x: float = lerpf(0.0, size.x, float(index) / float(count - 1))
|
||||
var ratio: float = clampf((values[index] - value_range.x) / span, 0.0, 1.0)
|
||||
var y: float = lerpf(size.y, 0.0, ratio)
|
||||
points.append(Vector2(x, y))
|
||||
draw_polyline(points, accent, 2.0, true)
|
||||
draw_circle(points[-1], 3.0, accent)
|
||||
@@ -0,0 +1 @@
|
||||
uid://4rtqkgrljvw5
|
||||
@@ -79,7 +79,7 @@ func _build_ui() -> void:
|
||||
mission.add_theme_constant_override("separation", 18)
|
||||
mission_panel.add_child(mission)
|
||||
mission.add_child(_heading("Our mission", 26))
|
||||
mission.add_child(_body("RanaZe exists to make rigorous, clinically grounded medical education more accessible. We are building an independent Step 3 study experience that helps learners develop clinical judgment through original questions, realistic CCS cases, transparent explanations, and continuous improvement driven by learner feedback."))
|
||||
mission.add_child(_body("RanaZe exists to make rigorous, clinically grounded Step 3 prep accessible to every test taker — not another subscription funnel built to profit off people who already have too little time and money to spare. We run at cost: your support covers hosting and content development, nothing more. No investors, no upsells — just an independent tool built to stay in your corner through Step 3."))
|
||||
mission.add_child(HSeparator.new())
|
||||
mission.add_child(_heading("What your support makes possible", 20))
|
||||
for item_text in [
|
||||
|
||||
+139
-34
@@ -25,7 +25,9 @@ var reset_view: VBoxContainer
|
||||
var steam_view: VBoxContainer
|
||||
var login_input: LineEdit
|
||||
var password_input: LineEdit
|
||||
var password_reveal_button: Button
|
||||
var password_reveal_entries: Array[Dictionary] = []
|
||||
var legal_consent_texts: Array[RichTextLabel] = []
|
||||
var legal_consent_squares: Array[Panel] = []
|
||||
var registration_username_input: LineEdit
|
||||
var registration_email_input: LineEdit
|
||||
var registration_password_input: LineEdit
|
||||
@@ -40,6 +42,7 @@ var steam_action: OptionButton
|
||||
var steam_username_input: LineEdit
|
||||
var steam_email_input: LineEdit
|
||||
var steam_password_input: LineEdit
|
||||
var steam_confirm_input: LineEdit
|
||||
var steam_legal_check: CheckBox
|
||||
var external_buttons: Dictionary = {}
|
||||
var panel_gutter: MarginContainer
|
||||
@@ -124,8 +127,8 @@ func _build_ui() -> void:
|
||||
page_margin.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||
page_margin.add_theme_constant_override("margin_left", 52)
|
||||
page_margin.add_theme_constant_override("margin_right", 52)
|
||||
page_margin.add_theme_constant_override("margin_top", 56)
|
||||
page_margin.add_theme_constant_override("margin_bottom", 56)
|
||||
page_margin.add_theme_constant_override("margin_top", 28)
|
||||
page_margin.add_theme_constant_override("margin_bottom", 28)
|
||||
add_child(page_margin)
|
||||
|
||||
var columns := HBoxContainer.new()
|
||||
@@ -189,23 +192,34 @@ func _build_ui() -> void:
|
||||
panel_center.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||
columns.add_child(panel_center)
|
||||
auth_panel = PanelContainer.new()
|
||||
auth_panel.custom_minimum_size = Vector2(710, 820)
|
||||
auth_panel.custom_minimum_size = Vector2(710, 1000)
|
||||
panel_center.add_child(auth_panel)
|
||||
var scroll := ScrollContainer.new()
|
||||
scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
|
||||
auth_panel.add_child(scroll)
|
||||
panel_gutter = MarginContainer.new()
|
||||
panel_gutter.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
panel_gutter.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||
panel_gutter.add_theme_constant_override("margin_right", 28)
|
||||
scroll.add_child(panel_gutter)
|
||||
panel_content = VBoxContainer.new()
|
||||
panel_content.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
panel_content.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||
panel_content.add_theme_constant_override("separation", 24)
|
||||
panel_gutter.add_child(panel_content)
|
||||
|
||||
# main_column holds everything except the legal footer and is the part
|
||||
# that should center vertically when a view (e.g. login) is shorter than
|
||||
# the panel - the footer stays pinned to the very bottom either way.
|
||||
var main_column := VBoxContainer.new()
|
||||
main_column.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||
main_column.alignment = BoxContainer.ALIGNMENT_CENTER
|
||||
main_column.add_theme_constant_override("separation", 16)
|
||||
panel_content.add_child(main_column)
|
||||
|
||||
var panel_header := HBoxContainer.new()
|
||||
panel_header.add_theme_constant_override("separation", 18)
|
||||
panel_content.add_child(panel_header)
|
||||
main_column.add_child(panel_header)
|
||||
var small_icon := TextureRect.new()
|
||||
small_icon.texture = Brand.ranaze_mark_texture()
|
||||
small_icon.custom_minimum_size = Vector2(72, 72)
|
||||
@@ -229,14 +243,14 @@ func _build_ui() -> void:
|
||||
reset_view = _build_reset_view()
|
||||
steam_view = _build_steam_view()
|
||||
for view in [login_view, register_view, reset_view, steam_view]:
|
||||
panel_content.add_child(view)
|
||||
main_column.add_child(view)
|
||||
|
||||
message_label = Label.new()
|
||||
message_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
message_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
message_label.add_theme_font_size_override("font_size", 14)
|
||||
message_label.add_theme_font_override("font", Brand.font)
|
||||
panel_content.add_child(message_label)
|
||||
main_column.add_child(message_label)
|
||||
var legal_footer := HBoxContainer.new()
|
||||
legal_footer.alignment = BoxContainer.ALIGNMENT_CENTER
|
||||
legal_footer.add_theme_constant_override("separation", 12)
|
||||
@@ -284,7 +298,8 @@ func _build_register_view() -> VBoxContainer:
|
||||
registration_email_input = _add_field(view, "Email", "[email protected]")
|
||||
registration_password_input = _add_field(view, "Password", "At least 8 characters", true)
|
||||
registration_confirm_input = _add_field(view, "Confirm password", "Re-enter your password", true)
|
||||
_add_show_password(view, [registration_password_input, registration_confirm_input])
|
||||
_add_password_reveal(registration_password_input)
|
||||
_add_password_reveal(registration_confirm_input)
|
||||
registration_legal_check = _add_legal_consent(view)
|
||||
_add_primary_button(view, "Create Account", _on_register_pressed)
|
||||
_add_back_button(view, "Already have an account? Sign in", "login")
|
||||
@@ -302,7 +317,8 @@ func _build_reset_view() -> VBoxContainer:
|
||||
reset_code_input.max_length = 6
|
||||
reset_password_input = _add_field(reset_confirm_group, "New password", "At least 8 characters", true)
|
||||
reset_confirm_input = _add_field(reset_confirm_group, "Confirm new password", "Re-enter your new password", true)
|
||||
_add_show_password(reset_confirm_group, [reset_password_input, reset_confirm_input])
|
||||
_add_password_reveal(reset_password_input)
|
||||
_add_password_reveal(reset_confirm_input)
|
||||
_add_primary_button(reset_confirm_group, "Update Password", _confirm_reset)
|
||||
_add_back_button(view, "Back to sign in", "login")
|
||||
reset_confirm_group.visible = false
|
||||
@@ -323,7 +339,9 @@ func _build_steam_view() -> VBoxContainer:
|
||||
steam_username_input = _add_field(view, "Username or email", "Choose a username")
|
||||
steam_email_input = _add_field(view, "Email", "[email protected]")
|
||||
steam_password_input = _add_field(view, "Password", "At least 8 characters", true)
|
||||
_add_show_password(view, [steam_password_input])
|
||||
steam_confirm_input = _add_field(view, "Confirm password", "Re-enter your password", true)
|
||||
_add_password_reveal(steam_password_input)
|
||||
_add_password_reveal(steam_confirm_input)
|
||||
steam_legal_check = _add_legal_consent(view)
|
||||
_add_primary_button(view, "Continue", _complete_steam)
|
||||
_add_back_button(view, "Cancel external sign in", "login")
|
||||
@@ -331,17 +349,84 @@ func _build_steam_view() -> VBoxContainer:
|
||||
|
||||
|
||||
func _add_legal_consent(parent: VBoxContainer) -> CheckBox:
|
||||
# A bare CheckBox's own word-wrap left-aligns every wrapped line flush
|
||||
# with the checkbox glyph, so a second line reads as un-indented. Split
|
||||
# the toggle from its text into a row instead - the RichTextLabel's own
|
||||
# wrapping then stays confined to its column, and "Terms of Service"/
|
||||
# "Privacy Policy" render as inline links rather than a duplicate row
|
||||
# of link buttons underneath.
|
||||
#
|
||||
# The actual toggle state lives on a real CheckBox (kept invisible and
|
||||
# out of the row's layout) because other code reads .button_pressed -
|
||||
# but Button/CheckBox reserves vertical space for a text baseline even
|
||||
# with an empty string, which was making the visible square non-square
|
||||
# no matter what custom_minimum_size said. Drawing the visible square
|
||||
# as a plain Panel sidesteps that entirely.
|
||||
var check := CheckBox.new()
|
||||
check.text = "I agree to the Terms of Service and acknowledge the Privacy Policy."
|
||||
check.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
check.visible = false
|
||||
parent.add_child(check)
|
||||
var links := HBoxContainer.new()
|
||||
links.add_theme_constant_override("separation", 12)
|
||||
parent.add_child(links)
|
||||
_add_legal_links(links)
|
||||
|
||||
var margin := MarginContainer.new()
|
||||
margin.add_theme_constant_override("margin_top", 8)
|
||||
margin.add_theme_constant_override("margin_bottom", 8)
|
||||
parent.add_child(margin)
|
||||
var center := CenterContainer.new()
|
||||
margin.add_child(center)
|
||||
var row := HBoxContainer.new()
|
||||
row.add_theme_constant_override("separation", 12)
|
||||
center.add_child(row)
|
||||
|
||||
var square := Panel.new()
|
||||
square.custom_minimum_size = Vector2(20, 20)
|
||||
square.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN
|
||||
square.size_flags_vertical = Control.SIZE_SHRINK_CENTER
|
||||
square.mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
square.set_meta("legal_consent_checkbox", check)
|
||||
square.gui_input.connect(func(event: InputEvent):
|
||||
if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT:
|
||||
check.button_pressed = not check.button_pressed
|
||||
)
|
||||
row.add_child(square)
|
||||
legal_consent_squares.append(square)
|
||||
check.toggled.connect(func(_pressed: bool): _refresh_legal_consent_square(square))
|
||||
|
||||
var consent_text := RichTextLabel.new()
|
||||
consent_text.bbcode_enabled = true
|
||||
consent_text.fit_content = true
|
||||
consent_text.scroll_active = false
|
||||
consent_text.meta_underlined = false
|
||||
consent_text.custom_minimum_size = Vector2(480, 0)
|
||||
consent_text.size_flags_vertical = Control.SIZE_SHRINK_CENTER
|
||||
consent_text.add_theme_font_override("normal_font", Brand.font)
|
||||
consent_text.add_theme_font_size_override("normal_font_size", 13)
|
||||
consent_text.meta_clicked.connect(func(meta: Variant): _open_legal_document(str(meta)))
|
||||
legal_consent_texts.append(consent_text)
|
||||
row.add_child(consent_text)
|
||||
return check
|
||||
|
||||
|
||||
func _refresh_legal_consent_text(consent_text: RichTextLabel, dark: bool) -> void:
|
||||
var text_hex := Brand.text_color(dark).to_html(false)
|
||||
var link_hex := (Brand.SKY_BLUE if dark else Brand.ROYAL_BLUE).to_html(false)
|
||||
consent_text.text = (
|
||||
"[color=#%s]I agree to the [/color][url=terms][color=#%s]Terms of Service[/color][/url][color=#%s] and acknowledge the [/color][url=privacy][color=#%s]Privacy Policy[/color][/url][color=#%s].[/color]"
|
||||
% [text_hex, link_hex, text_hex, link_hex, text_hex]
|
||||
)
|
||||
|
||||
|
||||
func _refresh_legal_consent_square(square: Panel) -> void:
|
||||
var dark := UIThemeService.is_dark_mode()
|
||||
var accent := Brand.SKY_BLUE if dark else Brand.ROYAL_BLUE
|
||||
var checked := square.get_meta("legal_consent_checkbox", null) as CheckBox
|
||||
var is_checked := checked != null and checked.button_pressed
|
||||
var box := StyleBoxFlat.new()
|
||||
box.bg_color = accent if is_checked else Color.TRANSPARENT
|
||||
box.border_color = accent
|
||||
box.set_border_width_all(2)
|
||||
box.set_corner_radius_all(5)
|
||||
square.add_theme_stylebox_override("panel", box)
|
||||
|
||||
|
||||
func _add_legal_links(parent: HBoxContainer) -> void:
|
||||
for spec in [["Terms of Service", "terms"], ["Privacy Policy", "privacy"]]:
|
||||
var link := Button.new()
|
||||
@@ -357,7 +442,7 @@ func _open_legal_document(kind: String) -> void:
|
||||
|
||||
func _new_form() -> VBoxContainer:
|
||||
var form := VBoxContainer.new()
|
||||
form.add_theme_constant_override("separation", 16)
|
||||
form.add_theme_constant_override("separation", 10)
|
||||
return form
|
||||
|
||||
|
||||
@@ -388,7 +473,7 @@ func _add_field(parent: VBoxContainer, label_text: String, placeholder: String,
|
||||
input.secret = secret
|
||||
input.caret_blink = true
|
||||
input.caret_blink_interval = 0.5
|
||||
input.custom_minimum_size = Vector2(0, 68)
|
||||
input.custom_minimum_size = Vector2(0, 56)
|
||||
input.add_theme_font_size_override("font_size", 17)
|
||||
input.add_theme_font_override("font", Brand.font)
|
||||
parent.add_child(input)
|
||||
@@ -396,19 +481,9 @@ func _add_field(parent: VBoxContainer, label_text: String, placeholder: String,
|
||||
return input
|
||||
|
||||
|
||||
func _add_show_password(parent: VBoxContainer, targets: Array) -> void:
|
||||
var toggle := CheckButton.new()
|
||||
toggle.text = "Show password"
|
||||
toggle.toggled.connect(func(should_reveal: bool):
|
||||
for target in targets:
|
||||
target.secret = not should_reveal
|
||||
)
|
||||
parent.add_child(toggle)
|
||||
|
||||
|
||||
func _add_password_reveal(input: LineEdit) -> void:
|
||||
var reveal := Button.new()
|
||||
password_reveal_button = reveal
|
||||
password_reveal_entries.append({"input": input, "button": reveal})
|
||||
reveal.text = ""
|
||||
reveal.tooltip_text = "Show password"
|
||||
reveal.flat = true
|
||||
@@ -487,6 +562,9 @@ func _update_responsive_layout() -> void:
|
||||
var layout_width := size.x
|
||||
if layout_width <= 0.0:
|
||||
layout_width = get_viewport_rect().size.x
|
||||
var layout_height := size.y
|
||||
if layout_height <= 0.0:
|
||||
layout_height = get_viewport_rect().size.y
|
||||
var compact_desktop := layout_width < 900.0
|
||||
var mobile_web := false
|
||||
if OS.has_feature("web"):
|
||||
@@ -505,7 +583,16 @@ func _update_responsive_layout() -> void:
|
||||
theme_button.custom_minimum_size = Vector2(44, 44)
|
||||
theme_button.position = Vector2(-58, 14)
|
||||
theme_button.add_theme_font_size_override("font_size", 20)
|
||||
auth_panel.custom_minimum_size = Vector2(640, 790) if compact_desktop else Vector2(710, 820)
|
||||
# 1000/1010 comfortably fits every view (login/register/reset/steam)
|
||||
# without the inner ScrollContainer kicking in, on any window tall
|
||||
# enough to hold it - but clamp to whatever height is actually
|
||||
# available so a short window shrinks the panel (and lets the
|
||||
# ScrollContainer take over) instead of the panel overflowing off
|
||||
# the top/bottom of the screen.
|
||||
var available_panel_height := layout_height - 56.0
|
||||
var target_height := 1010.0 if compact_desktop else 1000.0
|
||||
var panel_height := clampf(available_panel_height, 400.0, target_height)
|
||||
auth_panel.custom_minimum_size = Vector2(640 if compact_desktop else 710, panel_height)
|
||||
panel_gutter.add_theme_constant_override("margin_right", 28)
|
||||
_apply_compact_scale(panel_content, 1.0)
|
||||
_apply_theme()
|
||||
@@ -544,7 +631,13 @@ func _apply_theme() -> void:
|
||||
label.add_theme_color_override("font_color", Brand.text_color(dark))
|
||||
for input in fields:
|
||||
_style_auth_field(input, dark)
|
||||
for consent_text in legal_consent_texts:
|
||||
_refresh_legal_consent_text(consent_text, dark)
|
||||
for square in legal_consent_squares:
|
||||
_refresh_legal_consent_square(square)
|
||||
for button in find_children("*", "Button", true, false):
|
||||
if button is CheckBox:
|
||||
continue
|
||||
var typed := button as Button
|
||||
typed.add_theme_font_override("font", Brand.font)
|
||||
typed.add_theme_color_override("font_color", Brand.text_color(dark))
|
||||
@@ -565,8 +658,8 @@ func _apply_theme() -> void:
|
||||
theme_button.add_theme_stylebox_override("normal", theme_normal)
|
||||
theme_button.add_theme_stylebox_override("hover", theme_normal)
|
||||
theme_button.add_theme_color_override("font_color", Brand.AMBER)
|
||||
if password_reveal_button != null:
|
||||
_refresh_password_reveal(password_input, password_reveal_button)
|
||||
for entry in password_reveal_entries:
|
||||
_refresh_password_reveal(entry["input"], entry["button"])
|
||||
subheading.add_theme_color_override("font_color", Brand.muted_color(dark))
|
||||
message_label.add_theme_color_override("font_color", Brand.muted_color(dark))
|
||||
if brand_tagline != null:
|
||||
@@ -765,7 +858,10 @@ func _on_steam_status(result: int, code: int, _headers: PackedStringArray, body:
|
||||
|
||||
|
||||
func _on_steam_action_changed(index: int) -> void:
|
||||
steam_email_input.visible = index == 0
|
||||
_set_field_visible(steam_email_input, index == 0)
|
||||
_set_field_visible(steam_confirm_input, index == 0)
|
||||
if index != 0:
|
||||
steam_confirm_input.text = ""
|
||||
steam_legal_check.visible = index == 0
|
||||
steam_username_input.placeholder_text = "Choose a username" if index == 0 else "Existing email or username"
|
||||
|
||||
@@ -773,7 +869,7 @@ func _on_steam_action_changed(index: int) -> void:
|
||||
func _complete_steam() -> void:
|
||||
var action := "create" if steam_action.selected == 0 else "link"
|
||||
if action == "create":
|
||||
var validation := _validate_account(steam_username_input.text.strip_edges(), steam_email_input.text.strip_edges(), steam_password_input.text, steam_password_input.text)
|
||||
var validation := _validate_account(steam_username_input.text.strip_edges(), steam_email_input.text.strip_edges(), steam_password_input.text, steam_confirm_input.text)
|
||||
if not validation.is_empty(): return _show_error(validation)
|
||||
if not steam_legal_check.button_pressed:
|
||||
return _show_error("Agree to the Terms of Service and acknowledge the Privacy Policy to create an account.")
|
||||
@@ -786,6 +882,15 @@ func _complete_steam() -> void:
|
||||
}, "Connecting account...")
|
||||
|
||||
|
||||
func _set_field_visible(input: LineEdit, visible_value: bool) -> void:
|
||||
input.visible = visible_value
|
||||
var index := input.get_index()
|
||||
if index > 0:
|
||||
var label := input.get_parent().get_child(index - 1)
|
||||
if label is Label:
|
||||
label.visible = visible_value
|
||||
|
||||
|
||||
func _on_steam_completed(result: int, code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
|
||||
_set_busy(false)
|
||||
if result != HTTPRequest.RESULT_SUCCESS or code < 200 or code >= 300:
|
||||
|
||||
+47
-15
@@ -11,7 +11,6 @@ var header_title: Label
|
||||
var environment_badge: Label
|
||||
var app_menu: PanelContainer
|
||||
var menu_exit_button: Button
|
||||
var sound_toggle: CheckButton
|
||||
var theme_button: Button
|
||||
var feedback_button: Button
|
||||
var feedback_dialog: Control
|
||||
@@ -154,7 +153,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"))
|
||||
@@ -351,6 +350,11 @@ func _build_ui() -> void:
|
||||
donations.main = self
|
||||
screens_container.add_child(donations)
|
||||
screens["donations"] = donations
|
||||
var access_store := preload("res://scripts/ui/access_store_screen.gd").new()
|
||||
access_store.name = "AccessStoreScreen"
|
||||
access_store.main = self
|
||||
screens_container.add_child(access_store)
|
||||
screens["access_store"] = access_store
|
||||
|
||||
feedback_dialog = preload("res://scripts/ui/app_feedback_dialog.gd").new()
|
||||
feedback_dialog.name = "AppFeedbackDialog"
|
||||
@@ -370,10 +374,16 @@ func _build_ui() -> void:
|
||||
|
||||
AuthState.auth_changed.connect(_on_auth_changed)
|
||||
AuthState.session_expired.connect(_on_session_expired)
|
||||
QuestionBankService.access_required.connect(_on_qbank_access_required)
|
||||
_refresh_header_controls()
|
||||
_build_app_menu()
|
||||
|
||||
|
||||
func _on_qbank_access_required(_message: String) -> void:
|
||||
if AuthState.is_logged_in:
|
||||
show_screen("access_store")
|
||||
|
||||
|
||||
func _build_sidebar() -> PanelContainer:
|
||||
var panel := PanelContainer.new()
|
||||
panel.name = "Sidebar"
|
||||
@@ -416,10 +426,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, "dynamic_questions", "Dynamic Questions", "activity", 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, "flashcards", "Flashcards", "clipboard-plus", true, Brand.is_uat_environment())
|
||||
_add_nav_button(container, "bookmarks", "Bookmarks", "bookmark", true)
|
||||
_add_nav_button(container, "notes", "Notes", "notebook-pen", true)
|
||||
|
||||
@@ -427,13 +437,14 @@ func _populate_navigation(container: VBoxContainer, include_brand: bool) -> void
|
||||
spacer.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||
container.add_child(spacer)
|
||||
container.add_child(HSeparator.new())
|
||||
_add_nav_button(container, "access_store", "Credits & Access", "clock-3", true)
|
||||
_add_nav_button(container, "donations", "Support RanaZe", "heart-pulse", true)
|
||||
_add_nav_button(container, "performance", "Profile", "user-round", true)
|
||||
_add_nav_button(container, "settings", "Settings", "settings", true)
|
||||
_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()
|
||||
@@ -447,6 +458,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):
|
||||
@@ -471,6 +483,7 @@ func _on_navigation_pressed(key: String) -> void:
|
||||
"notes": show_screen("notes")
|
||||
"bookmarks": show_screen("bookmarks")
|
||||
"donations": show_screen("donations")
|
||||
"access_store": show_screen("access_store")
|
||||
"logout": _logout()
|
||||
|
||||
|
||||
@@ -489,7 +502,8 @@ func _update_header_title(screen_name: String) -> void:
|
||||
"settings": "Settings",
|
||||
"notes": "Notes",
|
||||
"bookmarks": "Bookmarks",
|
||||
"donations": "Support RanaZe"
|
||||
"donations": "Support RanaZe",
|
||||
"access_store": "Credits & Access"
|
||||
}
|
||||
header_title.text = str(titles.get(screen_name, ""))
|
||||
|
||||
@@ -814,6 +828,9 @@ func _on_theme_changed(is_dark_mode: bool) -> void:
|
||||
var donations = screens.get("donations")
|
||||
if donations != null and donations.has_method("refresh_theme_controls"):
|
||||
donations.refresh_theme_controls()
|
||||
var access_store = screens.get("access_store")
|
||||
if access_store != null and access_store.has_method("refresh_theme_controls"):
|
||||
access_store.refresh_theme_controls()
|
||||
var player = screens.get("player")
|
||||
if player != null and player.has_method("refresh_theme_controls"):
|
||||
player.refresh_theme_controls()
|
||||
@@ -854,7 +871,7 @@ func _build_app_menu() -> void:
|
||||
app_menu.visible = false
|
||||
app_menu.top_level = true
|
||||
app_menu.mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
app_menu.custom_minimum_size = Vector2(340, 230)
|
||||
app_menu.custom_minimum_size = Vector2(340, 290)
|
||||
app_menu.add_theme_stylebox_override("panel", Style.panel_box(Color("172337"), Style.CYAN))
|
||||
add_child(app_menu)
|
||||
|
||||
@@ -876,13 +893,21 @@ func _build_app_menu() -> void:
|
||||
resume.pressed.connect(_close_app_menu)
|
||||
content.add_child(resume)
|
||||
|
||||
sound_toggle = CheckButton.new()
|
||||
sound_toggle.text = "UI Sounds"
|
||||
sound_toggle.button_pressed = UISoundService.are_sounds_enabled()
|
||||
sound_toggle.custom_minimum_size = Vector2(0, 44)
|
||||
UISoundService.set_button_sound(sound_toggle, "none")
|
||||
sound_toggle.toggled.connect(UISoundService.set_sounds_enabled)
|
||||
content.add_child(sound_toggle)
|
||||
var profile_button := Button.new()
|
||||
profile_button.text = "Profile"
|
||||
profile_button.custom_minimum_size = Vector2(0, 48)
|
||||
Style.style_chip(profile_button, Style.CYAN)
|
||||
UISoundService.set_button_sound(profile_button, "open")
|
||||
profile_button.pressed.connect(_navigate_from_pause.bind("profile"))
|
||||
content.add_child(profile_button)
|
||||
|
||||
var settings_button := Button.new()
|
||||
settings_button.text = "Settings"
|
||||
settings_button.custom_minimum_size = Vector2(0, 48)
|
||||
Style.style_chip(settings_button, Style.CYAN)
|
||||
UISoundService.set_button_sound(settings_button, "open")
|
||||
settings_button.pressed.connect(_navigate_from_pause.bind("settings"))
|
||||
content.add_child(settings_button)
|
||||
|
||||
menu_exit_button = Button.new()
|
||||
menu_exit_button.text = "Exit Run"
|
||||
@@ -928,6 +953,13 @@ func _close_app_menu() -> void:
|
||||
BlockSessionService.resume_current_question_timer()
|
||||
|
||||
|
||||
func _navigate_from_pause(target_screen: String) -> void:
|
||||
var return_screen := current_screen_name
|
||||
show_screen(target_screen, {"return_screen": return_screen})
|
||||
if return_screen == "player":
|
||||
BlockSessionService.pause_current_question_timer()
|
||||
|
||||
|
||||
func _exit_run() -> void:
|
||||
_close_app_menu()
|
||||
if current_screen_name == "ccs_player":
|
||||
|
||||
@@ -26,6 +26,10 @@ 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:
|
||||
return DARK_TEXT if dark else LIGHT_TEXT
|
||||
|
||||
|
||||
@@ -29,4 +29,6 @@ static func key_for_screen(screen_name: String, payload: Variant = null) -> Stri
|
||||
key = "bookmarks"
|
||||
elif screen_name == "donations":
|
||||
key = "donations"
|
||||
elif screen_name == "access_store":
|
||||
key = "access_store"
|
||||
return key
|
||||
|
||||
@@ -19,6 +19,8 @@ var outcome_consent: CheckBox
|
||||
var outcome_status: Label
|
||||
var outcome_delete: Button
|
||||
var connected_accounts_list: VBoxContainer
|
||||
var return_screen := ""
|
||||
var back_to_session_button: Button
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
@@ -38,7 +40,10 @@ func _ready() -> void:
|
||||
_build()
|
||||
|
||||
|
||||
func on_show(_payload: Variant = null) -> void:
|
||||
func on_show(payload: Variant = null) -> void:
|
||||
var wrapper: Dictionary = payload if typeof(payload) == TYPE_DICTIONARY else {}
|
||||
return_screen = str(wrapper.get("return_screen", ""))
|
||||
_refresh_back_to_session_visibility()
|
||||
_render()
|
||||
AccountProfileService.refresh()
|
||||
DashboardService.refresh()
|
||||
@@ -63,6 +68,11 @@ func _build() -> void:
|
||||
status_label = _label("", 13, Brand.muted_color(_dark()))
|
||||
status_label.visible = false
|
||||
page.add_child(status_label)
|
||||
back_to_session_button = _button("Back to session", "", true)
|
||||
back_to_session_button.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN
|
||||
UISoundService.set_button_sound(back_to_session_button, "back")
|
||||
back_to_session_button.pressed.connect(_return_to_session)
|
||||
page.add_child(back_to_session_button)
|
||||
columns = GridContainer.new()
|
||||
columns.columns = 2
|
||||
columns.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
@@ -87,6 +97,7 @@ func _build() -> void:
|
||||
right.add_child(_outcome_card())
|
||||
right.add_child(_connected_accounts_card())
|
||||
right.add_child(_account_actions_card())
|
||||
_refresh_back_to_session_visibility()
|
||||
_update_responsive()
|
||||
_render()
|
||||
|
||||
@@ -247,19 +258,19 @@ func _render() -> void:
|
||||
return
|
||||
var account: Dictionary = AccountProfileService.account
|
||||
var prefs: Dictionary = AccountProfileService.preferences
|
||||
var username := str(account.get("username", AuthState.username))
|
||||
var display_name := str(account.get("displayName", "")).strip_edges()
|
||||
var username := string_field(account, "username", AuthState.username)
|
||||
var display_name := string_field(account, "displayName").strip_edges()
|
||||
profile_name.text = username if display_name.is_empty() else display_name
|
||||
var email := str(account.get("email", "")).strip_edges()
|
||||
var email := string_field(account, "email").strip_edges()
|
||||
profile_identity.text = "@%s" % username if email.is_empty() else "@%s | %s" % [username, email]
|
||||
var role := str(account.get("role", AuthState.role)).capitalize()
|
||||
var created := _date_only(str(account.get("createdUtc", "")))
|
||||
var role := string_field(account, "role", AuthState.role).capitalize()
|
||||
var created := _date_only(string_field(account, "createdUtc"))
|
||||
profile_meta.text = role if created.is_empty() else "%s | Member since %s" % [role, created]
|
||||
_render_connected_accounts(account.get("connectedProviders", []), account.get("availableProviders", []))
|
||||
(identity_values["exam"] as Label).text = _date_only(str(prefs.get("targetExamDate", "")), "Not scheduled")
|
||||
(identity_values["stage"] as Label).text = _friendly(str(prefs.get("preparationStage", "pre_dedicated")))
|
||||
(identity_values["exam"] as Label).text = _date_only(string_field(prefs, "targetExamDate"), "Not scheduled")
|
||||
(identity_values["stage"] as Label).text = _friendly(string_field(prefs, "preparationStage", "pre_dedicated"))
|
||||
(identity_values["goal"] as Label).text = "%d hours / week" % int(prefs.get("weeklyGoalHours", 10))
|
||||
(identity_values["mode"] as Label).text = _friendly(str(prefs.get("preferredStudyMode", "mixed")))
|
||||
(identity_values["mode"] as Label).text = _friendly(string_field(prefs, "preferredStudyMode", "mixed"))
|
||||
var data: Dictionary = DashboardService.data
|
||||
_set_metric("qbank", str(int(data.get("questionBankRating", QuestionBankService.overall_elo))))
|
||||
_set_metric("ccs", str(int(data.get("ccsRating", CCSService.rating.get("elo", 1000)))))
|
||||
@@ -342,7 +353,7 @@ func _render_outcome() -> void:
|
||||
outcome_delete.visible = false
|
||||
return
|
||||
outcome_score.value = int(outcome.get("officialScore", 200))
|
||||
outcome_date.text = _date_only(str(outcome.get("examDate", "")))
|
||||
outcome_date.text = _date_only(string_field(outcome, "examDate"))
|
||||
outcome_consent.button_pressed = true
|
||||
outcome_status.text = "Saved score %d for %s." % [int(outcome_score.value), outcome_date.text]
|
||||
outcome_delete.visible = true
|
||||
@@ -352,12 +363,12 @@ func _open_profile_editor() -> void:
|
||||
var dialog := _dialog("Edit profile")
|
||||
var display := LineEdit.new()
|
||||
display.placeholder_text = "Display name"
|
||||
display.text = str(AccountProfileService.account.get("displayName", ""))
|
||||
display.text = string_field(AccountProfileService.account, "displayName")
|
||||
_style_field(display)
|
||||
dialog.add_field(display)
|
||||
var email := LineEdit.new()
|
||||
email.placeholder_text = "Email address"
|
||||
email.text = str(AccountProfileService.account.get("email", ""))
|
||||
email.text = string_field(AccountProfileService.account, "email")
|
||||
_style_field(email)
|
||||
dialog.add_field(email)
|
||||
dialog.confirmed.connect(func(): AccountProfileService.update_account(display.text, email.text))
|
||||
@@ -370,18 +381,18 @@ func _open_study_editor() -> void:
|
||||
var dialog := _dialog("Edit study identity")
|
||||
var exam := LineEdit.new()
|
||||
exam.placeholder_text = "Target exam date: YYYY-MM-DD"
|
||||
exam.text = _date_only(str(prefs.get("targetExamDate", "")))
|
||||
exam.text = _date_only(string_field(prefs, "targetExamDate"))
|
||||
_style_field(exam)
|
||||
dialog.add_field(exam)
|
||||
var stage := OptionButton.new()
|
||||
_add_options(stage, [["Pre-dedicated", "pre_dedicated"], ["Dedicated", "dedicated"], ["Final review", "final_review"]], str(prefs.get("preparationStage", "pre_dedicated")))
|
||||
_add_options(stage, [["Pre-dedicated", "pre_dedicated"], ["Dedicated", "dedicated"], ["Final review", "final_review"]], string_field(prefs, "preparationStage", "pre_dedicated"))
|
||||
dialog.add_field(stage)
|
||||
var goal := OptionButton.new()
|
||||
for hours in [5, 10, 15, 20, 25]: goal.add_item("%d hours per week" % hours, hours)
|
||||
_select_id(goal, int(prefs.get("weeklyGoalHours", 10)))
|
||||
dialog.add_field(goal)
|
||||
var mode := OptionButton.new()
|
||||
_add_options(mode, [["Mixed", "mixed"], ["Question Bank", "question_bank"], ["CCS", "ccs"], ["Exam simulation", "exam_simulation"]], str(prefs.get("preferredStudyMode", "mixed")))
|
||||
_add_options(mode, [["Mixed", "mixed"], ["Question Bank", "question_bank"], ["CCS", "ccs"], ["Exam simulation", "exam_simulation"]], string_field(prefs, "preferredStudyMode", "mixed"))
|
||||
dialog.add_field(mode)
|
||||
dialog.confirmed.connect(func():
|
||||
AccountProfileService.update_preferences(exam.text, str(stage.get_item_metadata(stage.selected)), goal.get_selected_id(), str(mode.get_item_metadata(mode.selected))))
|
||||
@@ -510,6 +521,17 @@ func _button(text: String, icon_name: String = "", primary: bool = false) -> But
|
||||
return button
|
||||
|
||||
|
||||
func _refresh_back_to_session_visibility() -> void:
|
||||
if back_to_session_button != null:
|
||||
back_to_session_button.visible = not return_screen.is_empty()
|
||||
|
||||
|
||||
func _return_to_session() -> void:
|
||||
if return_screen.is_empty():
|
||||
return
|
||||
main.show_screen(return_screen)
|
||||
|
||||
|
||||
func _action_button(text: String, icon_name: String, color: Color = Color.TRANSPARENT) -> Button:
|
||||
var button := _button(text, icon_name, false)
|
||||
button.alignment = HORIZONTAL_ALIGNMENT_LEFT
|
||||
@@ -581,8 +603,13 @@ func _clear(container: Container) -> void:
|
||||
child.free()
|
||||
|
||||
|
||||
static func string_field(source: Dictionary, key: String, fallback: String = "") -> String:
|
||||
var value = source.get(key)
|
||||
return fallback if value == null else str(value)
|
||||
|
||||
|
||||
func _date_only(value: String, fallback: String = "") -> String:
|
||||
if value.is_empty() or value == "<null>": return fallback
|
||||
if value.is_empty(): return fallback
|
||||
return value.left(10)
|
||||
|
||||
|
||||
|
||||
+247
-63
@@ -65,7 +65,14 @@ 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
|
||||
@@ -95,6 +102,7 @@ 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
|
||||
@@ -107,6 +115,7 @@ 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
|
||||
@@ -146,6 +155,7 @@ 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")
|
||||
@@ -212,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)
|
||||
@@ -239,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
|
||||
@@ -248,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()
|
||||
@@ -258,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+"
|
||||
@@ -267,7 +316,6 @@ 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"
|
||||
@@ -280,21 +328,18 @@ func _ready() -> void:
|
||||
calculator_button.focus_mode = Control.FOCUS_NONE
|
||||
UISoundService.set_button_sound(calculator_button, "open")
|
||||
calculator_button.pressed.connect(_toggle_calculator)
|
||||
question_toolbar.add_child(calculator_button)
|
||||
|
||||
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
|
||||
@@ -308,7 +353,6 @@ 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)"
|
||||
@@ -321,7 +365,6 @@ func _ready() -> void:
|
||||
flashcard_button.disabled = true
|
||||
UISoundService.set_button_sound(flashcard_button, "toggle")
|
||||
flashcard_button.pressed.connect(_toggle_flashcard_current_question)
|
||||
question_toolbar.add_child(flashcard_button)
|
||||
flashcard_prefill_dialog = FlashcardPrefillDialog.new()
|
||||
flashcard_prefill_dialog.name = "FlashcardPrefillDialog"
|
||||
add_child(flashcard_prefill_dialog)
|
||||
@@ -336,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"
|
||||
@@ -355,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"
|
||||
@@ -363,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"
|
||||
@@ -421,6 +475,11 @@ func _ready() -> void:
|
||||
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
|
||||
@@ -521,6 +580,23 @@ func _ready() -> void:
|
||||
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
|
||||
@@ -556,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)
|
||||
@@ -588,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)
|
||||
@@ -675,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:
|
||||
@@ -717,12 +819,12 @@ func _answer_box(dark: bool, state: String, attached: bool = false) -> StyleBoxF
|
||||
box.content_margin_top = 22
|
||||
box.content_margin_bottom = 22
|
||||
if state == "default" or state == "eliminated":
|
||||
# Flat, divider-style row - most choices sit in a plain list; only the
|
||||
# states below need to visually pop out as their own card.
|
||||
# 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.border_width_bottom = 1
|
||||
box.set_corner_radius_all(0)
|
||||
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
|
||||
@@ -792,27 +894,17 @@ 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)
|
||||
|
||||
@@ -919,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))
|
||||
@@ -952,9 +1062,14 @@ func _apply_question_theme() -> void:
|
||||
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"))
|
||||
@@ -1054,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:
|
||||
@@ -1085,6 +1206,15 @@ func _input(event: InputEvent) -> void:
|
||||
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
|
||||
@@ -1141,21 +1271,41 @@ func _load_question() -> void:
|
||||
progress_button.text = _question_progress_text()
|
||||
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)
|
||||
@@ -1247,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):
|
||||
@@ -1661,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():
|
||||
@@ -1921,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
|
||||
@@ -2642,10 +2824,10 @@ func _build_report_modal() -> void:
|
||||
report_form_content.add_theme_constant_override("separation", 12)
|
||||
modal_body.add_child(report_form_content)
|
||||
|
||||
var category_label := Label.new()
|
||||
category_label.text = "What is the problem?"
|
||||
_set_scaled_label(category_label, 18, Style.CYAN)
|
||||
report_form_content.add_child(category_label)
|
||||
var report_category_label := Label.new()
|
||||
report_category_label.text = "What is the problem?"
|
||||
_set_scaled_label(report_category_label, 18, Style.CYAN)
|
||||
report_form_content.add_child(report_category_label)
|
||||
|
||||
var category_grid := GridContainer.new()
|
||||
category_grid.columns = 2
|
||||
@@ -3147,6 +3329,8 @@ func _style_choice_button_for_state(index: int, choice_id: String) -> void:
|
||||
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:
|
||||
|
||||
@@ -4,12 +4,17 @@ 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
|
||||
var return_screen := ""
|
||||
var back_to_session_button: Button
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
@@ -36,6 +41,17 @@ func _ready() -> void:
|
||||
subtitle.add_theme_color_override("font_color", Brand.SLATE)
|
||||
add_child(subtitle)
|
||||
|
||||
back_to_session_button = Button.new()
|
||||
back_to_session_button.text = "Back to session"
|
||||
back_to_session_button.custom_minimum_size = Vector2(0, 44)
|
||||
back_to_session_button.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN
|
||||
back_to_session_button.add_theme_stylebox_override("normal", Brand.gradient_button_box())
|
||||
back_to_session_button.add_theme_color_override("font_color", Color.WHITE)
|
||||
back_to_session_button.visible = false
|
||||
UISoundService.set_button_sound(back_to_session_button, "back")
|
||||
back_to_session_button.pressed.connect(_return_to_session)
|
||||
add_child(back_to_session_button)
|
||||
|
||||
var panel := PanelContainer.new()
|
||||
panel.custom_minimum_size = Vector2(520, 0)
|
||||
panel.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN
|
||||
@@ -57,8 +73,16 @@ 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)
|
||||
@@ -87,9 +111,13 @@ func _ready() -> void:
|
||||
_refresh_text_scale_labels()
|
||||
|
||||
|
||||
func on_show(_payload: Variant = null) -> void:
|
||||
func on_show(payload: Variant = null) -> void:
|
||||
var wrapper: Dictionary = payload if typeof(payload) == TYPE_DICTIONARY else {}
|
||||
return_screen = str(wrapper.get("return_screen", ""))
|
||||
back_to_session_button.visible = not return_screen.is_empty()
|
||||
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()
|
||||
|
||||
|
||||
@@ -131,6 +159,42 @@ func _build_text_scale_row(label_text: String, kind: String) -> HBoxContainer:
|
||||
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)
|
||||
@@ -149,6 +213,8 @@ func _refresh_text_scale_labels() -> void:
|
||||
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:
|
||||
@@ -159,6 +225,12 @@ func _open_legal_document(kind: String) -> void:
|
||||
main.open_legal_document(kind)
|
||||
|
||||
|
||||
func _return_to_session() -> void:
|
||||
if return_screen.is_empty():
|
||||
return
|
||||
main.show_screen(return_screen)
|
||||
|
||||
|
||||
func _on_theme_changed(is_dark: bool) -> void:
|
||||
theme_toggle.set_pressed_no_signal(is_dark)
|
||||
var panel := theme_toggle.get_parent().get_parent() as PanelContainer
|
||||
|
||||
@@ -137,6 +137,8 @@ func _connect_services() -> void:
|
||||
ExamSimulationService.state_changed.connect(_on_simulation_loaded)
|
||||
if not ExamSimulationService.request_failed.is_connected(_on_request_failed):
|
||||
ExamSimulationService.request_failed.connect(_on_request_failed)
|
||||
if not AccessService.status_changed.is_connected(_on_access_changed):
|
||||
AccessService.status_changed.connect(_on_access_changed)
|
||||
|
||||
|
||||
func on_show(_payload: Variant = null) -> void:
|
||||
@@ -147,6 +149,7 @@ func on_show(_payload: Variant = null) -> void:
|
||||
load_more_button.disabled = true
|
||||
ExamSimulationService.load_readiness()
|
||||
ExamSimulationService.load_active()
|
||||
AccessService.refresh()
|
||||
_load_history_page()
|
||||
|
||||
|
||||
@@ -223,7 +226,8 @@ func _render_active_section() -> void:
|
||||
card_layout.add_child(actions)
|
||||
var primary := Button.new()
|
||||
primary.name = "PrimarySimulationAction"
|
||||
primary.text = "Resume Simulation" if active else "Start Simulation"
|
||||
var has_exam_token := int(AccessService.status.get("examTokens", 0)) > 0
|
||||
primary.text = "Resume Simulation" if active else ("Start Simulation" if has_exam_token else "Get Exam Token")
|
||||
primary.custom_minimum_size = Vector2(0, 48)
|
||||
primary.disabled = pending_launch or (not active and not bool(ExamSimulationService.readiness.get("ready", false)))
|
||||
_style_gradient_button(primary)
|
||||
@@ -452,6 +456,7 @@ func _on_simulation_loaded() -> void:
|
||||
return
|
||||
if pending_launch:
|
||||
pending_launch = false
|
||||
AccessService.refresh()
|
||||
_route_loaded_simulation()
|
||||
return
|
||||
if not pending_simulation_id.is_empty():
|
||||
@@ -483,6 +488,9 @@ func _route_loaded_simulation() -> void:
|
||||
|
||||
|
||||
func _launch_or_resume() -> void:
|
||||
if not _has_active_simulation() and int(AccessService.status.get("examTokens", 0)) <= 0:
|
||||
main.show_screen("access_store")
|
||||
return
|
||||
pending_launch = true
|
||||
pending_simulation_id = ""
|
||||
_render_active_section()
|
||||
@@ -538,6 +546,11 @@ func _on_request_failed(message: String) -> void:
|
||||
_render_active_section()
|
||||
|
||||
|
||||
func _on_access_changed(_status: Dictionary) -> void:
|
||||
if visible:
|
||||
_render_active_section()
|
||||
|
||||
|
||||
func _update_responsive_layout() -> void:
|
||||
var use_compact := size.x < 940
|
||||
if compact_layout == use_compact:
|
||||
@@ -564,7 +577,8 @@ func _active_history_summary() -> Dictionary:
|
||||
|
||||
func _readiness_message() -> String:
|
||||
if bool(ExamSimulationService.readiness.get("ready", false)):
|
||||
return "The question bank is ready. Start when you have enough uninterrupted time for a full simulation."
|
||||
var tokens := int(AccessService.status.get("examTokens", 0))
|
||||
return "Exam tokens available: %d. One token is consumed when a new simulation is created." % tokens
|
||||
var available := int(ExamSimulationService.readiness.get("availableQuestions", 0))
|
||||
var required := int(ExamSimulationService.readiness.get("requiredQuestions", 232))
|
||||
return "Simulation unlocks when %d published questions are available. Currently %d/%d." % [required, available, required]
|
||||
|
||||
@@ -187,18 +187,19 @@ func _build_mode_view() -> void:
|
||||
retry_button = question_card.retry
|
||||
retry_button.pressed.connect(_retry_question_bank)
|
||||
|
||||
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:
|
||||
|
||||
@@ -9,6 +9,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "QuestionsApi", "api\Questio
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "QuestionsApi.Tests", "api\QuestionsApi.Tests\QuestionsApi.Tests.csproj", "{1897C6D6-452D-4142-BCCC-FA1A46E7C869}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tools", "tools", "{07C2787E-EAC7-C090-1BA3-A61EC2A24D84}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Difficulty4GapSeeder", "tools\Difficulty4GapSeeder\Difficulty4GapSeeder.csproj", "{EC397984-398E-49E6-B6F0-42269A629585}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
@@ -43,6 +47,18 @@ Global
|
||||
{1897C6D6-452D-4142-BCCC-FA1A46E7C869}.Release|x64.Build.0 = Release|Any CPU
|
||||
{1897C6D6-452D-4142-BCCC-FA1A46E7C869}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{1897C6D6-452D-4142-BCCC-FA1A46E7C869}.Release|x86.Build.0 = Release|Any CPU
|
||||
{EC397984-398E-49E6-B6F0-42269A629585}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{EC397984-398E-49E6-B6F0-42269A629585}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{EC397984-398E-49E6-B6F0-42269A629585}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{EC397984-398E-49E6-B6F0-42269A629585}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{EC397984-398E-49E6-B6F0-42269A629585}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{EC397984-398E-49E6-B6F0-42269A629585}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{EC397984-398E-49E6-B6F0-42269A629585}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{EC397984-398E-49E6-B6F0-42269A629585}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{EC397984-398E-49E6-B6F0-42269A629585}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{EC397984-398E-49E6-B6F0-42269A629585}.Release|x64.Build.0 = Release|Any CPU
|
||||
{EC397984-398E-49E6-B6F0-42269A629585}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{EC397984-398E-49E6-B6F0-42269A629585}.Release|x86.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
@@ -50,5 +66,6 @@ Global
|
||||
GlobalSection(NestedProjects) = preSolution
|
||||
{0B2F6FEF-3C08-4A72-BBA9-90C225BC24EC} = {D82C56D6-B2CD-F611-9E31-04638F7EC30E}
|
||||
{1897C6D6-452D-4142-BCCC-FA1A46E7C869} = {D82C56D6-B2CD-F611-9E31-04638F7EC30E}
|
||||
{EC397984-398E-49E6-B6F0-42269A629585} = {07C2787E-EAC7-C090-1BA3-A61EC2A24D84}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
|
||||
@@ -6,6 +6,9 @@ func _initialize() -> void:
|
||||
_assert_equal(ProgressProfile.validate_outcome(236, "2026-07-14", false), "Consent is required before saving an outcome.", "consent")
|
||||
_assert_equal(ProgressProfile.validate_outcome(236, "07/14/2026", true), "Enter the exam date as YYYY-MM-DD.", "date format")
|
||||
_assert_equal(ProgressProfile.validate_outcome(301, "2026-07-14", true), "Enter an official score from 1 through 300.", "score range")
|
||||
_assert_equal(ProgressProfile.string_field({"displayName": null}, "displayName", "fallback"), "fallback", "null value falls back")
|
||||
_assert_equal(ProgressProfile.string_field({}, "displayName", "fallback"), "fallback", "missing key falls back")
|
||||
_assert_equal(ProgressProfile.string_field({"displayName": "Dr. Rana"}, "displayName", "fallback"), "Dr. Rana", "present value wins")
|
||||
print("outcome_profile_test: PASS")
|
||||
quit(0)
|
||||
|
||||
|
||||
@@ -357,7 +357,9 @@ 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")
|
||||
@@ -374,7 +376,7 @@ 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")
|
||||
|
||||
@@ -15,6 +15,7 @@ func _run() -> void:
|
||||
_test_elapsed_timeout_clears_flow()
|
||||
_test_transient_failure_keeps_transaction()
|
||||
_test_leaving_completion_cancels_flow()
|
||||
_test_external_account_password_confirmation()
|
||||
login.queue_free()
|
||||
await process_frame
|
||||
quit(1 if failures > 0 else 0)
|
||||
@@ -47,6 +48,19 @@ func _test_leaving_completion_cancels_flow() -> void:
|
||||
_assert(login.poll_timer.is_stopped(), "leaving Steam account completion should stop polling")
|
||||
|
||||
|
||||
func _test_external_account_password_confirmation() -> void:
|
||||
login._on_steam_action_changed(0)
|
||||
_assert(login.steam_confirm_input.visible, "new external accounts should show password confirmation")
|
||||
_assert(login.steam_confirm_input.secret, "external password confirmation should be masked")
|
||||
_assert(
|
||||
login._validate_account("new_user", "[email protected]", "password-one", "password-two") == "Passwords do not match.",
|
||||
"external account creation should reject mismatched passwords")
|
||||
login.steam_confirm_input.text = "password-one"
|
||||
login._on_steam_action_changed(1)
|
||||
_assert(not login.steam_confirm_input.visible, "existing-account linking should hide password confirmation")
|
||||
_assert(login.steam_confirm_input.text.is_empty(), "hidden confirmation text should be cleared")
|
||||
|
||||
|
||||
func _assert(condition: bool, message: String) -> void:
|
||||
if condition:
|
||||
return
|
||||
|
||||
@@ -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")
|
||||
@@ -50,10 +50,13 @@ func _run() -> void:
|
||||
_assert(main_screen.account_chevron_button.get_index() == main_screen.account_button.get_index() + 1, "the account avatar and chevron should remain adjacent")
|
||||
_assert(main_screen.header_panel.find_child("HeaderStats", true, false) == null, "the application header should not display rating or role labels")
|
||||
var pause_menu_has_profile := false
|
||||
var pause_menu_has_settings := false
|
||||
for menu_button in main_screen.app_menu.find_children("*", "Button", true, false):
|
||||
if menu_button.text == "Profile":
|
||||
pause_menu_has_profile = true
|
||||
_assert(not pause_menu_has_profile, "the paused-run menu should not duplicate profile navigation")
|
||||
if menu_button.text == "Settings":
|
||||
pause_menu_has_settings = true
|
||||
_assert(pause_menu_has_profile and pause_menu_has_settings, "the paused-run menu should offer Profile and Settings navigation")
|
||||
_assert(main_screen.session_expiry_dialog != null, "the application shell should construct a global session-expiry prompt")
|
||||
main_screen.session_expiry_dialog._show_warning(120)
|
||||
_assert(main_screen.session_expiry_dialog.visible, "the session-expiry prompt should cover every authenticated screen")
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\api\QuestionsApi\QuestionsApi.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,297 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using QuestionsApi.Models;
|
||||
using QuestionsApi.Services;
|
||||
|
||||
const string accessedOn = "2026-07-26";
|
||||
var apply = args.Contains("--apply", StringComparer.OrdinalIgnoreCase);
|
||||
var connectionString = Environment.GetEnvironmentVariable("STEP3_MONGODB_CONNECTION_STRING")
|
||||
?? "mongodb://192.168.86.50:27017";
|
||||
var databaseName = Environment.GetEnvironmentVariable("STEP3_MONGODB_DATABASE")
|
||||
?? "promiscuity";
|
||||
|
||||
var definitions = new[]
|
||||
{
|
||||
new Seed("myasthenic-crisis-respiratory-failure", "Nervous System", "Medicine", "Diagnosis",
|
||||
"A 63-year-old woman with myasthenia gravis develops pneumonia. She has increasing dysphagia, a weak cough, shallow breathing, and can count only to 8 on one breath. Oxygen saturation is 96% on room air. Vital capacity is 12 mL/kg and negative inspiratory force is -18 cm H2O.",
|
||||
"Which is the most appropriate next step in management?",
|
||||
"Electively intubate and begin ventilatory support", "Give pyridostigmine only and reassess in 4 hours", "Administer supplemental oxygen without ventilatory support", "Perform an edrophonium challenge", "Begin noninvasive ventilation on an unmonitored ward",
|
||||
"Bulbar weakness plus markedly reduced vital capacity and inspiratory force indicates impending myasthenic respiratory failure. Oxygen saturation can remain normal until late; airway control should not wait for hypoxemia.",
|
||||
"Impending ventilatory failure is identified by respiratory mechanics and bulbar function, not pulse oximetry alone.", "vital capacity 12 mL/kg with weak cough and dysphagia", "normal oxygen saturation", "edrophonium is diagnostic, not airway therapy",
|
||||
"International Consensus Guidance for Management of Myasthenia Gravis: Executive Summary", "Neurology", "https://www.neurology.org/doi/10.1212/WNL.0000000000002790", "Management of impending myasthenic crisis and respiratory failure."),
|
||||
|
||||
new Seed("pituitary-apoplexy-emergency-steroids", "Endocrine System", "Medicine", "Management",
|
||||
"A 48-year-old man has sudden severe headache, vomiting, diplopia, and hypotension. Examination shows bitemporal visual field loss and left third-nerve palsy. Sodium is 128 mEq/L and glucose is 54 mg/dL. CT shows a sellar mass with acute hemorrhage.",
|
||||
"Which treatment should be administered immediately?",
|
||||
"Intravenous hydrocortisone", "Cabergoline", "Levothyroxine before glucocorticoids", "Mannitol", "Emergent transsphenoidal surgery before medical stabilization",
|
||||
"Pituitary apoplexy can cause acute ACTH deficiency and fatal adrenal crisis. Hemodynamic instability, hypoglycemia, and hyponatremia require immediate empiric IV hydrocortisone while endocrine and neurosurgical evaluation proceeds.",
|
||||
"In unstable pituitary apoplexy, treat possible acute adrenal insufficiency before definitive imaging or surgery.", "sellar hemorrhage with hypotension and hypoglycemia", "visual field loss alone", "thyroid hormone can worsen untreated adrenal insufficiency",
|
||||
"Emergency management of pituitary apoplexy in adult patients", "Society for Endocrinology", "https://pmc.ncbi.nlm.nih.gov/articles/PMC5314810/", "Immediate empirical glucocorticoid treatment in pituitary apoplexy."),
|
||||
|
||||
new Seed("necrotizing-fasciitis-surgical-source-control", "Skin and Subcutaneous Tissue", "Surgery", "Management",
|
||||
"A 56-year-old man with diabetes has rapidly worsening leg pain 18 hours after a minor abrasion. Temperature is 39.2 C. The leg is swollen with violaceous bullae; pain is far greater than the skin findings. Crepitus is absent. Lactate is 5.1 mmol/L. Broad-spectrum antibiotics are started.",
|
||||
"Which is the most appropriate next step?",
|
||||
"Immediate operative exploration and debridement", "MRI before surgical consultation", "Bedside incision and drainage only", "Hyperbaric oxygen as sole therapy", "Observe response to antibiotics for 12 hours",
|
||||
"Necrotizing soft-tissue infection is a surgical emergency. High clinical suspicion warrants immediate surgical exploration and debridement; imaging must not delay source control.",
|
||||
"Disproportionate pain, systemic toxicity, and rapid progression demand immediate surgical exploration even without crepitus.", "rapid progression with shock and violaceous bullae", "absence of crepitus", "antibiotics cannot replace debridement",
|
||||
"Clinical Practice Guidelines for Skin and Soft Tissue Infections", "Infectious Diseases Society of America", "https://www.idsociety.org/practice-guideline/skin-and-soft-tissue-infections/", "Prompt surgical consultation and debridement for suspected necrotizing fasciitis."),
|
||||
|
||||
new Seed("sjs-ten-immediate-drug-withdrawal", "Skin and Subcutaneous Tissue", "Medicine", "Management",
|
||||
"A 35-year-old woman started lamotrigine 3 weeks ago. She now has fever, painful dusky targetoid lesions, epidermal detachment over 12% of her body, oral erosions, and conjunctivitis. Blood pressure is stable.",
|
||||
"Which immediate intervention most improves prognosis?",
|
||||
"Stop lamotrigine and transfer for specialized supportive care", "Continue lamotrigine and add topical corticosteroids", "Begin oral acyclovir", "Debride all detached epidermis at bedside", "Discharge with an oral antihistamine",
|
||||
"This is Stevens-Johnson syndrome/toxic epidermal necrolysis. Immediate withdrawal of the culprit drug is the key first step, followed by burn-unit or ICU-level supportive and multidisciplinary care.",
|
||||
"Early culprit-drug withdrawal is the most important immediately modifiable intervention in SJS/TEN.", "mucosal erosions with epidermal detachment after a high-risk drug", "target lesions alone", "infection treatment does not replace drug withdrawal",
|
||||
"UK guidelines for the management of Stevens-Johnson syndrome/toxic epidermal necrolysis in adults 2016", "British Association of Dermatologists", "https://academic.oup.com/bjd/article/174/6/1194/6617016", "Immediate withdrawal of the culprit drug and specialized supportive care."),
|
||||
|
||||
new Seed("anti-gbm-pulmonary-renal-treatment", "Renal and Urinary System", "Medicine", "Management",
|
||||
"A 29-year-old man has hemoptysis, dyspnea, and oliguria. Creatinine rose from 0.9 to 4.8 mg/dL in 5 days. Urinalysis shows dysmorphic erythrocytes and red-cell casts. Chest imaging shows bilateral alveolar opacities. Anti-GBM antibody is strongly positive.",
|
||||
"Which is the best initial disease-directed treatment?",
|
||||
"Plasma exchange plus glucocorticoids and cyclophosphamide", "Glucocorticoids alone", "Rituximab alone after renal biopsy", "Hemodialysis without immunosuppression", "Broad-spectrum antibiotics only",
|
||||
"Anti-GBM disease with pulmonary hemorrhage and rapidly progressive glomerulonephritis requires urgent plasma exchange to remove antibody plus glucocorticoids and cyclophosphamide to suppress further production.",
|
||||
"Do not delay combined plasma exchange and immunosuppression when anti-GBM disease is strongly suspected.", "pulmonary hemorrhage plus rapidly progressive nephritis and anti-GBM antibody", "alveolar opacities alone", "dialysis supports kidney failure but does not stop antibody injury",
|
||||
"KDIGO 2021 Clinical Practice Guideline for the Management of Glomerular Diseases", "KDIGO", "https://kdigo.org/wp-content/uploads/2024/05/KDIGO-2021-Glomerular-Diseases-Guideline_English_2024-Chapter-Updates.pdf", "Immediate plasma exchange, cyclophosphamide, and glucocorticoids for anti-GBM disease."),
|
||||
|
||||
new Seed("atherosclerotic-ras-flash-pulmonary-edema", "Renal and Urinary System", "Medicine", "Management",
|
||||
"A 72-year-old man with diffuse atherosclerosis has resistant hypertension despite 4 drugs and three admissions for sudden flash pulmonary edema. Creatinine increased 35% after an ACE inhibitor. Duplex ultrasonography shows severe bilateral renal artery stenosis.",
|
||||
"Which intervention is most appropriate?",
|
||||
"Evaluate for renal artery revascularization", "Increase the ACE inhibitor dose", "Start high-dose glucocorticoids", "Perform unilateral nephrectomy", "Continue medical therapy without further evaluation",
|
||||
"Most atherosclerotic renal artery stenosis is treated medically, but recurrent flash pulmonary edema, resistant hypertension, and bilateral high-grade disease identify a selective high-risk phenotype that may benefit from revascularization.",
|
||||
"Recurrent flash pulmonary edema is a classic high-risk presentation in which renal revascularization should be considered.", "recurrent flash edema with bilateral severe stenosis", "atherosclerosis alone", "routine stenting is not indicated for stable incidental stenosis",
|
||||
"Revascularization for Renovascular Disease: A Scientific Statement From the American Heart Association", "American Heart Association", "https://pmc.ncbi.nlm.nih.gov/articles/PMC11731842/", "Selective revascularization for high-risk renovascular syndromes including flash pulmonary edema."),
|
||||
|
||||
new Seed("fournier-gangrene-immediate-debridement", "Male Reproductive System", "Surgery", "Management",
|
||||
"A 61-year-old man with poorly controlled diabetes has fever, severe perineal pain, scrotal edema, and a patch of dusky skin extending toward the anus. CT shows fascial gas. Fluids and broad-spectrum antibiotics are started.",
|
||||
"Which is the most appropriate next step?",
|
||||
"Urgent wide surgical debridement", "Needle aspiration of the scrotum", "Topical antimicrobial therapy", "Elective orchiectomy next week", "Wait for wound-culture speciation",
|
||||
"Fournier gangrene is necrotizing fasciitis of the perineum. Survival depends on immediate, aggressive operative debridement in addition to resuscitation and broad antimicrobial therapy.",
|
||||
"Fournier gangrene requires immediate operative source control; cultures and imaging must not delay debridement.", "perineal toxicity with fascial gas", "scrotal swelling alone", "antibiotics do not penetrate devitalized tissue adequately",
|
||||
"Clinical Practice Guidelines for Skin and Soft Tissue Infections", "Infectious Diseases Society of America", "https://www.idsociety.org/practice-guideline/skin-and-soft-tissue-infections/", "Urgent surgical management of Fournier gangrene."),
|
||||
|
||||
new Seed("vasa-previa-fetal-hemorrhage", "Pregnancy, Childbirth, and the Puerperium", "Obstetrics", "Management",
|
||||
"A 35-year-old woman at 35 weeks has sudden painless vaginal bleeding immediately after spontaneous rupture of membranes. Maternal blood pressure is 122/76 mm Hg. Fetal heart rate falls to 70/min with a sinusoidal pattern. Prenatal imaging showed a velamentous cord insertion.",
|
||||
"Which is the most appropriate next step?",
|
||||
"Immediate cesarean delivery", "Maternal observation with repeat hemoglobin", "Oxytocin augmentation for vaginal delivery", "Digital examination for cervical dilation", "Administer methotrexate",
|
||||
"Bleeding after membrane rupture with acute fetal compromise and velamentous insertion indicates ruptured vasa previa and fetal hemorrhage. Immediate cesarean delivery is required.",
|
||||
"In vasa previa, the fetus—not the mother—can exsanguinate rapidly after membrane rupture.", "bleeding after rupture with fetal bradycardia and velamentous insertion", "stable maternal vital signs", "expectant management risks fetal death",
|
||||
"SMFM Consult Series #37: Diagnosis and management of vasa previa", "Society for Maternal-Fetal Medicine", "https://publications.smfm.org/publications/215-society-for-maternal-fetal-medicine-consult-series-37/", "Emergency cesarean delivery for bleeding or rupture of membranes with vasa previa."),
|
||||
|
||||
new Seed("placenta-accreta-planned-cesarean-hysterectomy", "Pregnancy, Childbirth, and the Puerperium", "Obstetrics", "Management",
|
||||
"A 39-year-old gravida 5 para 4 at 32 weeks has placenta previa and three prior cesarean deliveries. Ultrasound shows placental lacunae and loss of the retroplacental clear zone, consistent with placenta accreta spectrum. She is stable and desires no future fertility.",
|
||||
"Which delivery plan is most appropriate?",
|
||||
"Planned preterm cesarean hysterectomy with the placenta left in situ", "Attempt manual placental removal after vaginal delivery", "Wait for spontaneous labor at 40 weeks", "Perform dilation and curettage after cesarean delivery", "Give methotrexate before delivery",
|
||||
"Suspected placenta accreta spectrum should be managed at an experienced center with planned cesarean hysterectomy, avoiding placental disruption because attempted removal can trigger catastrophic hemorrhage.",
|
||||
"For placenta accreta spectrum, planned multidisciplinary cesarean hysterectomy with placenta left in situ minimizes hemorrhage.", "previa plus multiple cesareans and accreta ultrasound signs", "desire for sterilization alone", "manual removal can cause massive bleeding",
|
||||
"Placenta Accreta Spectrum", "American College of Obstetricians and Gynecologists", "https://www.acog.org/clinical/clinical-guidance/obstetric-care-consensus/articles/2018/12/placenta-accreta-spectrum", "Timing and operative approach for placenta accreta spectrum."),
|
||||
|
||||
new Seed("ovarian-torsion-detorsion-preservation", "Female Reproductive System and Breast", "Obstetrics and Gynecology", "Management",
|
||||
"A 17-year-old girl has sudden severe right lower-quadrant pain with vomiting. Ultrasound shows a 7-cm ovarian cyst and an enlarged ovary with absent venous flow. Laparoscopy shows a twisted, dark blue ovary.",
|
||||
"Which is the most appropriate management?",
|
||||
"Laparoscopic detorsion with ovarian preservation", "Oophorectomy because the ovary appears necrotic", "Needle aspiration and discharge", "Observe for return of Doppler flow", "Begin combined oral contraceptives only",
|
||||
"Adnexal torsion requires prompt laparoscopy. The adnexa should be detorsed and preserved regardless of its dark appearance because gross color poorly predicts viability and ovarian function commonly recovers.",
|
||||
"A dark torsed ovary should usually be detorsed and preserved, especially in adolescents.", "sudden pain, vomiting, enlarged ovary, and twisted pedicle", "absent Doppler flow alone", "delay reduces ovarian salvage",
|
||||
"Adnexal Torsion in Adolescents", "American College of Obstetricians and Gynecologists", "https://www.acog.org/clinical/clinical-guidance/committee-opinion/articles/2019/08/adnexal-torsion-in-adolescents", "Prompt detorsion and ovarian preservation regardless of appearance."),
|
||||
|
||||
new Seed("amniotic-fluid-embolism-resuscitation", "Pregnancy, Childbirth, and the Puerperium", "Obstetrics", "Management",
|
||||
"A 30-year-old woman suddenly develops hypoxemia, hypotension, and cardiac arrest during labor. After return of spontaneous circulation, she has profuse bleeding from venipuncture sites and severe hypofibrinogenemia. No fever or rash is present.",
|
||||
"Which management is most appropriate?",
|
||||
"Supportive cardiopulmonary resuscitation plus early massive transfusion for coagulopathy", "Heparin infusion for presumed pulmonary thromboembolism", "Immediate bronchoscopy to remove an obstruction", "High-dose epinephrine autoinjector only", "Wait for a confirmatory serum test before treatment",
|
||||
"The abrupt triad of hypoxia, cardiovascular collapse, and disseminated intravascular coagulation during labor is amniotic fluid embolism. It is a clinical diagnosis treated with high-quality resuscitation, hemodynamic support, and prompt correction of coagulopathy.",
|
||||
"Amniotic fluid embolism has no confirmatory bedside test or specific antidote; immediate supportive resuscitation is lifesaving.", "peripartum collapse followed by DIC", "hypoxemia alone", "anticoagulation can worsen the profound coagulopathy",
|
||||
"SMFM Clinical Guidelines #9: Amniotic fluid embolism", "Society for Maternal-Fetal Medicine", "https://publications.smfm.org/publications/581-society-for-maternal-fetal-medicine-clinical-guidelines-9/", "Immediate supportive resuscitation and management of coagulopathy."),
|
||||
|
||||
new Seed("myxedema-coma-steroids-before-thyroxine", "Endocrine System", "Medicine", "Management",
|
||||
"A 74-year-old woman is found confused in winter. Temperature is 32.8 C, pulse is 42/min, and blood pressure is 82/50 mm Hg. She has dry skin, delayed reflex relaxation, sodium 124 mEq/L, low free T4, and markedly elevated TSH.",
|
||||
"Which treatment regimen should be initiated?",
|
||||
"Intravenous hydrocortisone followed by intravenous levothyroxine", "Oral levothyroxine after discharge", "Intravenous levothyroxine without glucocorticoids", "Methimazole and propranolol", "Rapid active external rewarming alone",
|
||||
"Myxedema coma requires ICU supportive care and IV thyroid hormone. Empiric IV glucocorticoids should precede levothyroxine because coexisting adrenal insufficiency may be present and thyroid replacement can precipitate adrenal crisis.",
|
||||
"In myxedema coma, give stress-dose glucocorticoids before IV thyroid hormone.", "hypothermia, bradycardia, hypotension, hyponatremia, and severe hypothyroidism", "elevated TSH alone", "oral therapy is unreliable in critical illness",
|
||||
"Guidelines for the Treatment of Hypothyroidism", "American Thyroid Association", "https://pmc.ncbi.nlm.nih.gov/articles/PMC4267409/", "IV levothyroxine and empiric glucocorticoids in myxedema coma."),
|
||||
|
||||
new Seed("hhs-initial-fluid-resuscitation", "Endocrine System", "Medicine", "Management",
|
||||
"An 81-year-old man with type 2 diabetes is confused and profoundly dehydrated. Glucose is 980 mg/dL, effective osmolality is 348 mOsm/kg, pH is 7.37, bicarbonate is 22 mEq/L, beta-hydroxybutyrate is 1.2 mmol/L, and potassium is 4.8 mEq/L.",
|
||||
"Which is the most appropriate initial treatment?",
|
||||
"Intravenous isotonic crystalloid with close electrolyte and osmolality monitoring", "Immediate bicarbonate infusion", "Subcutaneous long-acting insulin only", "Fluid restriction to prevent cerebral edema", "Immediate hemodialysis",
|
||||
"This is hyperosmolar hyperglycemic state without substantial ketoacidosis. Initial management prioritizes restoration of intravascular volume with IV isotonic crystalloid, followed by carefully monitored insulin and electrolyte therapy.",
|
||||
"Volume resuscitation comes first in HHS; osmolality must fall gradually with frequent monitoring.", "severe hyperosmolality and dehydration without acidosis", "hyperglycemia alone", "insulin before adequate volume assessment can worsen circulatory collapse",
|
||||
"Hyperglycemic Crises in Adults With Diabetes: A Consensus Report", "American Diabetes Association", "https://diabetesjournals.org/care/article/47/8/1257/156808/Hyperglycemic-Crises-in-Adults-With-Diabetes-A", "Initial fluid, insulin, electrolyte, and monitoring strategy for HHS."),
|
||||
|
||||
new Seed("serotonin-syndrome-cyproheptadine", "Behavioral Health", "Medicine", "Management",
|
||||
"A 42-year-old woman taking sertraline is given linezolid for pneumonia. Hours later she becomes agitated and diaphoretic with temperature 39.4 C, diarrhea, inducible ankle clonus, ocular clonus, and hyperreflexia. Sertraline and linezolid are stopped, IV fluids and benzodiazepines are given, but severe symptoms persist.",
|
||||
"Which additional treatment is most appropriate?",
|
||||
"Cyproheptadine", "Dantrolene", "Bromocriptine", "Physostigmine", "Haloperidol",
|
||||
"Rapid onset after serotonergic drug interaction plus clonus and hyperreflexia identifies serotonin syndrome. After stopping causative drugs and giving supportive care and benzodiazepines, cyproheptadine is the serotonin antagonist used for persistent moderate-to-severe toxicity.",
|
||||
"Clonus and hyperreflexia distinguish serotonin syndrome; cyproheptadine is used when supportive care is insufficient.", "rapid onset with clonus after sertraline-linezolid interaction", "fever and rigidity alone", "dantrolene treats malignant hyperthermia, not serotonergic excess",
|
||||
"FDA Drug Safety Communication: Serious CNS reactions possible when linezolid is given to patients taking serotonergic psychiatric medications", "U.S. Food and Drug Administration", "https://www.fda.gov/drugs/drug-safety-and-availability/fda-drug-safety-communication-serious-cns-reactions-possible-when-linezolid-zyvox-given-patients", "Recognition and urgent management of serotonin toxicity from linezolid interactions."),
|
||||
|
||||
new Seed("postpartum-psychosis-emergency-hospitalization", "Behavioral Health", "Psychiatry", "Management",
|
||||
"A 29-year-old woman 8 days after delivery has slept 2 hours total in 3 days. She says her infant is possessed and reports a command voice telling her to purify the baby in the bathtub. She is pacing, euphoric, and refuses to let relatives hold the infant.",
|
||||
"Which is the most appropriate next step?",
|
||||
"Emergency psychiatric hospitalization with protection of mother and infant", "Routine outpatient psychotherapy next week", "Reassurance that postpartum blues is self-limited", "Start an SSRI and leave the infant in her care", "Ask the family to monitor her at home",
|
||||
"Delusions, mania, severe insomnia, and commands to harm an infant indicate postpartum psychosis, a psychiatric emergency with high suicide and infanticide risk. Immediate hospitalization and separation from unsupervised infant care are required.",
|
||||
"Postpartum psychosis requires emergency psychiatric care and immediate safety protection for both mother and infant.", "postpartum delusions with command hallucinations and mania", "tearfulness alone", "postpartum blues does not cause psychosis or dangerous commands",
|
||||
"Screening and Diagnosis of Mental Health Conditions During Pregnancy and Postpartum", "American College of Obstetricians and Gynecologists", "https://www.acog.org/clinical/clinical-guidance/clinical-practice-guideline/articles/2023/06/screening-and-diagnosis-of-mental-health-conditions-during-pregnancy-and-postpartum", "Recognition of acute postpartum psychosis as a perinatal mental health emergency."),
|
||||
|
||||
new Seed("noninferiority-trial-ci-interpretation", "Biostatistics and Epidemiology", "Biostatistics", "Study Interpretation",
|
||||
"A randomized trial compares a shorter antibiotic course with standard therapy. The prespecified noninferiority margin for risk difference (short minus standard) is +5 percentage points, where positive values favor standard therapy. Treatment failure is 10% versus 9%, with a risk difference of +1 percentage point and a 95% confidence interval of -2 to +4.",
|
||||
"Which conclusion is best supported?",
|
||||
"The shorter course is noninferior but superiority is not established", "The shorter course is both noninferior and superior", "Noninferiority is not established because the interval crosses zero", "The treatments are proven equivalent", "The standard course is superior because the point estimate is positive",
|
||||
"The entire confidence interval lies below the prespecified +5% harm margin, establishing noninferiority. Because the interval includes zero, superiority of either treatment is not established; noninferiority does not prove equivalence.",
|
||||
"Noninferiority depends on excluding the harm margin, whereas superiority depends on excluding the null.", "upper confidence bound +4% is below the +5% margin", "the interval crosses zero", "crossing zero prevents superiority but not noninferiority",
|
||||
"CONSORT extension for reporting noninferiority and equivalence randomized trials", "CONSORT Group", "https://www.consort-statement.org/extensions/non-inferiority-and-equivalence-trials", "Interpretation of confidence intervals relative to noninferiority margins and the null."),
|
||||
|
||||
new Seed("verification-bias-diagnostic-study", "Biostatistics and Epidemiology", "Biostatistics", "Bias",
|
||||
"Investigators evaluate a new test for pulmonary embolism. All patients with positive index tests receive CT pulmonary angiography, but only 15% of patients with negative index tests receive the reference standard. The unverified negative patients are classified as disease-free.",
|
||||
"Which bias most directly threatens the reported test accuracy?",
|
||||
"Verification bias", "Recall bias", "Lead-time bias", "Immortal-time bias", "Ecologic fallacy",
|
||||
"Verification bias occurs when receipt of the reference standard depends on the index-test result. Missed disease among unverified test-negative patients can distort sensitivity and specificity.",
|
||||
"A diagnostic study must apply the reference standard independently of the index-test result or account for partial verification.", "reference testing depends on index-test positivity", "retrospective symptom reporting", "classifying unverified negatives as disease-free inflates apparent accuracy",
|
||||
"STARD 2015: An Updated List of Essential Items for Reporting Diagnostic Accuracy Studies", "EQUATOR Network", "https://www.equator-network.org/reporting-guidelines/stard/", "Avoidance and reporting of partial verification in diagnostic accuracy studies."),
|
||||
|
||||
new Seed("cluster-randomized-analysis-unit", "Biostatistics and Epidemiology", "Biostatistics", "Study Design",
|
||||
"Twenty primary care clinics are randomized to a handoff intervention or usual care. Investigators analyze outcomes from 4,000 patients as if every patient were independently randomized and do not adjust for clinic membership.",
|
||||
"What is the most likely consequence of this analytic error?",
|
||||
"Standard errors will usually be underestimated", "The intervention effect will necessarily reverse direction", "Selection bias will be eliminated", "Statistical power will always decrease", "The intracluster correlation will become zero",
|
||||
"Patients within a clinic share clinicians and processes, so their outcomes are correlated. Ignoring clustering treats observations as more independent than they are, commonly underestimating standard errors and inflating type I error.",
|
||||
"Cluster-randomized trials require analyses that account for intracluster correlation.", "randomization occurred at the clinic level", "large patient sample size", "ignoring clusters creates falsely narrow confidence intervals",
|
||||
"CONSORT extension to cluster randomised trials", "CONSORT Group", "https://www.consort-statement.org/extensions/cluster-trials", "Design and analysis requirements for cluster-randomized trials."),
|
||||
|
||||
new Seed("sentinel-event-root-cause-analysis", "Social Sciences and Patient Safety", "Patient Safety", "Systems Improvement",
|
||||
"A patient receives a 10-fold heparin overdose after a pharmacist stocks two look-alike vial concentrations in adjacent automated-dispensing bins and a nurse bypasses barcode scanning during a network outage. The patient has an intracranial hemorrhage.",
|
||||
"Which response best reduces the risk of recurrence?",
|
||||
"Conduct a multidisciplinary root cause analysis and implement monitored system-level corrective actions", "Terminate the nurse and close the investigation", "Remind all staff to be more careful", "Wait to see whether the same event happens again", "Keep the event confidential from the safety office",
|
||||
"A severe preventable harm is a sentinel event. A credible root cause analysis examines interacting system and human factors, leads to strong corrective actions, and monitors whether those actions prevent recurrence.",
|
||||
"Sentinel-event review should identify system causes and produce measurable, monitored corrective actions rather than stop at individual blame.", "multiple interacting medication-system failures causing death or severe harm", "one nurse bypassed a scanner", "punishment alone leaves look-alike stocking and downtime vulnerabilities intact",
|
||||
"Sentinel Event Policy and Procedures", "The Joint Commission", "https://www.jointcommission.org/en-us/knowledge-library/support-center/standards-interpretation/sentinel-event-policy-and-procedures", "Comprehensive systematic analysis and action planning after sentinel events."),
|
||||
|
||||
new Seed("medical-error-disclosure", "Social Sciences and Patient Safety", "Ethics", "Communication",
|
||||
"A physician realizes that she prescribed a drug despite a documented allergy, causing anaphylaxis and an ICU admission. The patient is now stable. The hospital safety team has been notified.",
|
||||
"What should the physician do next?",
|
||||
"Promptly disclose the error, known consequences, and next steps to the patient and apologize", "Wait until the patient asks whether an error occurred", "Describe the event only as an unpredictable allergic reaction", "Ask risk management to speak while she avoids the patient", "Alter the note to remove the allergy documentation",
|
||||
"Patients should be informed promptly and honestly about errors that caused harm, including what is known, the consequences, ongoing care, and prevention efforts. A sincere apology and continued clinical support are appropriate.",
|
||||
"Disclosure of harmful error is an ethical duty and should be timely, factual, compassionate, and paired with ongoing care.", "known preventable medication error with patient harm", "fear of litigation", "concealment undermines autonomy, trust, and safety learning",
|
||||
"Code of Medical Ethics Opinion 8.6: Promoting Patient Safety", "American Medical Association", "https://code-medical-ethics.ama-assn.org/ethics-opinions/promoting-patient-safety", "Ethical duty to disclose medical errors and support affected patients.")
|
||||
};
|
||||
|
||||
var questions = definitions.Select(Build).ToList();
|
||||
var errors = questions
|
||||
.Select(question => (question.ExternalId, Error: QuestionValidator.Validate(question)))
|
||||
.Where(result => result.Error is not null)
|
||||
.ToList();
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
foreach (var error in errors) Console.Error.WriteLine($"{error.ExternalId}: {error.Error}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (questions.Count != 20 || questions.Select(q => q.ExternalId).Distinct().Count() != 20)
|
||||
{
|
||||
Console.Error.WriteLine("Expected exactly 20 unique questions.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
Console.WriteLine($"Validated {questions.Count} difficulty-4 questions.");
|
||||
if (!apply)
|
||||
{
|
||||
Console.WriteLine("Dry run only. Pass --apply to upsert into the configured development database.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["MongoDB:ConnectionString"] = connectionString,
|
||||
["MongoDB:DatabaseName"] = databaseName
|
||||
}).Build();
|
||||
var store = new QuestionStore(new MongoDatabaseProvider(configuration));
|
||||
var upserted = await store.UpsertManyByExternalIdAsync(questions);
|
||||
var saved = await store.GetByExternalIdsAsync(questions.Select(q => q.ExternalId));
|
||||
var invalidSaved = saved.Values.Where(q => q.Difficulty != 4 || !q.IsPublished || q.References.Count == 0).ToList();
|
||||
if (upserted != 20 || saved.Count != 20 || invalidSaved.Count != 0)
|
||||
{
|
||||
Console.Error.WriteLine($"Verification failed: upserted={upserted}, found={saved.Count}, invalid={invalidSaved.Count}.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
Console.WriteLine($"Upserted and verified 20 questions in database '{databaseName}'.");
|
||||
return 0;
|
||||
|
||||
UpsertQuestionRequest Build(Seed seed)
|
||||
{
|
||||
var answers = new[] { seed.Correct, seed.Wrong1, seed.Wrong2, seed.Wrong3, seed.Wrong4 };
|
||||
var ids = new[] { "A", "B", "C", "D", "E" };
|
||||
var choices = ids.Zip(answers, (id, text) => new QuestionChoice { Id = id, Text = text }).ToList();
|
||||
var explanations = ids.ToDictionary(
|
||||
id => id,
|
||||
id => id == "A"
|
||||
? seed.Explanation
|
||||
: $"This choice does not address the decisive finding: {seed.Discriminator}. {seed.Pitfall2}.");
|
||||
var wrongFollowups = ids.Skip(1).ToDictionary(
|
||||
id => id,
|
||||
id => new WrongAnswerFollowup
|
||||
{
|
||||
Prompt = $"In this same vignette, which finding most strongly favors {seed.Correct} over {choices.Single(c => c.Id == id).Text}?",
|
||||
Choices =
|
||||
[
|
||||
new() { Id = "1", Text = seed.Discriminator },
|
||||
new() { Id = "2", Text = seed.Pitfall1 },
|
||||
new() { Id = "3", Text = "The patient's age by itself" }
|
||||
],
|
||||
CorrectChoiceId = "1",
|
||||
TeachingPoint = seed.Takeaway
|
||||
});
|
||||
|
||||
return new UpsertQuestionRequest
|
||||
{
|
||||
ExternalId = $"gap-d4-20260726-{seed.Slug}",
|
||||
Stem = seed.Stem,
|
||||
LeadIn = seed.LeadIn,
|
||||
Choices = choices,
|
||||
CorrectChoiceId = "A",
|
||||
Explanation = seed.Explanation,
|
||||
KeyTakeaway = seed.Takeaway,
|
||||
UsmlePearl = seed.Pitfall2,
|
||||
ReviewFollowups =
|
||||
[
|
||||
new()
|
||||
{
|
||||
Prompt = $"Which principle is most important for the patient in this vignette?",
|
||||
Choices =
|
||||
[
|
||||
new() { Id = "1", Text = seed.Takeaway },
|
||||
new() { Id = "2", Text = seed.Pitfall1 },
|
||||
new() { Id = "3", Text = "Delay intervention until every possible diagnosis is excluded" }
|
||||
],
|
||||
CorrectChoiceId = "1",
|
||||
TeachingPoint = seed.Takeaway
|
||||
}
|
||||
],
|
||||
ChoiceExplanations = explanations,
|
||||
WrongAnswerFollowups = wrongFollowups,
|
||||
ChoiceSelectionPercentages = [],
|
||||
Discipline = seed.Discipline,
|
||||
System = seed.System,
|
||||
PerformanceCategory = seed.Category,
|
||||
Difficulty = 4,
|
||||
Labs = [],
|
||||
Media = [],
|
||||
Tags = [seed.Slug.Replace('-', ' '), "difficulty 4", "gap coverage", seed.Category.ToLowerInvariant()],
|
||||
References =
|
||||
[
|
||||
new()
|
||||
{
|
||||
Title = seed.ReferenceTitle,
|
||||
Publisher = seed.Publisher,
|
||||
Url = seed.Url,
|
||||
Supports = seed.Supports,
|
||||
AccessedOn = accessedOn,
|
||||
Reviewer = "OpenAI Codex medical content review"
|
||||
}
|
||||
],
|
||||
IsPublished = true
|
||||
};
|
||||
}
|
||||
|
||||
record Seed(
|
||||
string Slug, string System, string Discipline, string Category, string Stem, string LeadIn,
|
||||
string Correct, string Wrong1, string Wrong2, string Wrong3, string Wrong4,
|
||||
string Explanation, string Takeaway, string Discriminator, string Pitfall1, string Pitfall2,
|
||||
string ReferenceTitle, string Publisher, string Url, string Supports);
|
||||
@@ -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=\"uat\"|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})."
|
||||
+1
-1
@@ -32,7 +32,7 @@ WORKDIR /src
|
||||
COPY . .
|
||||
RUN mkdir -p /build \
|
||||
&& sed -i "s|build_revision=\"development\"|build_revision=\"${BUILD_REVISION}\"|" project.godot \
|
||||
&& sed -i "s|deployment_environment=\"development\"|deployment_environment=\"${DEPLOYMENT_ENVIRONMENT}\"|" project.godot \
|
||||
&& sed -i "s|deployment_environment=\"uat\"|deployment_environment=\"${DEPLOYMENT_ENVIRONMENT}\"|" project.godot \
|
||||
&& sed -i "s|https://us3q.ranaze.com|${PUBLIC_API_ORIGIN}|g" project.godot \
|
||||
&& sed -i "s|custom_template/release=\"\"|custom_template/release=\"/root/.local/share/godot/export_templates/${GODOT_TEMPLATE_DIR}/web_nothreads_release.zip\"|" export_presets.cfg \
|
||||
&& godot --headless --editor --path /src --quit \
|
||||
|
||||
@@ -180,6 +180,8 @@
|
||||
letter-spacing: 0;
|
||||
line-height: 1;
|
||||
margin: 12px 0 0;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.wordmark-rana {
|
||||
@@ -207,7 +209,10 @@
|
||||
font-size: clamp(20px, 2.3vw, 28px);
|
||||
font-weight: 430;
|
||||
letter-spacing: 0;
|
||||
line-height: 1.3;
|
||||
margin: 17px 0 clamp(36px, 6vh, 54px);
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.progress-row {
|
||||
@@ -239,6 +244,7 @@
|
||||
font-size: 22px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 650;
|
||||
line-height: 1.3;
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
@@ -246,8 +252,10 @@
|
||||
color: var(--muted);
|
||||
font-size: 19px;
|
||||
letter-spacing: 0;
|
||||
line-height: 1.3;
|
||||
margin: 24px 0 0;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#status-notice {
|
||||
|
||||
Reference in New Issue
Block a user