From d69a1fd0e38758fa771f90a0c3926a9d60637217 Mon Sep 17 00:00:00 2001 From: hz Date: Thu, 23 Jul 2026 03:59:02 -0500 Subject: [PATCH] Sync text zoom preference through the account instead of local settings Local user://settings.cfg meant zoom level didn't follow a student across devices/browsers. Question bank and CCS text scale now live on the user's study profile in Mongo (QuestionTextScale/CcsTextScale), synced through a new PUT /api/profile/me/text-scale endpoint and the existing preferences GET/cache already used for study preferences. Both question_player.gd and ccs_service.gd read the synced value at startup and react to AccountProfileService.profile_changed so a value pulled down after login (or changed from another session) applies without a restart. Backend validates the 0.85-1.35 range and updates only the field(s) provided via a targeted $set, so it can't clobber the other scale or the unrelated study-profile preferences. --- .../UserProfileModelsTests.cs | 22 ++++++++++++ .../Controllers/ProfileController.cs | 8 +++++ api/QuestionsApi/Models/UserProfileModels.cs | 23 +++++++++++++ api/QuestionsApi/Services/UserProfileStore.cs | 14 ++++++++ scripts/autoload/account_profile_service.gd | 21 ++++++++++-- scripts/autoload/ccs_service.gd | 34 +++++++++---------- scripts/ui/ccs_player.gd | 2 ++ scripts/ui/ccs_review.gd | 1 + scripts/ui/question_player.gd | 32 ++++++++--------- tests/ccs_player_test.gd | 3 ++ 10 files changed, 124 insertions(+), 36 deletions(-) create mode 100644 api/QuestionsApi.Tests/UserProfileModelsTests.cs diff --git a/api/QuestionsApi.Tests/UserProfileModelsTests.cs b/api/QuestionsApi.Tests/UserProfileModelsTests.cs new file mode 100644 index 0000000..4fa0073 --- /dev/null +++ b/api/QuestionsApi.Tests/UserProfileModelsTests.cs @@ -0,0 +1,22 @@ +using QuestionsApi.Models; +using Xunit; + +namespace QuestionsApi.Tests; + +public sealed class UserProfileModelsTests +{ + [Theory] + [InlineData(null, null, true)] + [InlineData(1.0, 1.0, true)] + [InlineData(0.85, 1.35, true)] + [InlineData(0.84, null, false)] + [InlineData(null, 1.36, false)] + public void UpdateTextScaleRequestValidator_EnforcesRange(double? questionScale, double? ccsScale, bool expectedValid) + { + var request = new UpdateTextScaleRequest { QuestionTextScale = questionScale, CcsTextScale = ccsScale }; + + var error = UpdateTextScaleRequestValidator.Validate(request); + + Assert.Equal(expectedValid, error is null); + } +} diff --git a/api/QuestionsApi/Controllers/ProfileController.cs b/api/QuestionsApi/Controllers/ProfileController.cs index 0adef3c..5da4a6b 100644 --- a/api/QuestionsApi/Controllers/ProfileController.cs +++ b/api/QuestionsApi/Controllers/ProfileController.cs @@ -27,6 +27,14 @@ public sealed class ProfileController(UserProfileStore profiles, IConfiguration return Ok(await profiles.UpsertAsync(UserId(), request)); } + [HttpPut("me/text-scale")] + [Authorize(Roles = "USER,SUPER")] + public async Task UpdateTextScale(UpdateTextScaleRequest request) + { + if (UpdateTextScaleRequestValidator.Validate(request) is { } error) return BadRequest(error); + return Ok(await profiles.UpdateTextScaleAsync(UserId(), request)); + } + [HttpGet("me/export")] [Authorize(Roles = "USER,SUPER")] public async Task Export() => Ok(new diff --git a/api/QuestionsApi/Models/UserProfileModels.cs b/api/QuestionsApi/Models/UserProfileModels.cs index aedfb6c..64d5a6a 100644 --- a/api/QuestionsApi/Models/UserProfileModels.cs +++ b/api/QuestionsApi/Models/UserProfileModels.cs @@ -13,6 +13,8 @@ public sealed class UserStudyProfile public string PreparationStage { get; set; } = "pre_dedicated"; public int WeeklyGoalHours { get; set; } = 10; public string PreferredStudyMode { get; set; } = "mixed"; + public double QuestionTextScale { get; set; } = 1.0; + public double CcsTextScale { get; set; } = 1.0; public int Revision { get; set; } public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow; } @@ -25,6 +27,12 @@ public sealed class UpdateUserStudyProfileRequest public string PreferredStudyMode { get; set; } = "mixed"; } +public sealed class UpdateTextScaleRequest +{ + public double? QuestionTextScale { get; set; } + public double? CcsTextScale { get; set; } +} + public sealed class DeletedUserTombstone { [BsonId] @@ -48,3 +56,18 @@ public static class UserStudyProfileValidator return null; } } + +public static class UpdateTextScaleRequestValidator +{ + private const double Min = 0.85; + private const double Max = 1.35; + + public static string? Validate(UpdateTextScaleRequest request) + { + if (request.QuestionTextScale is { } questionScale && (questionScale < Min || questionScale > Max)) + return "Question text scale must be between 0.85 and 1.35."; + if (request.CcsTextScale is { } ccsScale && (ccsScale < Min || ccsScale > Max)) + return "CCS text scale must be between 0.85 and 1.35."; + return null; + } +} diff --git a/api/QuestionsApi/Services/UserProfileStore.cs b/api/QuestionsApi/Services/UserProfileStore.cs index 7076882..c943c55 100644 --- a/api/QuestionsApi/Services/UserProfileStore.cs +++ b/api/QuestionsApi/Services/UserProfileStore.cs @@ -50,6 +50,20 @@ public sealed class UserProfileStore new FindOneAndUpdateOptions { IsUpsert = true, ReturnDocument = ReturnDocument.After }); } + public async Task UpdateTextScaleAsync(string userId, UpdateTextScaleRequest request) + { + var update = Builders.Update.Set(value => value.UpdatedUtc, DateTime.UtcNow); + if (request.QuestionTextScale is { } questionScale) + update = update.Set(value => value.QuestionTextScale, questionScale); + if (request.CcsTextScale is { } ccsScale) + update = update.Set(value => value.CcsTextScale, ccsScale); + update = update.SetOnInsert(value => value.UserId, userId); + return await _profiles.FindOneAndUpdateAsync( + value => value.UserId == userId, + update, + new FindOneAndUpdateOptions { IsUpsert = true, ReturnDocument = ReturnDocument.After }); + } + public Task IsDeletedAsync(string userId) => _deletedUsers.Find(value => value.UserId == userId).AnyAsync(); diff --git a/scripts/autoload/account_profile_service.gd b/scripts/autoload/account_profile_service.gd index 4c9ff0c..88756b0 100644 --- a/scripts/autoload/account_profile_service.gd +++ b/scripts/autoload/account_profile_service.gd @@ -84,6 +84,20 @@ func update_preferences(target_exam_date: String, preparation_stage: String, wee _request_profile("update_preferences", HTTPClient.METHOD_PUT, "/me/preferences", preferences) +func update_text_scale(question_text_scale: Variant = null, ccs_text_scale: Variant = null) -> void: + var payload := {} + if question_text_scale != null: + payload["questionTextScale"] = question_text_scale + preferences["questionTextScale"] = question_text_scale + if ccs_text_scale != null: + payload["ccsTextScale"] = ccs_text_scale + preferences["ccsTextScale"] = ccs_text_scale + if payload.is_empty(): + return + _save_cache(preferences_pending) + _request_profile("update_text_scale", HTTPClient.METHOD_PUT, "/me/text-scale", payload) + + func change_password(current_password: String, new_password: String) -> void: _request_auth("change_password", HTTPClient.METHOD_POST, "/me/change-password", { "currentPassword": current_password, "newPassword": new_password @@ -192,7 +206,7 @@ func _on_profile_completed(result: int, response_code: int, _headers: PackedStri if typeof(parsed) != TYPE_DICTIONARY: request_failed.emit("The profile service returned invalid data.") return - if operation in ["preferences", "update_preferences"]: + if operation in ["preferences", "update_preferences", "update_text_scale"]: preferences = parsed preferences_pending = false _save_cache(false) @@ -345,4 +359,7 @@ func _error_message(body: PackedByteArray, fallback: String) -> String: static func _default_preferences() -> Dictionary: - return {"targetExamDate": null, "preparationStage": "pre_dedicated", "weeklyGoalHours": 10, "preferredStudyMode": "mixed"} + return { + "targetExamDate": null, "preparationStage": "pre_dedicated", "weeklyGoalHours": 10, "preferredStudyMode": "mixed", + "questionTextScale": 1.0, "ccsTextScale": 1.0 + } diff --git a/scripts/autoload/ccs_service.gd b/scripts/autoload/ccs_service.gd index b555d0a..ef7d6e0 100644 --- a/scripts/autoload/ccs_service.gd +++ b/scripts/autoload/ccs_service.gd @@ -13,13 +13,11 @@ signal attempt_abandoned signal report_submitted(report_id: String) signal report_failed(message: String) signal request_failed(message: String) +signal text_scale_changed const TEXT_SCALE_MIN := 0.85 const TEXT_SCALE_MAX := 1.35 const TEXT_SCALE_STEP := 0.1 -const TEXT_SCALE_SETTINGS_PATH := "user://settings.cfg" -const TEXT_SCALE_SETTINGS_SECTION := "study" -const TEXT_SCALE_SETTINGS_KEY := "ccs_text_scale" var api_url := DEFAULT_API_URL var request: HTTPRequest @@ -40,11 +38,16 @@ var request_access_token := "" func _ready() -> void: api_url = EndpointConfig.resolve(str(ProjectSettings.get_setting("step3/ccs_api_url", DEFAULT_API_URL)), "/api/ccs") - text_scale = _load_text_scale_preference() request = HTTPRequest.new() request.request_completed.connect(_on_request_completed) add_child(request) AuthState.auth_changed.connect(_on_auth_changed) + call_deferred("_connect_account_profile_service") + + +func _connect_account_profile_service() -> void: + text_scale = _synced_text_scale() + AccountProfileService.profile_changed.connect(_on_account_profile_changed) func load_cases() -> void: @@ -104,23 +107,20 @@ func submit_report(attempt_id: String, category: String, details: String) -> voi func adjust_text_scale(delta: float) -> void: text_scale = clampf(text_scale + delta, TEXT_SCALE_MIN, TEXT_SCALE_MAX) - _save_text_scale_preference(text_scale) + AccountProfileService.update_text_scale(null, text_scale) -func _load_text_scale_preference() -> float: - var config := ConfigFile.new() - if config.load(TEXT_SCALE_SETTINGS_PATH) == OK: - var saved: float = config.get_value(TEXT_SCALE_SETTINGS_SECTION, TEXT_SCALE_SETTINGS_KEY, 1.0) - return clampf(saved, TEXT_SCALE_MIN, TEXT_SCALE_MAX) - return 1.0 +func _synced_text_scale() -> float: + var saved: float = AccountProfileService.preferences.get("ccsTextScale", 1.0) + return clampf(saved, TEXT_SCALE_MIN, TEXT_SCALE_MAX) -func _save_text_scale_preference(value: float) -> void: - var config := ConfigFile.new() - config.load(TEXT_SCALE_SETTINGS_PATH) - config.set_value(TEXT_SCALE_SETTINGS_SECTION, TEXT_SCALE_SETTINGS_KEY, value) - if config.save(TEXT_SCALE_SETTINGS_PATH) != OK: - push_warning("Unable to save CCS text scale preference.") +func _on_account_profile_changed() -> void: + var synced := _synced_text_scale() + if is_equal_approx(synced, text_scale): + return + text_scale = synced + text_scale_changed.emit() func toggle_highlight(attempt_id: String, region_id: String, start: int, end: int) -> void: diff --git a/scripts/ui/ccs_player.gd b/scripts/ui/ccs_player.gd index b272363..0e59d63 100644 --- a/scripts/ui/ccs_player.gd +++ b/scripts/ui/ccs_player.gd @@ -58,6 +58,8 @@ func _ready() -> void: resized.connect(_update_responsive_layout) if not theme_service.theme_changed.is_connected(_on_theme_changed): theme_service.theme_changed.connect(_on_theme_changed) + if not CCSService.text_scale_changed.is_connected(_apply_text_scale): + CCSService.text_scale_changed.connect(_apply_text_scale) _apply_theme() call_deferred("_update_responsive_layout") diff --git a/scripts/ui/ccs_review.gd b/scripts/ui/ccs_review.gd index ce78164..449ab1c 100644 --- a/scripts/ui/ccs_review.gd +++ b/scripts/ui/ccs_review.gd @@ -40,6 +40,7 @@ func _ready() -> void: status = Label.new(); Style.set_label(status, 14, Style.RED); row.add_child(status) CCSService.request_failed.connect(_request_failed) if not theme_service.theme_changed.is_connected(_on_theme_changed): theme_service.theme_changed.connect(_on_theme_changed) + if not CCSService.text_scale_changed.is_connected(_apply_text_scale): CCSService.text_scale_changed.connect(_apply_text_scale) func _study_label(region: String) -> RichTextLabel: var label := RichTextLabel.new(); label.bbcode_enabled = true; label.fit_content = true; label.selection_enabled = true; label.set_meta("ccs_region", region); label.gui_input.connect(_text_input.bind(label)); return label diff --git a/scripts/ui/question_player.gd b/scripts/ui/question_player.gd index d9e6591..c7c5b38 100644 --- a/scripts/ui/question_player.gd +++ b/scripts/ui/question_player.gd @@ -18,9 +18,6 @@ const CORRECT_ADVANCE_DELAY_SECONDS := 1.25 const TEXT_SCALE_MIN := 0.85 const TEXT_SCALE_MAX := 1.35 const TEXT_SCALE_STEP := 0.1 -const TEXT_SCALE_SETTINGS_PATH := "user://settings.cfg" -const TEXT_SCALE_SETTINGS_SECTION := "study" -const TEXT_SCALE_SETTINGS_KEY := "question_text_scale" const REPORT_DETAILS_MIN := 10 const REPORT_DETAILS_MAX := 2000 const MEDIA_ZOOM_MIN := 0.25 @@ -149,7 +146,9 @@ var shake_origin := Vector2.ZERO func _ready() -> void: theme_service = get_node("/root/UIThemeService") - question_text_scale = _load_text_scale_preference() + question_text_scale = _synced_question_text_scale() + if not AccountProfileService.profile_changed.is_connected(_on_account_profile_changed): + AccountProfileService.profile_changed.connect(_on_account_profile_changed) set_anchors_preset(Control.PRESET_FULL_RECT) add_theme_constant_override("separation", 10) if not QuestionBankService.choice_percentages_updated.is_connected(_on_choice_percentages_updated): @@ -2125,26 +2124,25 @@ func _adjust_question_text_scale(delta: float) -> void: if is_equal_approx(next_scale, question_text_scale): return question_text_scale = next_scale - _save_text_scale_preference(next_scale) + AccountProfileService.update_text_scale(next_scale) _refresh_scaled_fonts(self) _render_stem() _apply_saved_choice_state() -func _load_text_scale_preference() -> float: - var config := ConfigFile.new() - if config.load(TEXT_SCALE_SETTINGS_PATH) == OK: - var saved: float = config.get_value(TEXT_SCALE_SETTINGS_SECTION, TEXT_SCALE_SETTINGS_KEY, 1.0) - return clamp(saved, TEXT_SCALE_MIN, TEXT_SCALE_MAX) - return 1.0 +func _synced_question_text_scale() -> float: + var saved: float = AccountProfileService.preferences.get("questionTextScale", 1.0) + return clamp(saved, TEXT_SCALE_MIN, TEXT_SCALE_MAX) -func _save_text_scale_preference(value: float) -> void: - var config := ConfigFile.new() - config.load(TEXT_SCALE_SETTINGS_PATH) - config.set_value(TEXT_SCALE_SETTINGS_SECTION, TEXT_SCALE_SETTINGS_KEY, value) - if config.save(TEXT_SCALE_SETTINGS_PATH) != OK: - push_warning("Unable to save question text scale preference.") +func _on_account_profile_changed() -> void: + var synced := _synced_question_text_scale() + if is_equal_approx(synced, question_text_scale): + return + question_text_scale = synced + _refresh_scaled_fonts(self) + _render_stem() + _apply_saved_choice_state() func _set_scaled_label(label: Label, base_size: int, color: Color = Style.TEXT) -> void: diff --git a/tests/ccs_player_test.gd b/tests/ccs_player_test.gd index 8b4d79b..1dc10e5 100644 --- a/tests/ccs_player_test.gd +++ b/tests/ccs_player_test.gd @@ -52,7 +52,10 @@ func _run() -> void: var clerk_font_size: int = player.search.get_theme_font_size("font_size") var chart_font_size: int = player.summary.get_theme_font_size("normal_font_size") + var prior_profile_api_url: String = ProjectSettings.get_setting("step3/profile_api_url", "") + ProjectSettings.set_setting("step3/profile_api_url", "http://127.0.0.1:1") player._zoom(ccs_service.TEXT_SCALE_STEP) + ProjectSettings.set_setting("step3/profile_api_url", prior_profile_api_url) _assert(player.summary.get_theme_font_size("normal_font_size") > chart_font_size, "text zoom should enlarge clinical chart text") _assert(player.search.get_theme_font_size("font_size") == clerk_font_size, "text zoom should not resize Order Clerk controls")