Author SHA1 Message Date
hz 22b0df7aed Add isolated question completion credits 2026-07-28 18:40:49 -05:00
hz 1d5d03a4f6 Add difficulty 4 question gap batch 2026-07-26 16:32:41 -05:00
hzandClaude Sonnet 5 efe60f36c5 Fix dashboard info-icon rendering and profile <null> display
Info badge: the icon was a PanelContainer using StyleBoxFlat corner
radius to fake a circle, with the radius exceeding half the box size,
causing visible artifacts. Replaced with a custom-drawn Control
(draw_arc) for a clean ring at any size, and added a hover tooltip
explaining what each stat card measures.

Profile: Dictionary.get(key, default) only falls back when the key is
missing, not when it's present with a null value, so a null
displayName rendered as the literal string "<null>" (str(null) in
GDScript) - including pre-filling the edit-profile dialog with it.
Added a shared string_field() helper that treats null the same as
missing, applied everywhere an account/preference string field is
displayed or edited.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-07-26 16:27:46 -05:00
hzandClaude Sonnet 5 f680024dc3 Rewrite the donation screen's mission statement
Repositions the copy around being an independent, at-cost alternative
to the Step 3 prep industry's subscription funnels, and broadens the
audience from residents specifically to any Step 3 test taker.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-07-26 16:19:21 -05:00
hz 94e0e5dd70 Confirm passwords for external account creation 2026-07-26 14:52:41 -05:00
hzandClaude Sonnet 5 74398133f7 Exclude sample exam simulations from the dashboard's readiness score
The SUPER-role "preview results" QA tooling seeds a completed
ExamSimulation with scripted (not real) answers, flagged IsSample.
The dashboard's readiness lookup picked whichever completed
simulation was most recent regardless of that flag, so running the
QA preview on an account could override its real Exam Readiness card
with a synthetic pass probability. Recent-activity/weekly-activity
queries got the same exclusion since they read from the same
collection for the same reason.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-07-26 14:47:07 -05:00
hz 447f080552 Add ready question production promotion 2026-07-26 01:17:55 -05:00
hzandClaude Sonnet 5 ef44347bf6 Fix loader tagline centering and dashboard card icons
Centers the loader's tagline under the logo, replaces the broken
info-glyph (a Unicode character missing from the Inter font, which
rendered as a tofu box) with a drawn circle-and-i badge, and swaps
the stat-card icons to match the mockup: book for Question Bank
Rating, bar chart for Exam Readiness, star for Average Score.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-07-26 01:06:05 -05:00
hz 4251885a73 Redesign dashboard top-4 stats as separate cards
Frontend half of the dashboard stat-card redesign, matching the
provided mockup. The 4 metrics used to be plain rows sharing one
bordered box (_add_metric -> a single metric_grid inside one _card).
Replaced with 4 independently-_card-wrapped panels in a responsive
GridContainer (4 cols / 2 compact, same threshold _update_responsive
already used).

Each card now has: a colored icon badge, title + info glyph, a big
value, a colored trend/status line, a small visual, and a footer
caption:
- Question Bank Rating / CCS Rating: a new minimal DashboardSparkline
  (dashboard_sparkline.gd) fed from the current rating and the
  week-ago snapshot (reconstructed client-side from the delta the API
  already returns - no separate history endpoint needed). Trend line
  and caption come from the new delta/percentile fields; both degrade
  to a plain single-point sparkline and a rated-count caption when no
  snapshot exists yet.
- Exam Readiness: a new DashboardSegmentBar (dashboard_segment_bar.gd -
  nothing like a segmented pill progress indicator existed anywhere in
  the codebase) instead of a sparkline, colored by the High/Medium/Low
  label; shows a neutral "complete a simulation" state when the user
  has none yet rather than a fabricated readiness.
- Average Score: reuses the existing ProgressBar/_style_progress,
  relabeled from "First-Attempt Accuracy".

Dropped the old "Study Time" card (not part of the mockup - the
underlying data still drives the weekly activity chart below, it just
no longer gets a dedicated top-line number) and the now-fully-unused
_format_duration.

Verified headlessly: instantiated the real screen against fake
DashboardService data, confirmed every card's value/trend/caption text
and the segment-bar/progress-bar values match the mockup exactly, and
rendered actual screenshots of both the populated and "no data yet"
fallback states.
2026-07-26 00:43:39 -05:00
hz 34d283e213 Add rating snapshots, percentile, and exam readiness to the dashboard API
Backend half of the dashboard stat-card redesign. Three new data
points the old DashboardResponse had no way to compute:

- Week-over-week rating deltas: UserRating/CcsUserRating only ever
  stored the current Elo (mutated in place, UpdatedUtc just means
  "last touched"), so there was no way to know what a rating was 7
  days ago. Added UserRatingSnapshot (new collection, unique on
  UserId+DateUtc) and UserRatingSnapshotService, a BackgroundService
  following AnswerStatisticsMaintenanceService's exact shape (a
  PeriodicTimer plus a Mongo lease so replicas behind the HPA don't
  race), writing one snapshot per user per day. No historical
  backfill - deltas are simply null for a user's first ~7 days after
  this ships, which the DTO represents honestly via nullable ints
  rather than a fake "+0".
