From efe60f36c57dd49147d1d481909129b78bebdc17 Mon Sep 17 00:00:00 2001 From: hz Date: Sun, 26 Jul 2026 16:27:46 -0500 Subject: [PATCH] Fix dashboard info-icon rendering and profile 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 "" (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 --- scripts/ui/dashboard_info_badge.gd | 13 +++++++++ scripts/ui/dashboard_info_badge.gd.uid | 1 + scripts/ui/dashboard_screen.gd | 38 +++++++++++++------------- scripts/ui/progress_profile.gd | 35 ++++++++++++++---------- tests/outcome_profile_test.gd | 3 ++ 5 files changed, 56 insertions(+), 34 deletions(-) create mode 100644 scripts/ui/dashboard_info_badge.gd create mode 100644 scripts/ui/dashboard_info_badge.gd.uid diff --git a/scripts/ui/dashboard_info_badge.gd b/scripts/ui/dashboard_info_badge.gd new file mode 100644 index 0000000..b5e241a --- /dev/null +++ b/scripts/ui/dashboard_info_badge.gd @@ -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) diff --git a/scripts/ui/dashboard_info_badge.gd.uid b/scripts/ui/dashboard_info_badge.gd.uid new file mode 100644 index 0000000..ac2acb9 --- /dev/null +++ b/scripts/ui/dashboard_info_badge.gd.uid @@ -0,0 +1 @@ +uid://cnnscljyarxaa diff --git a/scripts/ui/dashboard_screen.gd b/scripts/ui/dashboard_screen.gd index d862cc6..0771a3d 100644 --- a/scripts/ui/dashboard_screen.gd +++ b/scripts/ui/dashboard_screen.gd @@ -5,6 +5,7 @@ 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 @@ -82,15 +83,18 @@ func _build() -> void: 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) + _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) + _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) + _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 @@ -98,7 +102,8 @@ func _build() -> void: 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) + _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 @@ -415,28 +420,23 @@ func _render_recent(items: Array) -> void: recent_list.add_child(row) -func _info_badge(dark: bool) -> Control: - var badge := PanelContainer.new() - badge.custom_minimum_size = Vector2(15, 15) +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 - var box := StyleBoxFlat.new() - box.bg_color = Color.TRANSPARENT - box.border_color = Brand.muted_color(dark) - box.set_border_width_all(1) - box.set_corner_radius_all(8) - box.content_margin_left = 0 - box.content_margin_right = 0 - box.content_margin_top = 0 - box.content_margin_bottom = 0 - badge.add_theme_stylebox_override("panel", box) + 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) -> void: +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) @@ -461,7 +461,7 @@ func _add_stat_card(key: String, title: String, icon_name: String, accent: Color 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)) + header.add_child(_info_badge(dark, info_tooltip)) var value := _label("--", 28, Brand.text_color(dark), true) card_content.add_child(value) diff --git a/scripts/ui/progress_profile.gd b/scripts/ui/progress_profile.gd index 43c285e..5a295a3 100644 --- a/scripts/ui/progress_profile.gd +++ b/scripts/ui/progress_profile.gd @@ -258,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))))) @@ -353,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 @@ -363,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)) @@ -381,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)))) @@ -603,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 == "": return fallback + if value.is_empty(): return fallback return value.left(10) diff --git a/tests/outcome_profile_test.gd b/tests/outcome_profile_test.gd index 853b92a..5abe321 100644 --- a/tests/outcome_profile_test.gd +++ b/tests/outcome_profile_test.gd @@ -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)