Implement responsive profile and account data controls

This commit is contained in:
hz
2026-07-19 13:39:15 -05:00
parent 7508d4fe1d
commit dacf2b89b2
22 changed files with 1099 additions and 290 deletions
+9
View File
@@ -70,6 +70,15 @@ jobs:
run: |
kubectl create namespace step3-questions --dry-run=client -o yaml | kubectl apply -f -
- name: Configure internal API secret
env:
INTERNAL_PURGE_SECRET: ${{ secrets.INTERNAL_PURGE_SECRET }}
run: |
test -n "$INTERNAL_PURGE_SECRET"
kubectl create secret generic medmind-internal-api \
--from-literal=purge-secret="$INTERNAL_PURGE_SECRET" \
--dry-run=client -o yaml | kubectl apply -n step3-questions -f -
- name: Apply Questions deployment & service
run: |
kubectl apply -f api/QuestionsApi/k8s/deployment.yaml -n step3-questions
@@ -0,0 +1,58 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using QuestionsApi.Models;
using QuestionsApi.Services;
using System.Security.Claims;
using System.Security.Cryptography;
namespace QuestionsApi.Controllers;
[ApiController]
[Route("api/profile")]
public sealed class ProfileController(UserProfileStore profiles, IConfiguration configuration) : ControllerBase
{
[HttpGet("me/preferences")]
[Authorize(Roles = "USER,SUPER")]
public async Task<IActionResult> GetPreferences()
{
var profile = await profiles.GetAsync(UserId());
return Ok(profile ?? new UserStudyProfile { UserId = UserId() });
}
[HttpPut("me/preferences")]
[Authorize(Roles = "USER,SUPER")]
public async Task<IActionResult> UpdatePreferences(UpdateUserStudyProfileRequest request)
{
if (UserStudyProfileValidator.Validate(request) is { } error) return BadRequest(error);
return Ok(await profiles.UpsertAsync(UserId(), request));
}
[HttpGet("me/export")]
[Authorize(Roles = "USER,SUPER")]
public async Task<IActionResult> Export() => Ok(new
{
schemaVersion = 1,
exportedUtc = DateTime.UtcNow,
userId = UserId(),
records = await profiles.ExportAsync(UserId())
});
[HttpDelete("internal/users/{userId}")]
[AllowAnonymous]
public async Task<IActionResult> Purge(string userId)
{
var expected = configuration["InternalPurge:Secret"];
var supplied = Request.Headers["X-MedMind-Internal-Key"].FirstOrDefault();
if (string.IsNullOrWhiteSpace(expected) || !CryptographicOperations.FixedTimeEquals(
System.Text.Encoding.UTF8.GetBytes(expected),
System.Text.Encoding.UTF8.GetBytes(supplied ?? "")))
return Unauthorized();
await profiles.PurgeAsync(userId);
return NoContent();
}
private string UserId() => User.FindFirstValue(ClaimTypes.NameIdentifier)
?? User.FindFirstValue(ClaimTypes.Name)
?? User.Identity?.Name
?? throw new UnauthorizedAccessException();
}
@@ -6,6 +6,8 @@ public sealed class DashboardResponse
public int RatedQuestionCount { get; set; }
public int CcsRating { get; set; } = 1000;
public int RatedCcsCaseCount { get; set; }
public int QuestionsCompleted { get; set; }
public int CcsCasesCompleted { get; set; }
public int FirstAttemptAccuracy { get; set; }
public int AccuracySampleSize { get; set; }
public int QuestionsThisWeek { get; set; }
@@ -0,0 +1,50 @@
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
namespace QuestionsApi.Models;
public sealed class UserStudyProfile
{
[BsonId]
[BsonRepresentation(BsonType.ObjectId)]
public string? Id { get; set; }
public string UserId { get; set; } = "";
public DateTime? TargetExamDate { get; set; }
public string PreparationStage { get; set; } = "pre_dedicated";
public int WeeklyGoalHours { get; set; } = 10;
public string PreferredStudyMode { get; set; } = "mixed";
public int Revision { get; set; }
public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow;
}
public sealed class UpdateUserStudyProfileRequest
{
public DateTime? TargetExamDate { get; set; }
public string PreparationStage { get; set; } = "pre_dedicated";
public int WeeklyGoalHours { get; set; } = 10;
public string PreferredStudyMode { get; set; } = "mixed";
}
public sealed class DeletedUserTombstone
{
[BsonId]
public string UserId { get; set; } = "";
public DateTime DeletedUtc { get; set; } = DateTime.UtcNow;
}
public static class UserStudyProfileValidator
{
private static readonly HashSet<string> Stages = ["pre_dedicated", "dedicated", "final_review"];
private static readonly HashSet<int> Goals = [5, 10, 15, 20, 25];
private static readonly HashSet<string> Modes = ["mixed", "question_bank", "ccs", "exam_simulation"];
public static string? Validate(UpdateUserStudyProfileRequest request)
{
if (!Stages.Contains(request.PreparationStage)) return "Invalid preparation stage.";
if (!Goals.Contains(request.WeeklyGoalHours)) return "Weekly goal must be 5, 10, 15, 20, or 25 hours.";
if (!Modes.Contains(request.PreferredStudyMode)) return "Invalid preferred study mode.";
if (request.TargetExamDate?.Kind == DateTimeKind.Local)
request.TargetExamDate = request.TargetExamDate.Value.ToUniversalTime();
return null;
}
}
+13
View File
@@ -25,6 +25,7 @@ builder.Services.AddSingleton<UserNotepadStore>();
builder.Services.AddSingleton<QuestionBookmarkStore>();
builder.Services.AddSingleton<ReviewQueueStore>();
builder.Services.AddSingleton<DashboardStore>();
builder.Services.AddSingleton<UserProfileStore>();
builder.Services.AddSingleton<ServiceMetrics>();
builder.Services.AddHostedService<AnswerStatisticsMaintenanceService>();
@@ -71,6 +72,18 @@ builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
ValidateLifetime = true,
ClockSkew = TimeSpan.FromSeconds(30)
};
o.Events = new JwtBearerEvents
{
OnTokenValidated = async context =>
{
var userId = context.Principal?.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
if (!string.IsNullOrWhiteSpace(userId))
{
var profiles = context.HttpContext.RequestServices.GetRequiredService<UserProfileStore>();
if (await profiles.IsDeletedAsync(userId)) context.Fail("Account deleted");
}
}
};
});
builder.Services.AddAuthorization();
+5 -1
View File
@@ -39,11 +39,13 @@ public sealed class DashboardStore
var ratingTask = _ratings.Find(value => value.UserId == userId).FirstOrDefaultAsync();
var ccsRatingTask = _ccsRatings.Find(value => value.UserId == userId).FirstOrDefaultAsync();
var ccsAttemptsTask = _ccsAttempts.Find(value => value.UserId == userId && value.CreatedUtc >= trendStart).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 bookmarkCountTask = _bookmarks.CountDocumentsAsync(value => value.UserId == userId);
var notepadTask = _notes.Find(value => value.UserId == userId).FirstOrDefaultAsync();
var reviewAttemptsTask = _reviewQueue.GetReviewsSinceAsync(userId, weekStart);
await Task.WhenAll(attemptsTask, ratingTask, ccsRatingTask, ccsAttemptsTask,
await Task.WhenAll(attemptsTask, ratingTask, ccsRatingTask, ccsAttemptsTask, completedCcsCountTask,
simulationsTask, bookmarkCountTask, notepadTask, reviewAttemptsTask);
var attempts = attemptsTask.Result;
@@ -62,6 +64,8 @@ public sealed class DashboardStore
RatedQuestionCount = ratingTask.Result?.RatedQuestionCount ?? 0,
CcsRating = ccsRatingTask.Result?.Elo ?? 1000,
RatedCcsCaseCount = ccsRatingTask.Result?.RatedCaseCount ?? 0,
QuestionsCompleted = answeredAttempts.Count,
CcsCasesCompleted = checked((int)completedCcsCountTask.Result),
AccuracySampleSize = recentAnswered.Count,
FirstAttemptAccuracy = Percentage(recentAnswered.Count(value => value.IsCorrect), recentAnswered.Count),
QuestionsThisWeek = answeredAttempts.Count(value => value.CreatedUtc >= weekStart),
@@ -0,0 +1,99 @@
using MongoDB.Bson;
using MongoDB.Driver;
using QuestionsApi.Models;
namespace QuestionsApi.Services;
public sealed class UserProfileStore
{
private readonly IMongoDatabase _database;
private readonly IMongoCollection<UserStudyProfile> _profiles;
private readonly IMongoCollection<DeletedUserTombstone> _deletedUsers;
private static readonly string[] OwnedCollections =
[
"QuestionAttempts", "UserRatings", "QuestionBookmarks", "UserNotepads",
"ReviewQueueItems", "QuestionReviewAttempts", "ReviewQueueBackfillStates",
"CcsAttempts", "CcsUserRatings", "CcsRatedCaseResults", "ExamSimulations",
"Step3Outcomes", "QuestionReports", "CcsCaseReports", "AppFeedback", "UserStudyProfiles"
];
public UserProfileStore(MongoDatabaseProvider mongo)
{
_database = mongo.Database;
_profiles = _database.GetCollection<UserStudyProfile>("UserStudyProfiles");
_deletedUsers = _database.GetCollection<DeletedUserTombstone>("DeletedUserTombstones");
_profiles.Indexes.CreateOne(new CreateIndexModel<UserStudyProfile>(
Builders<UserStudyProfile>.IndexKeys.Ascending(value => value.UserId),
new CreateIndexOptions { Unique = true }));
}
public async Task<UserStudyProfile?> GetAsync(string userId) =>
await _profiles.Find(value => value.UserId == userId).FirstOrDefaultAsync();
public async Task<UserStudyProfile> UpsertAsync(string userId, UpdateUserStudyProfileRequest request)
{
var update = Builders<UserStudyProfile>.Update
.Set(value => value.TargetExamDate, request.TargetExamDate)
.Set(value => value.PreparationStage, request.PreparationStage)
.Set(value => value.WeeklyGoalHours, request.WeeklyGoalHours)
.Set(value => value.PreferredStudyMode, request.PreferredStudyMode)
.Set(value => value.UpdatedUtc, DateTime.UtcNow)
.Inc(value => value.Revision, 1)
.SetOnInsert(value => value.UserId, userId);
return await _profiles.FindOneAndUpdateAsync(
value => value.UserId == userId,
update,
new FindOneAndUpdateOptions<UserStudyProfile> { IsUpsert = true, ReturnDocument = ReturnDocument.After });
}
public Task<bool> IsDeletedAsync(string userId) =>
_deletedUsers.Find(value => value.UserId == userId).AnyAsync();
public async Task<Dictionary<string, List<object?>>> ExportAsync(string userId)
{
var result = new Dictionary<string, List<object?>>(StringComparer.OrdinalIgnoreCase);
foreach (var collectionName in OwnedCollections)
{
var documents = await _database.GetCollection<BsonDocument>(collectionName)
.Find(Builders<BsonDocument>.Filter.Eq("UserId", userId)).ToListAsync();
result[collectionName] = documents.Select(document => Map(document)).ToList();
}
return result;
}
public async Task PurgeAsync(string userId)
{
await _deletedUsers.ReplaceOneAsync(
value => value.UserId == userId,
new DeletedUserTombstone { UserId = userId, DeletedUtc = DateTime.UtcNow },
new ReplaceOptions { IsUpsert = true });
foreach (var collectionName in OwnedCollections)
{
await _database.GetCollection<BsonDocument>(collectionName)
.DeleteManyAsync(Builders<BsonDocument>.Filter.Eq("UserId", userId));
}
foreach (var collectionName in new[] { "QuestionReports", "CcsCaseReports", "AppFeedback" })
{
await _database.GetCollection<BsonDocument>(collectionName).UpdateManyAsync(
Builders<BsonDocument>.Filter.Eq("ReviewedByUserId", userId),
Builders<BsonDocument>.Update.Unset("ReviewedByUserId"));
}
}
private static object? Map(BsonValue value)
{
if (value.IsBsonNull) return null;
if (value.IsBsonDocument)
return value.AsBsonDocument.Elements.ToDictionary(element => element.Name, element => Map(element.Value));
if (value.IsBsonArray) return value.AsBsonArray.Select(Map).ToList();
if (value.IsObjectId) return value.AsObjectId.ToString();
if (value.IsValidDateTime) return value.ToUniversalTime();
if (value.IsDecimal128) return Decimal128.ToDecimal(value.AsDecimal128);
if (value.IsBsonBinaryData) return Convert.ToBase64String(value.AsBsonBinaryData.Bytes);
if (value.IsBsonRegularExpression) return value.AsBsonRegularExpression.ToString();
return BsonTypeMapper.MapToDotNetValue(value);
}
}
+6
View File
@@ -29,6 +29,12 @@ spec:
imagePullPolicy: IfNotPresent
ports:
- containerPort: 5008
env:
- name: InternalPurge__Secret
valueFrom:
secretKeyRef:
name: medmind-internal-api
key: purge-secret
resources:
requests:
cpu: 250m
+26
View File
@@ -0,0 +1,26 @@
# Profile and account operations
The profile screen reads account identity from `GET /api/Auth/me`, study preferences from
`GET /api/profile/me/preferences`, and performance metrics from `GET /api/dashboard`.
## Deployment
Set the same non-empty `INTERNAL_PURGE_SECRET` Gitea Actions secret in both the Step 3 and
Promiscuity repositories. Their deployment workflows create namespace-local
`medmind-internal-api` Kubernetes secrets from that value.
Deploy in this order:
1. Questions API
2. Auth API
3. Godot web client
The Auth API only removes an account after the Questions API confirms that the user's study
records were purged. The purge endpoint is idempotent and creates a tombstone that prevents an
old access token from recreating study data.
## Data export
Exports are versioned JSON files containing account metadata and user-owned study records.
Password hashes, refresh tokens, global question content, and moderation data owned by other
users are excluded.
+2
View File
@@ -32,6 +32,7 @@ NotesService="*res://scripts/autoload/notes_service.gd"
BookmarkService="*res://scripts/autoload/bookmark_service.gd"
ReviewQueueService="*res://scripts/autoload/review_queue_service.gd"
DashboardService="*res://scripts/autoload/dashboard_service.gd"
AccountProfileService="*res://scripts/autoload/account_profile_service.gd"
[display]
@@ -58,5 +59,6 @@ question_reports_api_url="https://s3q.ranaze.com/api/question-reports"
app_feedback_api_url="https://s3q.ranaze.com/api/app-feedback"
notes_api_url="https://s3q.ranaze.com/api/notes"
dashboard_api_url="https://s3q.ranaze.com/api/dashboard"
profile_api_url="https://s3q.ranaze.com/api/profile"
review_queue_api_url="https://s3q.ranaze.com/api/review-queue"
build_revision="development"
+229
View File
@@ -0,0 +1,229 @@
extends Node
signal profile_changed
signal request_failed(message: String)
signal preferences_saved
signal password_changed
signal account_deleted
signal archive_saved(path: String)
const EndpointConfig := preload("res://scripts/autoload/api_endpoint_config.gd")
const AUTH_URL := "https://pauth.ranaze.com/api/Auth"
const PROFILE_URL := "https://s3q.ranaze.com/api/profile"
var account: Dictionary = {}
var preferences: Dictionary = _default_preferences()
var auth_request: HTTPRequest
var profile_request: HTTPRequest
var auth_operation := ""
var profile_operation := ""
var auth_token := ""
var profile_token := ""
var preferences_pending := false
func _ready() -> void:
auth_request = HTTPRequest.new()
auth_request.timeout = 25.0
auth_request.request_completed.connect(_on_auth_completed)
add_child(auth_request)
profile_request = HTTPRequest.new()
profile_request.timeout = 30.0
profile_request.request_completed.connect(_on_profile_completed)
add_child(profile_request)
AuthState.auth_changed.connect(_on_auth_changed)
func refresh() -> void:
if not AuthState.can_send_authenticated_request():
return
_load_cache()
_request_auth("identity", HTTPClient.METHOD_GET, "/me")
if preferences_pending:
_request_profile("update_preferences", HTTPClient.METHOD_PUT, "/me/preferences", preferences)
else:
_request_profile("preferences", HTTPClient.METHOD_GET, "/me/preferences")
func update_account(display_name: String, email: String) -> void:
_request_auth("update_account", HTTPClient.METHOD_PATCH, "/me", {
"displayName": display_name.strip_edges(), "email": email.strip_edges()
})
func update_preferences(target_exam_date: String, preparation_stage: String, weekly_goal_hours: int, preferred_mode: String) -> void:
var target_date: Variant = null
if not target_exam_date.strip_edges().is_empty():
target_date = "%sT00:00:00Z" % target_exam_date.strip_edges()
preferences = {
"targetExamDate": target_date,
"preparationStage": preparation_stage,
"weeklyGoalHours": weekly_goal_hours,
"preferredStudyMode": preferred_mode
}
preferences_pending = true
_save_cache(true)
profile_changed.emit()
_request_profile("update_preferences", HTTPClient.METHOD_PUT, "/me/preferences", preferences)
func change_password(current_password: String, new_password: String) -> void:
_request_auth("change_password", HTTPClient.METHOD_POST, "/me/change-password", {
"currentPassword": current_password, "newPassword": new_password
})
func export_archive() -> void:
_request_profile("export", HTTPClient.METHOD_GET, "/me/export")
func delete_account(password: String, confirmation: String) -> void:
_request_auth("delete_account", HTTPClient.METHOD_DELETE, "/me", {
"password": password, "confirmation": confirmation
})
func _request_auth(operation: String, method: HTTPClient.Method, path: String, payload: Variant = null) -> void:
if auth_request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED:
request_failed.emit("Another account request is still in progress.")
return
auth_operation = operation
auth_token = AuthState.access_token
var headers := PackedStringArray(["Authorization: Bearer %s" % auth_token, "Content-Type: application/json"])
var url := "%s%s" % [EndpointConfig.resolve(str(ProjectSettings.get_setting("step3/auth_api_base_url", AUTH_URL)), "/api/Auth").rstrip("/"), path]
var error := auth_request.request(url, headers, method, "" if payload == null else JSON.stringify(payload))
if error != OK:
auth_operation = ""
request_failed.emit("The account service could not be reached.")
func _request_profile(operation: String, method: HTTPClient.Method, path: String, payload: Variant = null) -> void:
if profile_request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED:
request_failed.emit("Another profile request is still in progress.")
return
profile_operation = operation
profile_token = AuthState.access_token
var headers := PackedStringArray(["Authorization: Bearer %s" % profile_token, "Content-Type: application/json"])
var base := EndpointConfig.resolve(str(ProjectSettings.get_setting("step3/profile_api_url", PROFILE_URL)), "/api/profile")
var error := profile_request.request("%s%s" % [base.rstrip("/"), path], headers, method, "" if payload == null else JSON.stringify(payload))
if error != OK:
profile_operation = ""
request_failed.emit("The profile service could not be reached.")
func _on_auth_completed(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
var operation := auth_operation
auth_operation = ""
if response_code == 401:
AuthState.handle_unauthorized(auth_token)
return
if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300:
request_failed.emit(_error_message(body, "The account request failed."))
return
if operation in ["identity", "update_account"]:
var parsed: Variant = JSON.parse_string(body.get_string_from_utf8())
if typeof(parsed) != TYPE_DICTIONARY:
request_failed.emit("The account service returned invalid data.")
return
account = parsed
profile_changed.emit()
elif operation == "change_password":
password_changed.emit()
AuthState.clear_session()
elif operation == "delete_account":
_clear_cache()
account_deleted.emit()
AuthState.clear_session()
func _on_profile_completed(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
var operation := profile_operation
profile_operation = ""
if response_code == 401:
AuthState.handle_unauthorized(profile_token)
return
if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300:
request_failed.emit(_error_message(body, "The profile request failed."))
return
var parsed: Variant = JSON.parse_string(body.get_string_from_utf8())
if typeof(parsed) != TYPE_DICTIONARY:
request_failed.emit("The profile service returned invalid data.")
return
if operation in ["preferences", "update_preferences"]:
preferences = parsed
preferences_pending = false
_save_cache(false)
profile_changed.emit()
if operation == "update_preferences":
preferences_saved.emit()
elif operation == "export":
var archive: Dictionary = parsed
archive["account"] = account.duplicate(true)
_save_archive(JSON.stringify(archive, " "))
func _save_archive(content: String) -> void:
var filename := "medmind-account-export-%s.json" % Time.get_date_string_from_system()
if OS.has_feature("web"):
var encoded := Marshalls.raw_to_base64(content.to_utf8_buffer())
JavaScriptBridge.eval("(function(){const a=document.createElement('a');a.href='data:application/json;base64,%s';a.download=%s;document.body.appendChild(a);a.click();a.remove();})();" % [encoded, JSON.stringify(filename)], true)
archive_saved.emit(filename)
return
var directory := OS.get_system_dir(OS.SYSTEM_DIR_DOWNLOADS)
var path := directory.path_join(filename)
var file := FileAccess.open(path, FileAccess.WRITE)
if file == null:
request_failed.emit("The archive could not be saved.")
return
file.store_string(content)
archive_saved.emit(path)
func _on_auth_changed() -> void:
if AuthState.is_logged_in:
_load_cache()
else:
account = {}
preferences = _default_preferences()
func _cache_path() -> String:
return "user://profile-%s.json" % AuthState.username.sha256_text().left(16)
func _load_cache() -> void:
if AuthState.username.is_empty() or not FileAccess.file_exists(_cache_path()):
return
var parsed: Variant = JSON.parse_string(FileAccess.get_file_as_string(_cache_path()))
if typeof(parsed) == TYPE_DICTIONARY:
preferences = parsed.get("preferences", _default_preferences())
preferences_pending = bool(parsed.get("pending", false))
func _save_cache(pending: bool) -> void:
if AuthState.username.is_empty():
return
var file := FileAccess.open(_cache_path(), FileAccess.WRITE)
if file != null:
preferences_pending = pending
file.store_string(JSON.stringify({"preferences": preferences, "pending": pending}))
func _clear_cache() -> void:
if not AuthState.username.is_empty() and FileAccess.file_exists(_cache_path()):
DirAccess.remove_absolute(ProjectSettings.globalize_path(_cache_path()))
func _error_message(body: PackedByteArray, fallback: String) -> String:
var raw := body.get_string_from_utf8().strip_edges()
var parsed: Variant = JSON.parse_string(raw)
if typeof(parsed) == TYPE_DICTIONARY:
var detail := str(parsed.get("detail", parsed.get("title", ""))).strip_edges()
if not detail.is_empty():
return detail
var text := raw.trim_prefix("\"").trim_suffix("\"")
return fallback if text.is_empty() else text
static func _default_preferences() -> Dictionary:
return {"targetExamDate": null, "preparationStage": "pre_dedicated", "weeklyGoalHours": 10, "preferredStudyMode": "mixed"}
@@ -0,0 +1 @@
uid://dbgdrurlh4btb
-20
View File
@@ -21,7 +21,6 @@ func _ready() -> void:
size_flags_horizontal = Control.SIZE_EXPAND_FILL
size_flags_vertical = Control.SIZE_EXPAND_FILL
add_theme_constant_override("separation", 16)
_build_header()
_build_filters()
status_label = Label.new()
status_label.add_theme_font_override("font", Brand.font)
@@ -58,25 +57,6 @@ func _ready() -> void:
UIThemeService.theme_changed.connect(func(_dark: bool): _render())
func _build_header() -> void:
var eyebrow := Label.new()
eyebrow.text = "STUDY TOOLS"
eyebrow.add_theme_font_override("font", Brand.font)
eyebrow.add_theme_font_size_override("font_size", 13)
eyebrow.add_theme_color_override("font_color", Brand.SKY_BLUE)
add_child(eyebrow)
var title := Label.new()
title.text = "Bookmarks"
title.add_theme_font_override("font", Brand.font)
title.add_theme_font_size_override("font_size", 32)
add_child(title)
var subtitle := Label.new()
subtitle.text = "Review saved questions without changing your rating or progression."
subtitle.add_theme_font_override("font", Brand.font)
subtitle.add_theme_color_override("font_color", Brand.muted_color(UIThemeService.is_dark_mode()))
add_child(subtitle)
func _build_filters() -> void:
var row := HFlowContainer.new()
row.add_theme_constant_override("separation", 10)
+7 -5
View File
@@ -21,15 +21,17 @@ var weekly_summary: VBoxContainer
var metric_values: Dictionary = {}
var metric_details: Dictionary = {}
var pending_review_launch := false
var review_queue_service: Variant
func _ready() -> void:
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
review_queue_service = get_node("/root/ReviewQueueService")
DashboardService.dashboard_changed.connect(_render)
DashboardService.dashboard_failed.connect(_show_error)
ReviewQueueService.question_loaded.connect(_on_review_question_loaded)
ReviewQueueService.question_failed.connect(_on_review_question_failed)
ReviewQueueService.pass_completed.connect(_on_review_pass_completed)
review_queue_service.question_loaded.connect(_on_review_question_loaded)
review_queue_service.question_failed.connect(_on_review_question_failed)
review_queue_service.pass_completed.connect(_on_review_pass_completed)
UIThemeService.theme_changed.connect(_rebuild)
resized.connect(_update_responsive)
_build()
@@ -225,7 +227,7 @@ func _render_focus(systems: Array) -> void:
progress.custom_minimum_size.y = 8
_style_progress(progress, Brand.PURPLE)
focus_content.add_child(progress)
var practice := _button("Practice this system", "chevron-right")
var practice := _button("Practice this system")
practice.pressed.connect(func(): main.show_screen("selection", "question_bank"))
focus_content.add_child(practice)
@@ -333,7 +335,7 @@ func _start_review_queue() -> void:
return
pending_review_launch = true
_show_loading_message("Preparing your review queue...")
ReviewQueueService.start_pass()
review_queue_service.start_pass()
_render_review(DashboardService.data)
+11 -5
View File
@@ -471,11 +471,7 @@ func _apply_theme() -> void:
else:
label.add_theme_color_override("font_color", Brand.text_color(dark))
for input in fields:
input.add_theme_color_override("font_color", Brand.text_color(dark))
input.add_theme_color_override("font_placeholder_color", Brand.muted_color(dark))
input.add_theme_color_override("caret_color", Brand.SKY_BLUE if dark else Brand.ROYAL_BLUE)
input.add_theme_stylebox_override("normal", Brand.field_box(dark))
input.add_theme_stylebox_override("focus", Brand.field_box(dark, true))
_style_auth_field(input, dark)
for button in find_children("*", "Button", true, false):
var typed := button as Button
typed.add_theme_font_override("font", Brand.font)
@@ -503,6 +499,16 @@ func _apply_theme() -> void:
brand_tagline.add_theme_color_override("default_color", Brand.muted_color(dark))
func _style_auth_field(input: LineEdit, dark: bool) -> void:
input.add_theme_color_override("font_color", Brand.text_color(dark))
input.add_theme_color_override("font_uneditable_color", Brand.text_color(dark))
input.add_theme_color_override("font_placeholder_color", Brand.muted_color(dark))
input.add_theme_color_override("caret_color", Brand.SKY_BLUE if dark else Brand.ROYAL_BLUE)
input.add_theme_stylebox_override("normal", Brand.field_box(dark))
input.add_theme_stylebox_override("focus", Brand.field_box(dark, true))
input.add_theme_stylebox_override("read_only", Brand.field_box(dark))
func _on_theme_changed(_is_dark: bool) -> void:
_apply_theme()
+1 -1
View File
@@ -381,12 +381,12 @@ func _populate_navigation(container: VBoxContainer, include_brand: bool) -> void
_add_nav_button(container, "exams", "Exam Simulations", "clipboard-list", true)
_add_nav_button(container, "bookmarks", "Bookmarks", "bookmark", true)
_add_nav_button(container, "notes", "Notes", "notebook-pen", true)
_add_nav_button(container, "performance", "Profile", "user-round", true)
var spacer := Control.new()
spacer.size_flags_vertical = Control.SIZE_EXPAND_FILL
container.add_child(spacer)
container.add_child(HSeparator.new())
_add_nav_button(container, "performance", "Profile", "user-round", true)
_add_nav_button(container, "settings", "Settings", "settings", true)
_add_nav_button(container, "logout", "Log out", "log-out", true)
-12
View File
@@ -1,6 +1,5 @@
extends VBoxContainer
const Brand := preload("res://scripts/ui/medmind_style.gd")
const NotesEditor := preload("res://scripts/ui/notes_editor.gd")
var main
@@ -11,17 +10,6 @@ func _ready() -> void:
size_flags_horizontal = Control.SIZE_EXPAND_FILL
size_flags_vertical = Control.SIZE_EXPAND_FILL
add_theme_constant_override("separation", 16)
var eyebrow := Label.new()
eyebrow.text = "STUDY TOOLS"
eyebrow.add_theme_font_override("font", Brand.font)
eyebrow.add_theme_font_size_override("font_size", 13)
eyebrow.add_theme_color_override("font_color", Brand.SKY_BLUE)
add_child(eyebrow)
var title := Label.new()
title.text = "Notes"
title.add_theme_font_override("font", Brand.font)
title.add_theme_font_size_override("font_size", 32)
add_child(title)
notes_editor = NotesEditor.new()
add_child(notes_editor)
+522 -219
View File
@@ -1,266 +1,569 @@
extends VBoxContainer
extends Control
const Style := preload("res://scripts/ui/ui_style.gd")
const Brand := preload("res://scripts/ui/medmind_style.gd")
var main: Control
var profile_text: RichTextLabel
var reset_qbank_button: Button
var reset_qbank_pending := false
var scroll: ScrollContainer
var page: VBoxContainer
var columns: GridContainer
var profile_name: Label
var profile_identity: Label
var profile_meta: Label
var identity_values: Dictionary = {}
var metric_values: Dictionary = {}
var systems_list: VBoxContainer
var status_label: Label
var outcome_score: SpinBox
var outcome_date: LineEdit
var outcome_consent: CheckBox
var outcome_status: Label
var outcome_delete_button: Button
var outcome_grid: GridContainer
var ccs_rating_label: Label
var outcome_delete: Button
func _ready() -> void:
set_anchors_preset(Control.PRESET_FULL_RECT)
add_theme_constant_override("separation", 14)
var title := Label.new()
title.text = "Progress Profile"
Style.set_label(title, 34, Style.CYAN)
add_child(title)
var scroll := ScrollContainer.new()
scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
add_child(scroll)
var content := VBoxContainer.new()
content.size_flags_horizontal = Control.SIZE_EXPAND_FILL
content.add_theme_constant_override("separation", 14)
scroll.add_child(content)
ccs_rating_label = Label.new()
Style.set_label(ccs_rating_label, 20, Style.CYAN)
content.add_child(ccs_rating_label)
var panel := PanelContainer.new()
panel.add_theme_stylebox_override("panel", Style.panel_box(Color("172337"), Color("405475")))
panel.custom_minimum_size.y = 300
content.add_child(panel)
profile_text = RichTextLabel.new()
profile_text.bbcode_enabled = true
profile_text.size_flags_vertical = Control.SIZE_EXPAND_FILL
panel.add_child(profile_text)
_build_outcome_panel(content)
var row := HBoxContainer.new()
var back := Button.new()
back.text = "Back"
back.pressed.connect(func(): main.show_screen("selection", "step3"))
row.add_child(back)
var reset := Button.new()
reset.text = "Reset Progress"
Style.style_chip(reset, Style.RED)
reset.pressed.connect(func():
ProfileService.reset_profile()
on_show()
)
row.add_child(reset)
var simulations := Button.new()
simulations.text = "Simulation History"
Style.style_chip(simulations, Style.CYAN)
simulations.pressed.connect(func(): main.show_screen("simulation_history"))
row.add_child(simulations)
var ccs_history := Button.new()
ccs_history.text = "CCS History"
Style.style_chip(ccs_history, Style.CYAN)
ccs_history.pressed.connect(func(): main.show_screen("ccs_history"))
row.add_child(ccs_history)
reset_qbank_button = Button.new()
reset_qbank_button.text = "Reset QBank"
Style.style_chip(reset_qbank_button, Style.AMBER)
reset_qbank_button.pressed.connect(_on_reset_qbank_pressed)
row.add_child(reset_qbank_button)
add_child(row)
if not ExamSimulationService.outcome_changed.is_connected(_refresh_outcome):
ExamSimulationService.outcome_changed.connect(_refresh_outcome)
if not ExamSimulationService.request_failed.is_connected(_on_outcome_request_failed):
ExamSimulationService.request_failed.connect(_on_outcome_request_failed)
if not CCSService.rating_changed.is_connected(_refresh_ccs_rating):
CCSService.rating_changed.connect(_refresh_ccs_rating)
resized.connect(_refresh_outcome_layout)
_refresh_outcome_layout()
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
AccountProfileService.profile_changed.connect(_render)
AccountProfileService.request_failed.connect(_show_error)
AccountProfileService.preferences_saved.connect(func(): _show_saved("Study identity saved."))
AccountProfileService.archive_saved.connect(func(path: String): _show_saved("Archive saved: %s" % path))
DashboardService.dashboard_changed.connect(_render)
DashboardService.dashboard_failed.connect(_show_error)
ExamSimulationService.outcome_changed.connect(_render_outcome)
ExamSimulationService.request_failed.connect(_show_error)
UIThemeService.theme_changed.connect(_rebuild)
resized.connect(_update_responsive)
_build()
func on_show(_payload: Variant = null) -> void:
_reset_qbank_confirmation()
_refresh_ccs_rating()
var profile := ProfileService.get_profile()
var text := "[b]Overall ELO %d[/b]\n%d rated questions\nCurrent streak: %d\nBest streak: %d\nToday: %d/%d questions\n\n[b]Category ELO[/b]\n" % [
QuestionBankService.overall_elo,
QuestionBankService.rated_question_count,
profile["current_streak"],
profile["best_streak"],
profile.get("daily_questions_answered", 0),
profile.get("daily_goal_questions", 10)
]
if QuestionBankService.category_elos.is_empty():
text += "No rated categories yet.\n"
else:
var categories := QuestionBankService.category_elos.keys()
categories.sort()
for category in categories:
text += "- %s: %d\n" % [category, int(QuestionBankService.category_elos[category])]
text += "\n[b]System Accuracy[/b]\n"
if profile["system_stats"].is_empty():
text += "No completed questions yet.\n"
else:
for system_name in profile["system_stats"].keys():
var stats: Dictionary = profile["system_stats"][system_name]
var seen := int(stats.get("seen", 0))
var correct := int(stats.get("correct", 0))
var accuracy := int(round(float(correct) / float(max(seen, 1)) * 100.0))
text += "- %s: %d/%d (%d%%)\n" % [system_name, correct, seen, accuracy]
text += "\n[b]Recent Blocks[/b]\n"
for block in profile["block_history"]:
text += "- %s: %s %d/%d\n" % [
block.get("timestamp", ""),
block.get("system", ""),
block.get("correct", 0),
block.get("total", 0)
]
profile_text.text = text
_render()
AccountProfileService.refresh()
DashboardService.refresh()
CCSService.load_rating()
outcome_status.text = "Loading official outcome..."
ExamSimulationService.load_outcome()
func _refresh_ccs_rating() -> void:
ccs_rating_label.text = "CCS ELO %d | %d rated cases" % [CCSService.rating.get("elo", 1000), CCSService.rating.get("ratedCaseCount", 0)]
func _build() -> void:
for child in get_children():
remove_child(child)
child.free()
identity_values.clear()
metric_values.clear()
scroll = ScrollContainer.new()
scroll.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
add_child(scroll)
page = VBoxContainer.new()
page.size_flags_horizontal = Control.SIZE_EXPAND_FILL
page.add_theme_constant_override("separation", 14)
scroll.add_child(page)
status_label = _label("", 13, Brand.muted_color(_dark()))
status_label.visible = false
page.add_child(status_label)
columns = GridContainer.new()
columns.columns = 2
columns.size_flags_horizontal = Control.SIZE_EXPAND_FILL
columns.add_theme_constant_override("h_separation", 14)
columns.add_theme_constant_override("v_separation", 14)
page.add_child(columns)
var left := VBoxContainer.new()
left.size_flags_horizontal = Control.SIZE_EXPAND_FILL
left.add_theme_constant_override("separation", 14)
columns.add_child(left)
left.add_child(_profile_summary_card())
left.add_child(_study_identity_card())
left.add_child(_performance_card())
left.add_child(_systems_card())
var right := VBoxContainer.new()
right.custom_minimum_size.x = 410
right.size_flags_horizontal = Control.SIZE_EXPAND_FILL
right.add_theme_constant_override("separation", 14)
columns.add_child(right)
right.add_child(_outcome_card())
right.add_child(_account_actions_card())
_update_responsive()
_render()
func _build_outcome_panel(parent: VBoxContainer) -> void:
var panel := PanelContainer.new()
panel.add_theme_stylebox_override("panel", Style.panel_box(Color("172337"), Color("405475")))
parent.add_child(panel)
var section := VBoxContainer.new()
section.add_theme_constant_override("separation", 10)
panel.add_child(section)
var heading := Label.new()
heading.text = "Official Step 3 Outcome"
Style.set_label(heading, 22, Style.TEXT)
section.add_child(heading)
var note := Label.new()
note.text = "After receiving your official result, you may voluntarily submit it. You can edit or delete it at any time; calibration uses de-identified aggregates."
note.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
Style.set_label(note, 15, Style.MUTED)
section.add_child(note)
outcome_grid = GridContainer.new()
outcome_grid.add_theme_constant_override("h_separation", 10)
outcome_grid.add_theme_constant_override("v_separation", 10)
section.add_child(outcome_grid)
func _profile_summary_card() -> PanelContainer:
var body := VBoxContainer.new()
body.add_theme_constant_override("separation", 14)
body.add_child(_section_header("Profile Summary", "Your MedMind account"))
var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 18)
var avatar := PanelContainer.new()
avatar.custom_minimum_size = Vector2(74, 74)
avatar.add_theme_stylebox_override("panel", Brand.icon_circle_box(_dark(), Brand.PURPLE, 37, true))
var avatar_icon := TextureRect.new()
avatar_icon.texture = load("res://assets/icons/lucide/user-round.svg")
avatar_icon.custom_minimum_size = Vector2(34, 34)
avatar_icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
avatar_icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
avatar.add_child(avatar_icon)
row.add_child(avatar)
var copy := VBoxContainer.new()
copy.size_flags_horizontal = Control.SIZE_EXPAND_FILL
profile_name = _label("Loading profile...", 22, Brand.text_color(_dark()), true)
copy.add_child(profile_name)
profile_identity = _label("", 14, Brand.muted_color(_dark()))
copy.add_child(profile_identity)
profile_meta = _label("", 13, Brand.muted_color(_dark()))
copy.add_child(profile_meta)
row.add_child(copy)
var edit := _button("Edit profile", "pencil", false)
edit.size_flags_vertical = Control.SIZE_SHRINK_CENTER
edit.pressed.connect(_open_profile_editor)
row.add_child(edit)
body.add_child(row)
return _card(body)
func _study_identity_card() -> PanelContainer:
var body := VBoxContainer.new()
body.add_theme_constant_override("separation", 12)
var header := _header_with_action("Study Identity", "Your preparation plan", "Edit", _open_study_editor)
body.add_child(header)
var grid := GridContainer.new()
grid.columns = 2
grid.add_theme_constant_override("h_separation", 20)
grid.add_theme_constant_override("v_separation", 12)
for item in [
["exam", "Target exam date"], ["stage", "Current stage"],
["goal", "Weekly study goal"], ["mode", "Preferred study mode"]
]:
var box := VBoxContainer.new()
box.size_flags_horizontal = Control.SIZE_EXPAND_FILL
box.add_child(_label(item[1], 12, Brand.muted_color(_dark())))
var value := _label("--", 15, Brand.text_color(_dark()), true)
identity_values[item[0]] = value
box.add_child(value)
grid.add_child(box)
body.add_child(grid)
return _card(body)
func _performance_card() -> PanelContainer:
var body := VBoxContainer.new()
body.add_theme_constant_override("separation", 12)
body.add_child(_section_header("Performance Summary", "Your cumulative MedMind activity"))
var grid := GridContainer.new()
grid.columns = 3
grid.add_theme_constant_override("h_separation", 10)
grid.add_theme_constant_override("v_separation", 10)
for item in [
["qbank", "Question Bank rating", Brand.ROYAL_BLUE], ["ccs", "CCS rating", Brand.PURPLE],
["accuracy", "First-attempt accuracy", Brand.TEAL], ["questions", "Questions completed", Brand.ROYAL_BLUE],
["cases", "CCS cases completed", Brand.PURPLE], ["streak", "Current streak", Brand.AMBER]
]:
var metric := VBoxContainer.new()
metric.size_flags_horizontal = Control.SIZE_EXPAND_FILL
metric.add_child(_label(item[1], 11, Brand.muted_color(_dark())))
var value := _label("--", 23, item[2], true)
metric_values[item[0]] = value
metric.add_child(value)
grid.add_child(metric)
body.add_child(grid)
return _card(body)
func _systems_card() -> PanelContainer:
var body := VBoxContainer.new()
body.add_theme_constant_override("separation", 10)
var header := _header_with_action("System Performance", "Accuracy by Step 3 system", "View analytics", func(): main.show_screen("dashboard"))
body.add_child(header)
systems_list = VBoxContainer.new()
systems_list.add_theme_constant_override("separation", 8)
body.add_child(systems_list)
return _card(body)
func _outcome_card() -> PanelContainer:
var body := VBoxContainer.new()
body.add_theme_constant_override("separation", 10)
body.add_child(_section_header("Official Step 3 Outcome", "Optionally help calibrate future predictions"))
outcome_score = SpinBox.new()
outcome_score.min_value = 1
outcome_score.max_value = 300
outcome_score.value = 200
outcome_score.custom_minimum_size = Vector2(120, 44)
outcome_score.size_flags_horizontal = Control.SIZE_EXPAND_FILL
outcome_grid.add_child(outcome_score)
outcome_score.custom_minimum_size.y = 44
body.add_child(_field("Official score", outcome_score))
outcome_date = LineEdit.new()
outcome_date.placeholder_text = "Exam date: YYYY-MM-DD"
outcome_date.custom_minimum_size = Vector2(220, 44)
outcome_date.size_flags_horizontal = Control.SIZE_EXPAND_FILL
outcome_grid.add_child(outcome_date)
outcome_date.placeholder_text = "YYYY-MM-DD"
_style_field(outcome_date)
body.add_child(_field("Exam date", outcome_date))
outcome_consent = CheckBox.new()
outcome_consent.text = "I consent to calibration use"
outcome_consent.size_flags_horizontal = Control.SIZE_EXPAND_FILL
outcome_grid.add_child(outcome_consent)
var actions := HBoxContainer.new()
actions.add_theme_constant_override("separation", 8)
outcome_grid.add_child(actions)
var save_button := Button.new()
save_button.text = "Save outcome"
Style.style_chip(save_button, Style.CYAN)
save_button.pressed.connect(_save_outcome)
actions.add_child(save_button)
outcome_delete_button = Button.new()
outcome_delete_button.text = "Delete outcome"
Style.style_chip(outcome_delete_button, Style.RED)
outcome_delete_button.pressed.connect(_delete_outcome)
actions.add_child(outcome_delete_button)
outcome_status = Label.new()
outcome_consent.text = "I consent to de-identified calibration use"
body.add_child(outcome_consent)
var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 8)
var save := _button("Save outcome", "circle-check", true)
save.size_flags_horizontal = Control.SIZE_EXPAND_FILL
save.pressed.connect(_save_outcome)
row.add_child(save)
outcome_delete = _button("Delete", "x", false)
outcome_delete.pressed.connect(ExamSimulationService.delete_outcome)
row.add_child(outcome_delete)
body.add_child(row)
outcome_status = _label("No official outcome is stored.", 12, Brand.muted_color(_dark()))
outcome_status.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
Style.set_label(outcome_status, 15, Style.MUTED)
section.add_child(outcome_status)
body.add_child(outcome_status)
return _card(body)
func _refresh_outcome_layout() -> void:
if outcome_grid != null:
outcome_grid.columns = 1 if size.x < 900.0 else 4
func _account_actions_card() -> PanelContainer:
var body := VBoxContainer.new()
body.add_theme_constant_override("separation", 9)
body.add_child(_section_header("Account Actions", "Security and data controls"))
var password := _action_button("Change password", "settings")
password.pressed.connect(_open_password_dialog)
body.add_child(password)
var export := _action_button("Export my data", "copy")
export.pressed.connect(func(): AccountProfileService.export_archive())
body.add_child(export)
var delete := _action_button("Delete account permanently", "x", Brand.MAGENTA)
delete.pressed.connect(_open_delete_dialog)
body.add_child(delete)
return _card(body)
func _render() -> void:
if profile_name == null:
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()
profile_name.text = username if display_name.is_empty() else display_name
var email := str(account.get("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", "")))
profile_meta.text = role if created.is_empty() else "%s | Member since %s" % [role, created]
(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["goal"] as Label).text = "%d hours / week" % int(prefs.get("weeklyGoalHours", 10))
(identity_values["mode"] as Label).text = _friendly(str(prefs.get("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)))))
_set_metric("accuracy", "%d%%" % int(data.get("firstAttemptAccuracy", 0)))
_set_metric("questions", str(int(data.get("questionsCompleted", 0))))
_set_metric("cases", str(int(data.get("ccsCasesCompleted", 0))))
_set_metric("streak", "%d days" % int(data.get("currentStreak", 0)))
_render_systems(data.get("systems", []))
_render_outcome()
func _render_systems(systems: Array = DashboardService.data.get("systems", [])) -> void:
if systems_list == null:
return
_clear(systems_list)
if systems.is_empty():
systems_list.add_child(_label("Complete questions to build your system breakdown.", 13, Brand.muted_color(_dark())))
return
var sorted := systems.duplicate(true)
sorted.sort_custom(func(a: Dictionary, b: Dictionary): return int(a.get("total", 0)) > int(b.get("total", 0)))
for item in sorted.slice(0, mini(8, sorted.size())):
var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 10)
var title := _label(str(item.get("system", "General")), 13, Brand.text_color(_dark()))
title.custom_minimum_size.x = 210
title.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
row.add_child(title)
var bar := ProgressBar.new()
bar.show_percentage = false
bar.value = int(item.get("accuracy", 0))
bar.custom_minimum_size = Vector2(160, 7)
bar.size_flags_horizontal = Control.SIZE_EXPAND_FILL
bar.size_flags_vertical = Control.SIZE_SHRINK_CENTER
_style_progress(bar, Brand.ROYAL_BLUE)
row.add_child(bar)
var detail := _label("%d%% %d / %d" % [int(item.get("accuracy", 0)), int(item.get("correct", 0)), int(item.get("total", 0))], 12, Brand.muted_color(_dark()))
detail.custom_minimum_size.x = 110
row.add_child(detail)
systems_list.add_child(row)
func _render_outcome() -> void:
if outcome_status == null:
return
var outcome: Dictionary = ExamSimulationService.outcome
if outcome.is_empty():
outcome_status.text = "No official outcome is stored."
outcome_delete.visible = false
return
outcome_score.value = int(outcome.get("officialScore", 200))
outcome_date.text = _date_only(str(outcome.get("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
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", ""))
_style_field(display)
dialog.add_field(display)
var email := LineEdit.new()
email.placeholder_text = "Email address"
email.text = str(AccountProfileService.account.get("email", ""))
_style_field(email)
dialog.add_field(email)
dialog.confirmed.connect(func(): AccountProfileService.update_account(display.text, email.text))
add_child(dialog)
dialog.popup_centered(Vector2i(520, 260))
func _open_study_editor() -> void:
var prefs: Dictionary = AccountProfileService.preferences
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", "")))
_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")))
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")))
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))))
add_child(dialog)
dialog.popup_centered(Vector2i(540, 390))
func _open_password_dialog() -> void:
var dialog := _dialog("Change password")
var current := _password_field("Current password")
var replacement := _password_field("New password (8+ characters)")
var confirmation := _password_field("Confirm new password")
dialog.add_field(current)
dialog.add_field(replacement)
dialog.add_field(confirmation)
dialog.confirmed.connect(func():
if replacement.text != confirmation.text or replacement.text.length() < 8:
_show_error("New passwords must match and contain at least 8 characters.")
return
AccountProfileService.change_password(current.text, replacement.text))
add_child(dialog)
dialog.popup_centered(Vector2i(520, 330))
func _open_delete_dialog() -> void:
var dialog := _dialog("Permanently delete account")
var warning := _label("This permanently removes your account and all MedMind study data. This cannot be undone.", 13, Brand.MAGENTA)
warning.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
dialog.add_field(warning)
var password := _password_field("Current password")
dialog.add_field(password)
var confirmation := LineEdit.new()
confirmation.placeholder_text = "Type DELETE"
_style_field(confirmation)
dialog.add_field(confirmation)
dialog.confirmed.connect(func(): AccountProfileService.delete_account(password.text, confirmation.text))
add_child(dialog)
dialog.popup_centered(Vector2i(540, 330))
func _save_outcome() -> void:
var error := validate_outcome(int(outcome_score.value), outcome_date.text, outcome_consent.button_pressed)
if not error.is_empty():
_show_error(error)
return
ExamSimulationService.save_outcome(int(outcome_score.value), outcome_date.text.strip_edges())
static func validate_outcome(score: int, exam_date: String, consent: bool) -> String:
if score < 1 or score > 300:
return "Enter an official score from 1 through 300."
if RegEx.create_from_string("^\\d{4}-\\d{2}-\\d{2}$").search(exam_date.strip_edges()) == null:
return "Enter the exam date as YYYY-MM-DD."
if not consent:
return "Consent is required before saving an outcome."
var date_pattern := RegEx.new()
date_pattern.compile("^\\d{4}-\\d{2}-\\d{2}$")
if date_pattern.search(exam_date) == null:
return "Enter the exam date as YYYY-MM-DD."
return ""
func _save_outcome() -> void:
var validation_error := validate_outcome(int(outcome_score.value), outcome_date.text.strip_edges(), outcome_consent.button_pressed)
if not validation_error.is_empty():
outcome_status.text = validation_error
return
outcome_status.text = "Saving..."
ExamSimulationService.save_outcome(int(outcome_score.value), outcome_date.text.strip_edges())
func _update_responsive() -> void:
if columns != null:
columns.columns = 1 if size.x < 1050.0 else 2
func _delete_outcome() -> void:
outcome_status.text = "Deleting..."
ExamSimulationService.delete_outcome()
func _rebuild(_is_dark: bool) -> void:
_build()
func _refresh_outcome() -> void:
if ExamSimulationService.outcome.is_empty():
outcome_score.value = 200
outcome_date.text = ""
outcome_consent.button_pressed = false
outcome_delete_button.visible = false
outcome_status.text = "No official outcome is stored."
return
outcome_score.value = int(ExamSimulationService.outcome.get("officialScore", 200))
outcome_date.text = str(ExamSimulationService.outcome.get("examDate", "")).left(10)
outcome_consent.button_pressed = true
outcome_delete_button.visible = true
outcome_status.text = "Official outcome saved."
func _show_error(message: String) -> void:
status_label.text = message
status_label.add_theme_color_override("font_color", Brand.MAGENTA)
status_label.visible = true
func _on_outcome_request_failed(message: String) -> void:
if visible:
outcome_status.text = message
func _show_saved(message: String) -> void:
status_label.text = message
status_label.add_theme_color_override("font_color", Brand.SUCCESS)
status_label.visible = true
get_tree().create_timer(3.0).timeout.connect(func(): if is_instance_valid(status_label): status_label.visible = false)
func _on_reset_qbank_pressed() -> void:
if not reset_qbank_pending:
reset_qbank_pending = true
reset_qbank_button.text = "Confirm Reset QBank"
Style.style_chip(reset_qbank_button, Style.RED)
return
ProfileService.reset_qbank_memory()
if BlockSessionService.core_status_message == "All available questions cleared.":
BlockSessionService.core_status_message = ""
_reset_qbank_confirmation()
on_show()
func _card(child: Control) -> PanelContainer:
var panel := PanelContainer.new()
panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL
var box := Brand.content_card_box(_dark(), false)
box.content_margin_left = 20
box.content_margin_right = 20
box.content_margin_top = 18
box.content_margin_bottom = 18
panel.add_theme_stylebox_override("panel", box)
panel.add_child(child)
return panel
func _reset_qbank_confirmation() -> void:
reset_qbank_pending = false
if reset_qbank_button != null:
reset_qbank_button.text = "Reset QBank"
Style.style_chip(reset_qbank_button, Style.AMBER)
func _section_header(title: String, subtitle: String) -> VBoxContainer:
var box := VBoxContainer.new()
box.add_child(_label(title, 18, Brand.text_color(_dark()), true))
box.add_child(_label(subtitle, 12, Brand.muted_color(_dark())))
return box
func _header_with_action(title: String, subtitle: String, action: String, callback: Callable) -> HBoxContainer:
var row := HBoxContainer.new()
var heading := _section_header(title, subtitle)
heading.size_flags_horizontal = Control.SIZE_EXPAND_FILL
row.add_child(heading)
var button := _button(action, "pencil", false)
button.pressed.connect(callback)
row.add_child(button)
return row
func _button(text: String, icon_name: String = "", primary: bool = false) -> Button:
var button := Button.new()
button.text = text
button.custom_minimum_size = Vector2(0, 42)
button.add_theme_font_override("font", Brand.font)
button.add_theme_font_size_override("font_size", 13)
if not icon_name.is_empty():
button.icon = load("res://assets/icons/lucide/%s.svg" % icon_name)
button.add_theme_constant_override("icon_max_width", 17)
if primary:
button.add_theme_stylebox_override("normal", Brand.gradient_button_box())
button.add_theme_color_override("font_color", Color.WHITE)
else:
button.add_theme_stylebox_override("normal", Brand.secondary_button_box(_dark()))
return button
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
if color != Color.TRANSPARENT:
button.add_theme_color_override("font_color", color)
button.add_theme_color_override("icon_normal_color", color)
return button
func _label(text: String, font_size: int, color: Color, _bold: bool = false) -> Label:
var label := Label.new()
label.text = text
label.add_theme_font_override("font", Brand.font)
label.add_theme_font_size_override("font_size", font_size)
label.add_theme_color_override("font_color", color)
return label
func _field(caption: String, control: Control) -> VBoxContainer:
var box := VBoxContainer.new()
box.add_child(_label(caption, 12, Brand.muted_color(_dark())))
box.add_child(control)
return box
func _dialog(title: String) -> ProfileDialog:
var dialog := ProfileDialog.new()
dialog.title = title
dialog.ok_button_text = "Save"
dialog.add_theme_font_override("font", Brand.font)
dialog.fields.custom_minimum_size.x = 450
return dialog
func _password_field(placeholder: String) -> LineEdit:
var field := LineEdit.new()
field.placeholder_text = placeholder
field.secret = true
field.secret_character = "•"
_style_field(field)
return field
func _style_field(field: LineEdit) -> void:
field.custom_minimum_size.y = 46
field.add_theme_font_override("font", Brand.font)
field.add_theme_stylebox_override("normal", Brand.field_box(_dark()))
field.add_theme_stylebox_override("focus", Brand.field_box(_dark(), true))
func _style_progress(bar: ProgressBar, accent: Color) -> void:
var background := StyleBoxFlat.new()
background.bg_color = Color("26324a") if _dark() else Color("e2e8f0")
background.set_corner_radius_all(4)
var fill := StyleBoxFlat.new()
fill.bg_color = accent
fill.set_corner_radius_all(4)
bar.add_theme_stylebox_override("background", background)
bar.add_theme_stylebox_override("fill", fill)
func _set_metric(key: String, value: String) -> void:
if metric_values.has(key): (metric_values[key] as Label).text = value
func _clear(container: Container) -> void:
for child in container.get_children():
container.remove_child(child)
child.free()
func _date_only(value: String, fallback: String = "") -> String:
if value.is_empty() or value == "<null>": return fallback
return value.left(10)
func _friendly(value: String) -> String:
return value.replace("_", " ").capitalize()
func _dark() -> bool:
return UIThemeService.is_dark_mode()
func _add_options(control: OptionButton, options: Array, selected_value: String) -> void:
for option in options:
control.add_item(str(option[0]))
control.set_item_metadata(control.item_count - 1, option[1])
if str(option[1]) == selected_value: control.select(control.item_count - 1)
func _select_id(control: OptionButton, id: int) -> void:
for index in control.item_count:
if control.get_item_id(index) == id: control.select(index)
class ProfileDialog extends AcceptDialog:
var fields: VBoxContainer
func _init() -> void:
fields = VBoxContainer.new()
fields.add_theme_constant_override("separation", 10)
add_child(fields)
func add_field(node: Control) -> void:
fields.add_child(node)
-8
View File
@@ -7,7 +7,6 @@ const REVIEW_ICON := preload("res://assets/icons/lucide/chart-no-axes-column-inc
var main: Control
var page: VBoxContainer
var title_label: Label
var active_section: PanelContainer
var active_content: VBoxContainer
var history_section: PanelContainer
@@ -51,12 +50,6 @@ func _build_ui() -> void:
page.add_theme_constant_override("separation", 18)
scroll.add_child(page)
title_label = Label.new()
title_label.text = "Exams"
Brand.apply_font(title_label)
title_label.add_theme_font_size_override("font_size", 27)
page.add_child(title_label)
active_section = PanelContainer.new()
active_section.name = "ActiveSimulationSection"
page.add_child(active_section)
@@ -161,7 +154,6 @@ func refresh_theme_controls() -> void:
if page == null:
return
var dark := UIThemeService.is_dark_mode()
title_label.add_theme_color_override("font_color", Brand.text_color(dark))
active_section.add_theme_stylebox_override("panel", _section_box(dark))
history_section.add_theme_stylebox_override("panel", _section_box(dark))
status_label.add_theme_color_override("font_color", Brand.muted_color(dark))
+26 -19
View File
@@ -148,46 +148,53 @@ static func style_chip(button: Button, color: Color) -> void:
static func refresh_tree(root: Control, was_dark: bool) -> void:
apply_app_theme(root)
_translate_control(root, DARK_PALETTE if was_dark else LIGHT_PALETTE, LIGHT_PALETTE if was_dark else DARK_PALETTE)
_translate_control(
root,
DARK_PALETTE if was_dark else LIGHT_PALETTE,
LIGHT_PALETTE if was_dark else DARK_PALETTE,
was_dark
)
static func _translate_control(control: Control, source: Dictionary, target: Dictionary) -> void:
static func _translate_control(control: Control, source: Dictionary, target: Dictionary, from_dark: bool) -> void:
if control is ColorRect:
control.color = _translate_color(control.color, source, target)
control.color = _translate_color(control.color, source, target, from_dark)
for color_name in ["font_color", "font_hover_color", "font_pressed_color", "font_disabled_color", "font_selected_color", "default_color", "font_uneditable_color"]:
if control.has_theme_color_override(color_name):
control.add_theme_color_override(color_name, _translate_color(control.get_theme_color(color_name), source, target))
control.add_theme_color_override(color_name, _translate_color(control.get_theme_color(color_name), source, target, from_dark))
for style_name in ["panel", "normal", "hover", "pressed", "disabled", "focus", "fill", "background", "read_only"]:
if control.has_theme_stylebox_override(style_name):
var box := control.get_theme_stylebox(style_name)
if box is StyleBoxFlat:
var translated := box.duplicate() as StyleBoxFlat
translated.bg_color = _translate_color(translated.bg_color, source, target)
translated.border_color = _translate_color(translated.border_color, source, target)
translated.bg_color = _translate_color(translated.bg_color, source, target, from_dark)
translated.border_color = _translate_color(translated.border_color, source, target, from_dark)
control.add_theme_stylebox_override(style_name, translated)
for child in control.get_children():
if child is Control:
_translate_control(child, source, target)
_translate_control(child, source, target, from_dark)
static func _translate_color(color: Color, source: Dictionary, target: Dictionary) -> Color:
static func _translate_color(color: Color, source: Dictionary, target: Dictionary, from_dark: bool) -> Color:
for key in source:
var candidate: Color = source[key]
if color.is_equal_approx(candidate):
var replacement: Color = target[key]
replacement.a = color.a
return replacement
for dark_color in CUSTOM_DARK_TO_LIGHT:
if color.is_equal_approx(dark_color):
var replacement: Color = CUSTOM_DARK_TO_LIGHT[dark_color]
replacement.a = color.a
return replacement
for dark_color in CUSTOM_DARK_TO_LIGHT:
var light_color: Color = CUSTOM_DARK_TO_LIGHT[dark_color]
if color.is_equal_approx(light_color):
var replacement: Color = dark_color
replacement.a = color.a
return replacement
if from_dark:
for dark_color in CUSTOM_DARK_TO_LIGHT:
if color.is_equal_approx(dark_color):
var replacement: Color = CUSTOM_DARK_TO_LIGHT[dark_color]
replacement.a = color.a
return replacement
else:
for dark_color in CUSTOM_DARK_TO_LIGHT:
var light_color: Color = CUSTOM_DARK_TO_LIGHT[dark_color]
if color.is_equal_approx(light_color):
var replacement: Color = dark_color
replacement.a = color.a
return replacement
return color
+11
View File
@@ -28,6 +28,12 @@ func _initialize() -> void:
_assert(light_card.border_color.is_equal_approx(Brand.ROYAL_BLUE), "active light cards should use the primary blue border")
var light_field := Brand.field_box(false)
_assert(light_field.border_color.is_equal_approx(Brand.LIGHT_BORDER), "light fields should use the semantic cool-gray border")
var explicitly_light_panel := PanelContainer.new()
explicitly_light_panel.add_theme_stylebox_override("panel", Brand.content_card_box(false, false))
root.add_child(explicitly_light_panel)
Style.refresh_tree(root, true)
var preserved_light_box := explicitly_light_panel.get_theme_stylebox("panel") as StyleBoxFlat
_assert(preserved_light_box.bg_color.is_equal_approx(Color.WHITE), "a screen's explicit light styles should not be translated back to dark")
var theme_toggle := Button.new()
Brand.set_theme_toggle_icon(theme_toggle, true)
_assert(theme_toggle.text.is_empty() and theme_toggle.icon == Brand.SUN_ICON, "dark mode should use the bundled sun icon")
@@ -50,6 +56,11 @@ func _initialize() -> void:
var auth_field: LineEdit = login._add_field(field_parent, "Email", "[email protected]")
_assert(auth_field.caret_blink, "authentication fields should display a blinking caret")
_assert(is_equal_approx(auth_field.caret_blink_interval, 0.5), "authentication field caret should blink at a visible interval")
login._style_auth_field(auth_field, false)
var editable_box := auth_field.get_theme_stylebox("normal") as StyleBoxFlat
var read_only_box := auth_field.get_theme_stylebox("read_only") as StyleBoxFlat
_assert(read_only_box.bg_color.is_equal_approx(editable_box.bg_color), "authentication fields should retain their surface while a request is pending")
_assert(is_equal_approx(read_only_box.content_margin_left, editable_box.content_margin_left), "authentication fields should retain their text inset while a request is pending")
login.free()
theme_toggle.free()
+21
View File
@@ -72,6 +72,27 @@ func _run() -> void:
var question_bank_nav := main_screen.sidebar.find_child("NavQuestionBank", true, false) as Button
_assert(question_bank_nav != null and question_bank_nav.text == "Question Bank", "the Practice navigation item should be replaced by Question Bank")
_assert(main_screen.sidebar.find_child("NavPractice", true, false) == null, "the sidebar should not retain a Practice navigation item")
var profile_nav := main_screen.sidebar.find_child("NavPerformance", true, false) as Button
var settings_nav := main_screen.sidebar.find_child("NavSettings", true, false) as Button
_assert(profile_nav != null and settings_nav != null and profile_nav.get_index() + 1 == settings_nav.get_index(), "Profile should sit directly above Settings in the lower navigation group")
var theme_service = root.get_node("UIThemeService")
var prior_dark_mode: bool = theme_service.is_dark_mode()
theme_service.set_dark_mode(true)
await process_frame
theme_service.set_dark_mode(false)
await process_frame
var dashboard = main_screen.screens.get("dashboard")
var dashboard_card := dashboard.metric_grid.get_parent() as PanelContainer
var dashboard_box := dashboard_card.get_theme_stylebox("panel") as StyleBoxFlat
_assert(dashboard_box.bg_color.is_equal_approx(Color.WHITE), "dashboard cards should remain light after the shell refresh")
var simulations = main_screen.screens.get("simulation_history")
var simulation_box := simulations.active_section.get_theme_stylebox("panel") as StyleBoxFlat
_assert(simulation_box.bg_color.is_equal_approx(simulations._section_box(false).bg_color), "exam simulation panels should remain light after the shell refresh")
var notes_screen = main_screen.screens.get("notes")
var notes_box := notes_screen.notes_editor.editor.get_theme_stylebox("normal") as StyleBoxFlat
_assert(notes_box.bg_color.is_equal_approx(Color.WHITE), "the Notes editor should remain light after the shell refresh")
theme_service.set_dark_mode(prior_dark_mode)
await process_frame
host.queue_free()
await process_frame