- Percentile rank: DashboardStore now counts how many users have a
  higher OverallElo/Elo than the current user out of the total.
- Exam Readiness + projected pass rate: reuses the existing, tested
  IRT-based ExamSimulationScore.PassProbability (a real normal-CDF
  estimate of scoring >=200, computed in ExamSimulationScoring.Calculate)
  rather than inventing a new prediction model. DashboardStore pulls
  the latest completed simulation's score directly and maps
  PassProbability to a High/Medium/Low label via tunable thresholds.
- Average Score's "+6% vs last 100" needed no new data source at all -
  just one more slice (Skip(100).Take(100)) over the QuestionAttempts
  already loaded for FirstAttemptAccuracy.

Added DashboardStore.Percentile/ReadinessLabel as pure static helpers
(matching the existing Percentage/CalculateCurrentStreak pattern) and
unit tests for both. Full backend suite: 165 passed, 0 failed.
2026-07-26 00:39:11 -05:00
hz 500477b612 Add dashboard performance trends 2026-07-25 22:43:34 -05:00
hz 46953ee71a Reach Settings/Profile from the pause menu, with a way back
The pause menu (Escape during a question/CCS/flashcards run) only had
Resume/UI Sounds/Exit Run/Log Out. Settings already has an equivalent
"Interface sounds" toggle, so drop the duplicate from the pause menu.
More importantly, "player" and "ccs_player" hide the entire app shell
(header + sidebar), so there was no way to reach Settings or Profile
at all mid-session - add Profile/Settings buttons to the pause menu to
close that gap.

Navigating there follows the existing return_screen payload
convention (already used by simulation_history/results/review) rather
than inventing new navigation infrastructure: _navigate_from_pause()
remembers current_screen_name and passes it through show_screen's
payload. Both screens show a "Back to session" button when that's
set, and hide it otherwise (including on ordinary non-paused visits).

show_screen() unconditionally resumes the question timer via
_close_app_menu() as a side effect of any navigation, so
_navigate_from_pause() re-pauses it immediately when leaving "player" -
the timer then resumes for real, for free, when the user actually
returns via show_screen(return_screen).

progress_profile.gd rebuilds its whole child tree on every theme
toggle (_rebuild -> _build), which would otherwise wipe the button's
visibility - added _refresh_back_to_session_visibility(), called both
after _build() and from on_show(), so it survives a theme change while
paused.

Flipped tests/web_responsive_test.gd's assertion, which previously
required the pause menu NOT to have a Profile button.

Verified end-to-end headlessly: opening the pause menu from a live
"player" session, navigating to Settings and Profile, confirming
current_question_pause_started_at is correctly paused while away and
resumed on return, confirming the back-to-session button appears only
when reached via pause (not on ordinary nav), and confirming it
survives a dark/light theme toggle on the Profile screen.
2026-07-25 22:30:03 -05:00
hz aa5497c516 Center the legal consent row instead of left-aligning it
Wrapped the row in a CenterContainer so the checkbox+text sit centered
under the field column instead of hugging the left edge. Had to drop
the RichTextLabel's EXPAND_FILL (meaningless inside a CenterContainer,
which never stretches its child) in favor of an explicit 480px
minimum width - without one, the label collapsed toward zero width
and wrapped character-by-character, which is what the first attempt
at this actually rendered as before I caught it.

Verified with a direct coordinate check: the row's horizontal center
now matches the confirm-password field's center exactly (delta 0.0).
2026-07-25 11:00:48 -05:00
hz 6b1e41c187 Fix loading-screen sizing shift from the Inter font swap
font-display: swap paints with the fallback font stack first, then
swaps to Inter once the 876KB variable font finishes downloading.
.loader-wordmark was already width-fixed from an earlier centering
fix, but .loader-tagline, .loader-message, and .progress-percent had
no explicit width and (more importantly) used line-height: normal,
which is derived from each font's own metrics - Inter and the fallback
stack disagree by 3-4px per line. Those three deltas summed exactly to
the .loader column's total 10px height change, which is what visibly
shifted the whole centered loading screen once the swap happened.

Gave the width-dependent elements width: 100% (matching the wordmark)
and gave all three text elements an explicit unitless line-height
(computed from font-size, not font metrics) so their box size no
longer depends on which font is currently active.

Verified empirically: forced the fallback font stack on the live page
and diffed getBoundingClientRect() against Inter for every loader
element - all width/height deltas are 0 after the fix (previously up
to 17.5px width / 10px cascaded height).
2026-07-25 10:53:53 -05:00
hz eb9e018339 Fix non-square consent checkbox with a dedicated Panel square
Root cause: CheckBox/Button reserves vertical space for a text
baseline even with an empty string, so no amount of custom_minimum_size
or stylebox content-margin tweaking produced a true square - it just
came out close-but-not-quite in some layouts and visibly elongated in
others.

Decoupled the visual square from the CheckBox entirely: a plain Panel
(exactly 20x20, toggled by direct click) now draws the border/fill,
while a real (invisible) CheckBox still holds .button_pressed for the
rest of the code to read. Also added left padding on the row and
vertically centered both the square and the consent text within it.

Verified with a direct size dump (20x20 in both checked/unchecked
states, where it previously measured non-square) and rendered
screenshots of both states.
2026-07-25 10:45:45 -05:00
hz bff16cea98 Fix oversized, borderless consent checkbox
The generic "style every Button" pass in _apply_theme also matched
CheckBox (it's a Button subtype) and ran after the checkbox-specific
styling, overwriting the intended blue border with the plain default
button box - and that box's own content margins were inflating the
checkbox well past its 26x26 minimum (actual rendered size was 60x52).
Skip CheckBox in that generic loop, zero out the checkbox stylebox's
content margins, shrink it to 22x22, and stop it stretching to the
row's full height. Also sized the consent text down (15->13) and gave
the row some vertical breathing room.

Verified with a rendered screenshot and a direct stylebox/size dump:
checkbox is now 22x24 with a real 2px blue border, matching the
provided reference.
2026-07-25 10:26:35 -05:00
hz 5a2d825181 Rework legal consent to match the provided design
Drop the bordered card - it wasn't the right direction. Instead:
- Plain small checkbox with just a blue border (no fill/icon when
  unchecked), matching the reference exactly.
- "Terms of Service"/"Privacy Policy" render as inline blue links
  within the sentence (RichTextLabel + BBCode [url], routed through
  the same _open_legal_document as the footer), instead of a duplicate
  link row underneath. meta_underlined = false to match the
  reference's plain (non-underlined) link style.

Verified with real rendered screenshots at each step, including a
pixel-level crop confirming the underline was actually gone.
2026-07-25 10:13:18 -05:00
hz 7e977e0d30 Clean up the legal consent checkbox layout
A bare CheckBox's word-wrap left-aligns every wrapped line flush with
the checkbox glyph, so "Privacy Policy." on its own line read as
unindented and sloppy next to an otherwise fully themed form. Split
the toggle from its label (a separate Label wraps within its own
column instead, so wrapped lines stay aligned under the first line)
and wrapped the row in a bordered card matching the app's field
styling. Clicking the label also toggles the checkbox now, for a
bigger hit target.

Verified with an actual rendered screenshot: wrapped text now indents
consistently, and both checked/unchecked states are legible.
2026-07-25 09:52:41 -05:00
hz ba8e0ec256 Remove duplicate Terms/Privacy links from account creation
Register and Steam-linking forms rendered their own Terms of
Service/Privacy Policy links right under the consent checkbox, on top
of the ones already pinned to the bottom of the panel. The consent
checkbox text alone is enough; the persistent footer covers the links.
2026-07-25 09:36:06 -05:00
hz 7be83661f9 Standardize password reveal to the in-field eye icon everywhere
Register, reset, and Steam-linking forms used a separate "Show
password" CheckButton row; the main login form already had the eye
icon embedded in the field itself. Generalized _add_password_reveal
(previously hardwired to a single tracked button) to support multiple
fields via password_reveal_entries, and used it for every password
field instead. Removed the now-unused _add_show_password.

Verified all 6 password fields (login, register x2, reset x2, steam)
get a working reveal button and there's no leftover "Show password"
checkbox anywhere.
2026-07-25 09:34:44 -05:00
hz bb9f59b3cc Center STEP 3 TRAINER under the RanaZe wordmark
.loader-wordmark had no explicit width or text-align, so as a flex
item (align-items: center) it shrink-wrapped to whatever width the
"RanaZe" inline spans needed. wordmark-trainer's display:block then
filled that same ambiguous width rather than the wordmark block's
true rendered box, leaving it off-center relative to "RanaZe" above
it. Giving .loader-wordmark an explicit width:100% and text-align:
center makes both lines center within the same known-width box.

Verified in a real browser: both elements now report identical
getBoundingClientRect() centers (x=960 in a 1920px viewport).
2026-07-25 09:14:27 -05:00
hz 854c887a1a Center login panel content, pin legal links to the bottom
Making the auth panel a fixed height (to kill the scrollbar) left the
shorter views (login, reset) stranded at the top with dead space below.
Split panel_content into a main_column (header, message, mode views -
expand-fill, center-aligned) and the legal footer as a sibling that
stays pinned to the panel's bottom edge regardless of which view is
showing or how tall its content is.

Verified headlessly: top/bottom whitespace above and below the active
view's content is now equal (e.g. login 174/174, steam 2/2), and the
footer's position doesn't move between modes.
2026-07-25 09:10:19 -05:00
hz ce4db7cec8 Remove the login panel scrollbar
The auth panel was a fixed 710x820 (640x790 on narrower desktop
windows) regardless of which view was showing, but Register and the
Steam linking form need up to ~990px to lay out without scrolling -
so the inner ScrollContainer was always kicking in for those views.

- Tightened form spacing (_new_form separation 16->10, field height
  68->56, panel_content separation 24->16, page margins 56->28) to
  shrink how much height each view actually needs.
- Size the panel to the tallest view's real requirement (1000/1010),
  clamped to whatever height the window actually has so a short window
  shrinks the panel - and lets the ScrollContainer take over as a
  fallback - instead of the panel overflowing off-screen.

Verified headlessly across 1920x1080, 1366x768, and a narrow
compact-desktop width: no scrollbar in any mode at the two common
resolutions, and the panel now stays within the window (falling back
to scrolling only for the two tallest forms on the 768px-tall case,
rather than clipping off both edges of the screen as a fixed height
would have).
2026-07-25 00:52:12 -05:00
hz 191aa685fa Fix shadowed category_label warning in the report-question form
A local category_label in _build_report_modal() shadowed the
class-level category_label field (the question's topic tag near the
stem). Renamed the local to report_category_label.
2026-07-25 00:52:02 -05:00
hz 65ae99e2ce Update exe boot splash to the current shield logo
boot_splash/image was still on ranaze-loader-icon-v1.png, the mark
from before the app rebranded to the shield "RZ" logo (c91e70d). Point
it at assets/branding/ranaze-mark.png, the same mark already used for
the in-app header brand throughout the current UI.
2026-07-25 00:31:57 -05:00
hz b7b290c63b Fix deployment_environment default so the debugger reads UAT
project.godot's API URLs already pointed at UAT (us3q.ranaze.com), but
deployment_environment itself was still "development" - a leftover
label that never matched the actual origin. Since the UAT-only feature
gates (Dynamic Questions, CCS Cases, Flashcards) check for the literal
"uat" value, running from the Godot editor/debugger hid all three even
though it's UAT in every other respect.

Set it to "uat" to match reality, and update the two places that
sed-patch this value at build time (web/Dockerfile for the UAT/PRD web
builds, tools/export_steam_release.sh for the Steam build) so they
still match the source string they're replacing.
2026-07-25 00:30:14 -05:00
hz c1a71b1192 Feature-gate Flashcards to UAT only
Same treatment as Dynamic Questions and CCS Cases: hide the nav
sidebar entry and the dashboard's "flashcards due" review card unless
step3/deployment_environment is "uat".
2026-07-25 00:30:06 -05:00
hz f3a2e65627 Skip UAT/PRD deploys for changes that don't touch the deployable
Docs, local release tooling (tools/), and the GDScript test suite
(never executed by this pipeline, and irrelevant to the API image,
the exported web build, or k8s manifests) don't need a full deploy.
workflow_dispatch still allows a manual run for any of these if ever
needed.
2026-07-24 23:36:09 -05:00
hz bca31bd142 Add a Steam release export script pointed at production
project.godot stays checked in against UAT (us3q.ranaze.com) so the
Godot editor/debugger keeps testing against UAT. This script patches a
local copy to prd/s3q.ranaze.com just for the Windows Desktop export -
the same swap web/Dockerfile already does for UAT vs PRD web builds -
runs the export, then always restores the original file.

Verified: after a run, `git diff project.godot` is clean and the
exported .pck contains s3q.ranaze.com with no trace of us3q.ranaze.com.
2026-07-24 23:32:48 -05:00
hz 0e473343d0 Fix stale web_responsive_test icon assertion
It checked for a "DynamicQuestionsIcon" mode card that no longer
exists in study_selector.gd (that mode moved to its own nav item and
screen a while back), so it always failed. CCS Cases is now also
UAT-gated and won't be present in the default test environment either,
so point the check at Question Bank, the one mode card that's always
rendered.
2026-07-24 23:17:59 -05:00
hz 6df0a88fd4 Feature-gate Dynamic Questions and CCS Cases to UAT only
Both are still in progress and shouldn't be reachable in production
(or local dev) yet - hide every entry point (nav sidebar, the
dashboard's "dynamic concepts due" review card, and the CCS Cases
mode card on the study selector) unless step3/deployment_environment
is "uat", the same setting that already drives the UAT badge. Added
Brand.is_uat_environment() as the shared check.
2026-07-24 23:12:47 -05:00
hz 118c215608 Add a setting to turn off follow-up questions
The optional "FOLLOW-UP QUESTIONS" reinforcement section shown during
answer review is now gated behind a Settings toggle (on by default),
persisted locally the same way as dark mode and interface sounds.
2026-07-24 23:01:06 -05:00
hz cd1648fe38 Shorten "Skip question" button to "Skip" 2026-07-24 22:14:24 -05:00
hz f1b290aa73 Dock the question toolbar in line with the brand row
The toolbar had followed the progress group down into the row below
the branding after it moved there - readers expect actions docked to
the top brand row, not hanging off the question count.
2026-07-24 22:10:49 -05:00
hz 50f54b1bec Polish question player header/toolbar and add configurable block size
- Fix the toolbar wrapping onto two rows: its minimum width was tuned
  for an older, narrower button set and never widened back out after
  reverting to bare icon buttons.
- Drop the "Time elapsed"/"Ctrl+Enter" readout next to Submit - it was
  clutter; the underlying timer logic keeps running unseen.
- Move the RanaZe brand mark above the question-count row instead of
  sharing a line with it.
- Progress text drops the word "Question" everywhere ("1 of 10"), and
  core/dynamic sessions now cycle against a configurable block size
  (Settings > Question block size, default 10) instead of counting up
  forever.
2026-07-24 21:57:03 -05:00
hz 62e65fd22c Add Windows Desktop export with RanaZe branding
Replace the default Godot boot splash and exe icon with the RanaZe
mark so a Windows build reads as RanaZe from first launch, not the
engine it's built on.
2026-07-24 21:56:53 -05:00
hz 5197234210 Drop toolbar text labels in favor of hover tooltips
Varying label widths (especially when a button hides itself, e.g.
"Labs" on a question with no lab results) broke even spacing between
icons. Every toolbar button already has a tooltip_text carrying the
same label as hover text, so drop the labels/wrapper containers
entirely and go back to plain, uniformly-spaced 44x44 icon buttons.
2026-07-24 21:05:34 -05:00
hz 576b48d581 Fix toolbar gaps and theme toggle shape
- A toolbar icon that hides itself at runtime (labs, notes,
  flashcard, bookmark) left its text label behind, showing as an
  orphaned label floating in a gap. The label's wrapper now tracks
  the button's own visibility.
- The theme toggle used a distinct circular pill shape; changed it
  to match the same rounded-square box every other toolbar icon uses.
2026-07-24 19:55:49 -05:00
hz d073f82a76 Fit the toolbar on a single row
The 11 labeled groups are wider than the old bare-icon buttons, so
the toolbar's allotted minimum width (tuned for the old layout) was
too narrow and forced a wrap onto two rows. Widened it and raised
the compact-mode breakpoint to match.
2026-07-24 03:23:14 -05:00
hz a94cc5188f Fix bottom-bar time caption for core sessions
Core sessions show a per-question stopwatch that counts up (no fixed
deadline, just a pacing signal), not a countdown - it was incorrectly
labeled "Time remaining" like the simulation/Timed countdown; now
reads "Time elapsed" to match what the number actually does.
2026-07-24 03:16:30 -05:00
hz 0902cc238a Un-collapse the question toolbar - show every action directly
The 6-item + "More" overflow popup made the toolbar feel sparse and
hid frequently-used actions (flag, report, flashcard, theme) behind
an extra click. Reverting to a flat toolbar where every action is
directly visible and labeled, including text size (grouped A-/A+
under one "Text size" caption instead of behind a popup).
2026-07-24 03:03:40 -05:00
hz 974de67032 Redesign question bank screen to match new mockup
Adapts the quiz screen's header, toolbar, choice cards, and bottom bar
to the designer's new layout:

- Header: adds the RanaZe logo/wordmark and a question-progress bar +
  percentage alongside the existing "Question N of M" text.
- Toolbar: consolidates 12 icon-only/chip buttons into 6 labeled items
  (Text size, Calculator, Clear highlight, Notes, Bookmark, More), with
  a new "More" popup holding the rest (labs, flag, report, flashcard,
  theme toggle, feedback). The underlying buttons are reparented, not
  duplicated, so every existing theming/visibility/state call site
  keeps working unmodified.
- Category tag: shows the question's discipline (falling back to
  system) above the stem.
- Choice cards: every choice now renders as a subtly-bordered rounded
  card even before answering, not just a flat divider row; the
  currently-selected pre-submit choice shows a checkmark.
- Bottom bar: merges the Skip/Submit row and the timer row into one -
  Skip as a plain link, a centered "X of Y" progress bar, and a
  time slot that shows the existing countdown when a limit applies or
  a new count-up stopwatch ("Time elapsed") otherwise, plus a
  Ctrl+Enter/Cmd+Enter submit shortcut with a visible hint.
2026-07-24 02:28:22 -05:00
53 changed files with 2284 additions and 235 deletions
+4
View File
@@ -4,6 +4,10 @@ on:
push:
branches:
- production
paths-ignore:
- '**.md'
- 'tools/**'
- 'tests/**'
workflow_dispatch: {}
concurrency:
+4
View File
@@ -4,6 +4,10 @@ on:
push:
branches:
- main
paths-ignore:
- '**.md'
- 'tools/**'
- 'tests/**'
workflow_dispatch: {}
concurrency:
+12 -13
View File
@@ -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
+5 -5
View File
@@ -57,9 +57,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
@@ -69,11 +69,11 @@ The hostname change creates a new browser origin, so testers should expect to si
7. Configure and test the live Stripe webhook and 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
@@ -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
};
}
@@ -0,0 +1,21 @@
using QuestionsApi.Models;
using Xunit;
namespace QuestionsApi.Tests;
public sealed class CreditEconomyTests
{
[Fact]
public void EarningPolicy_UsesTheAgreedRollingLimit()
{
Assert.Equal(20, CreditEconomy.RollingEarningLimit);
Assert.Equal(TimeSpan.FromHours(24), CreditEconomy.EarningWindow);
}
[Fact]
public void CreditStatus_DoesNotExposeUserOrOperationIdentifiers()
{
Assert.Null(typeof(CreditStatusResponse).GetProperty(nameof(UserCreditAccount.UserId)));
Assert.Null(typeof(CreditStatusResponse).GetProperty(nameof(UserCreditAccount.RecentEarnings)));
}
}
+97 -1
View File
@@ -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,20 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using QuestionsApi.Services;
using System.Security.Claims;
namespace QuestionsApi.Controllers;
[ApiController]
[Route("api/credits")]
[Authorize(Roles = "USER,SUPER")]
public sealed class CreditsController(CreditStore credits) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> Get() => Ok(await credits.GetStatusAsync(UserId()));
private string UserId() => User.FindFirstValue(ClaimTypes.NameIdentifier)
?? User.FindFirstValue(ClaimTypes.Name)
?? User.Identity?.Name
?? throw new UnauthorizedAccessException();
}
@@ -19,6 +19,7 @@ public class QuestionsController : ControllerBase
private readonly ServiceMetrics _metrics;
private readonly QuestionBookmarkStore _bookmarks;
private readonly ReviewQueueStore _reviewQueue;
private readonly CreditStore _credits;
public QuestionsController(
QuestionStore questions,
@@ -28,7 +29,8 @@ public class QuestionsController : ControllerBase
AnswerStatisticsStore statistics,
ServiceMetrics metrics,
QuestionBookmarkStore bookmarks,
ReviewQueueStore reviewQueue)
ReviewQueueStore reviewQueue,
CreditStore credits)
{
_questions = questions;
_attempts = attempts;
@@ -38,6 +40,7 @@ public class QuestionsController : ControllerBase
_metrics = metrics;
_bookmarks = bookmarks;
_reviewQueue = reviewQueue;
_credits = credits;
}
[HttpGet("systems")]
@@ -172,6 +175,8 @@ public class QuestionsController : ControllerBase
_metrics.AttemptDuplicate();
}
await _reviewQueue.AddFromFirstAttemptAsync(record.Attempt);
if (record.Inserted && !User.IsInRole("SUPER"))
await _credits.AwardForFirstAnswerAsync(userId, record.Attempt.Id ?? "");
var ratingOwner = Guid.NewGuid().ToString("N");
var shouldRate = await _attempts.TryAcquireRatingLeaseAsync(
@@ -204,7 +209,8 @@ public class QuestionsController : ControllerBase
question,
await _statistics.GetPercentagesForQuestionAsync(question.ExternalId)),
QuestionElo = rating.QuestionElo,
Rating = rating
Rating = rating,
Credits = await _credits.GetStatusAsync(userId)
});
}
+2
View File
@@ -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;
}
+35
View File
@@ -0,0 +1,35 @@
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
namespace QuestionsApi.Models;
public static class CreditEconomy
{
public const int RollingEarningLimit = 20;
public static readonly TimeSpan EarningWindow = TimeSpan.FromHours(24);
}
public sealed class CreditEvent
{
public string OperationId { get; set; } = "";
public DateTime CreatedUtc { get; set; }
}
public sealed class UserCreditAccount
{
[BsonId]
[BsonRepresentation(BsonType.ObjectId)]
public string? Id { get; set; }
public string UserId { get; set; } = "";
public int Credits { get; set; }
public List<CreditEvent> RecentEarnings { get; set; } = [];
public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow;
}
public sealed class CreditStatusResponse
{
public int Credits { get; set; }
public int EarnedInRollingWindow { get; set; }
public int RemainingEarningCapacity { get; set; }
public int RollingWindowHours { get; set; } = 24;
}
@@ -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
@@ -141,6 +141,7 @@ public class FirstQuestionAttemptResponse
public Dictionary<string, int> ChoiceSelectionPercentages { get; set; } = [];
public int QuestionElo { get; set; } = 1000;
public RatingChangeResponse? Rating { get; set; }
public CreditStatusResponse? Credits { get; set; }
}
public class SkipQuestionResponse
+8
View File
@@ -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,8 @@ builder.Services.Configure<ForwardedHeadersOptions>(options =>
});
builder.Services.AddSingleton<MongoDatabaseProvider>();
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddSingleton<CreditStore>();
builder.Services.AddSingleton<QuestionStore>();
builder.Services.AddSingleton<QuestionAttemptStore>();
builder.Services.AddSingleton<AnswerStatisticsStore>();
@@ -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)
+78
View File
@@ -0,0 +1,78 @@
using MongoDB.Bson;
using MongoDB.Driver;
using QuestionsApi.Models;
namespace QuestionsApi.Services;
public sealed class CreditStore
{
private readonly IMongoCollection<UserCreditAccount> _accounts;
private readonly TimeProvider _clock;
public CreditStore(MongoDatabaseProvider mongo, TimeProvider clock)
{
_accounts = mongo.Database.GetCollection<UserCreditAccount>("UserCreditAccounts");
_clock = clock;
_accounts.Indexes.CreateOne(new CreateIndexModel<UserCreditAccount>(
Builders<UserCreditAccount>.IndexKeys.Ascending(value => value.UserId),
new CreateIndexOptions { Unique = true, Name = "uq_credit_user" }));
}
public async Task<bool> AwardForFirstAnswerAsync(string userId, string attemptId)
{
if (string.IsNullOrWhiteSpace(attemptId)) return false;
var now = _clock.GetUtcNow().UtcDateTime;
await EnsureAndCleanAsync(userId, now);
var filters = Builders<UserCreditAccount>.Filter;
var belowCap = new BsonDocumentFilterDefinition<UserCreditAccount>(
new BsonDocument("$expr", new BsonDocument("$lt", new BsonArray
{
new BsonDocument("$size", "$RecentEarnings"),
CreditEconomy.RollingEarningLimit
})));
var filter = filters.Eq(value => value.UserId, userId) &
filters.Not(filters.ElemMatch(value => value.RecentEarnings,
earning => earning.OperationId == attemptId)) &
belowCap;
var update = Builders<UserCreditAccount>.Update
.Inc(value => value.Credits, 1)
.Push(value => value.RecentEarnings, new CreditEvent
{
OperationId = attemptId,
CreatedUtc = now
})
.Set(value => value.UpdatedUtc, now);
return (await _accounts.UpdateOneAsync(filter, update)).ModifiedCount == 1;
}
public async Task<CreditStatusResponse> GetStatusAsync(string userId)
{
var now = _clock.GetUtcNow().UtcDateTime;
await EnsureAndCleanAsync(userId, now);
var account = await _accounts.Find(value => value.UserId == userId).FirstAsync();
return new CreditStatusResponse
{
Credits = account.Credits,
EarnedInRollingWindow = account.RecentEarnings.Count,
RemainingEarningCapacity = Math.Max(
0, CreditEconomy.RollingEarningLimit - account.RecentEarnings.Count)
};
}
private async Task EnsureAndCleanAsync(string userId, DateTime now)
{
await _accounts.UpdateOneAsync(
value => value.UserId == userId,
Builders<UserCreditAccount>.Update
.SetOnInsert(value => value.UserId, userId)
.SetOnInsert(value => value.UpdatedUtc, now),
new UpdateOptions { IsUpsert = true });
var cutoff = now - CreditEconomy.EarningWindow;
await _accounts.UpdateOneAsync(
value => value.UserId == userId,
Builders<UserCreditAccount>.Update.PullFilter(
value => value.RecentEarnings,
earning => earning.CreatedUtc <= cutoff));
}
}
+80 -12
View File
@@ -19,6 +19,8 @@ public sealed class DashboardStore
private readonly FlashcardStudyService _flashcards;
private readonly FlashcardScheduleStore _flashcardSchedules;
private readonly RatingStore _ratingStore;
private readonly CcsRatingStore _ccsRatingStore;
private readonly IMongoCollection<UserRating> _ratings;
public static readonly string[] Step3Systems =
[
@@ -45,7 +47,8 @@ public sealed class DashboardStore
DynamicQuestionStore dynamicQuestions,
FlashcardStudyService flashcards,
FlashcardScheduleStore flashcardSchedules,
RatingStore ratingStore)
RatingStore ratingStore,
CcsRatingStore ccsRatingStore)
{
_attempts = mongo.Database.GetCollection<QuestionAttempt>("QuestionAttempts");
_ccsRatings = mongo.Database.GetCollection<CcsUserRating>("CcsUserRatings");
@@ -54,12 +57,14 @@ 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;
}
public async Task<DashboardResponse> GetAsync(string userId, DateTime nowUtc)
@@ -69,24 +74,29 @@ public sealed class DashboardStore
var weekStart = today.AddDays(-6);
var attemptsTask = _attempts.Find(value => value.UserId == userId).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 +104,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 +147,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 +181,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 +214,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 +229,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 +251,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 +267,8 @@ public sealed class DashboardStore
Correct = correct,
Total = daily.Count,
Accuracy = Percentage(correct, daily.Count),
QuestionBankRating = currentRating
QuestionBankRating = currentRating,
CcsRating = currentCcsRating
});
}
return result;
@@ -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);
}
@@ -19,7 +19,8 @@ public sealed class UserProfileStore
"Step3Outcomes", "QuestionReports", "CcsCaseReports", "AppFeedback", "UserStudyProfiles",
"ExternalIdentities", "ExternalAuthTransactions",
"FlashcardDecks", "FlashcardNotes", "FlashcardSchedules", "FlashcardReviewLogs",
"FlashcardMediaAssets", "FlashcardSettings", "FlashcardImportPreviews"
"FlashcardMediaAssets", "FlashcardSettings", "FlashcardImportPreviews",
"UserCreditAccounts"
];
public UserProfileStore(MongoDatabaseProvider mongo)
Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

+14
View File
@@ -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

+43
View File
@@ -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
+13
View File
@@ -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

+43
View File
@@ -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
+19
View File
@@ -0,0 +1,19 @@
# Question-completion credits
This branch introduces credit earning only. It does not impose a question
limit, redeem credits, sell products, activate passes, consume exam tokens, or
reset QBank progress.
- A successfully stored first answer earns one credit regardless of whether it
is correct.
- A repeated answer does not earn another credit.
- A skipped question does not earn credit.
- At most 20 credits can be earned in a rolling 24-hour window.
- Historical attempts are not backfilled.
- `GET /api/credits` returns the authenticated user's balance and current
rolling-window capacity.
- The first-attempt response also includes the updated credit status.
The API is authoritative. Credit grants use the immutable attempt ID as their
idempotency key, so retrying the same completed question cannot grant another
credit. Credit accounts are included in account export and deletion.
+69
View File
@@ -27,3 +27,72 @@ html/canvas_resize_policy=2
html/focus_canvas_on_start=true
html/experimental_virtual_keyboard=false
progressive_web_app/enabled=false
[preset.1]
name="Windows Desktop"
platform="Windows Desktop"
runnable=true
advanced_options=false
dedicated_server=false
custom_features=""
export_filter="all_resources"
include_filter=""
exclude_filter=""
export_path="build/windows/RanaZe.exe"
patches=PackedStringArray()
encryption_include_filters=""
encryption_exclude_filters=""
encrypt_pck=false
encrypt_directory=false
script_export_mode=2
[preset.1.options]
custom_template/debug=""
custom_template/release=""
debug/export_console_wrapper=1
binary_format/embed_pck=false
binary_format/architecture="x86_64"
texture_format/bptc=true
texture_format/s3tc=true
texture_format/etc=false
texture_format/etc2=false
codesign/enable=false
codesign/timestamp=true
codesign/timestamp_server_url=""
codesign/digest_algorithm=1
codesign/description=""
codesign/custom_options=PackedStringArray()
application/modify_resources=true
application/icon="res://assets/branding/ranaze-app-icon.ico"
application/console_wrapper_icon=""
application/icon_interpolation=4
application/file_version="0.1.0"
application/product_version="0.1.0"
application/company_name="RanaZe"
application/product_name="RanaZe: Step 3 Trainer"
application/file_description="RanaZe: Step 3 Trainer"
application/copyright="RanaZe"
application/trademarks=""
application/export_angle=0
ssh_remote_deploy/enabled=false
ssh_remote_deploy/host="user@host_ip"
ssh_remote_deploy/port="22"
ssh_remote_deploy/extra_args_ssh=""
ssh_remote_deploy/extra_args_scp=""
ssh_remote_deploy/run_script="Expand-Archive -LiteralPath '{temp_dir}\\{archive_name}' -DestinationPath '{temp_dir}'
$action = New-ScheduledTaskAction -Execute '{temp_dir}\\{exe_name}' -Argument '{cmd_args}'
$trigger = New-ScheduledTaskTrigger -Once -At 00:00
$settings = New-ScheduledTaskSettingsSet
$task = New-ScheduledTask -Action $action -Trigger $trigger -Settings $settings
Register-ScheduledTask godot_remote_debug -InputObject $task -Force:$true
Start-ScheduledTask -TaskName godot_remote_debug
while (Get-ScheduledTask -TaskName godot_remote_debug | ? State -eq running) { Start-Sleep -Milliseconds 100 }
Unregister-ScheduledTask -TaskName godot_remote_debug -Confirm:$false"
ssh_remote_deploy/cleanup_script="$tasks = Get-ScheduledTask godot_remote_debug -ErrorAction Ignore
if ($tasks) {
$tasks | Stop-ScheduledTask
$tasks | Unregister-ScheduledTask -Confirm:$false
}
Remove-Item -Recurse -Force '{temp_dir}'"
+5 -1
View File
@@ -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]
@@ -60,7 +64,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"
+51
View File
@@ -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:
+13
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
uid://cnnscljyarxaa
+178 -51
View File
@@ -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)
+34
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
uid://duq4hsl7b72g2
+29
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
uid://4rtqkgrljvw5
+1 -1
View File
@@ -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
View File
@@ -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:
+29 -14
View File
@@ -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"))
@@ -416,10 +415,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)
@@ -433,7 +432,7 @@ func _populate_navigation(container: VBoxContainer, include_brand: bool) -> void
_add_nav_button(container, "logout", "Log out", "log-out", true)
func _add_nav_button(container: VBoxContainer, key: String, label_text: String, icon_name: String, enabled: bool) -> void:
func _add_nav_button(container: VBoxContainer, key: String, label_text: String, icon_name: String, enabled: bool, visible_flag: bool = true) -> void:
var button := Button.new()
button.text = label_text
button.name = "Nav%s" % key.to_pascal_case()
@@ -447,6 +446,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):
@@ -854,7 +854,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 +876,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 +936,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":
+4
View File
@@ -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
+43 -16
View File
@@ -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
View File
@@ -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:
+73 -1
View File
@@ -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
+13 -12
View File
@@ -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:
+17
View File
@@ -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
+3
View File
@@ -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)
+4 -2
View File
@@ -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")
+14
View File
@@ -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
+8 -5
View File
@@ -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>
+297
View File
@@ -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);
+35
View File
@@ -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
View File
@@ -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 \
+8
View File
@@ -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 {