Fix dashboard info-icon rendering and profile <null> display
Info badge: the icon was a PanelContainer using StyleBoxFlat corner radius to fake a circle, with the radius exceeding half the box size, causing visible artifacts. Replaced with a custom-drawn Control (draw_arc) for a clean ring at any size, and added a hover tooltip explaining what each stat card measures. Profile: Dictionary.get(key, default) only falls back when the key is missing, not when it's present with a null value, so a null displayName rendered as the literal string "<null>" (str(null) in GDScript) - including pre-filling the edit-profile dialog with it. Added a shared string_field() helper that treats null the same as missing, applied everywhere an account/preference string field is displayed or edited. Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,13 @@
|
|||||||
|
extends Control
|
||||||
|
|
||||||
|
var ring_color := Color.WHITE
|
||||||
|
|
||||||
|
|
||||||
|
func set_ring_color(next_color: Color) -> void:
|
||||||
|
ring_color = next_color
|
||||||
|
queue_redraw()
|
||||||
|
|
||||||
|
|
||||||
|
func _draw() -> void:
|
||||||
|
var radius: float = minf(size.x, size.y) * 0.5 - 1.0
|
||||||
|
draw_arc(size * 0.5, radius, 0.0, TAU, 48, ring_color, 1.2, true)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://cnnscljyarxaa
|
||||||
@@ -5,6 +5,7 @@ const TrendChart := preload("res://scripts/ui/dashboard_chart.gd")
|
|||||||
const ActivityChart := preload("res://scripts/ui/dashboard_activity_chart.gd")
|
const ActivityChart := preload("res://scripts/ui/dashboard_activity_chart.gd")
|
||||||
const DashboardSparkline := preload("res://scripts/ui/dashboard_sparkline.gd")
|
const DashboardSparkline := preload("res://scripts/ui/dashboard_sparkline.gd")
|
||||||
const DashboardSegmentBar := preload("res://scripts/ui/dashboard_segment_bar.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 main: Control
|
||||||
var scroll: ScrollContainer
|
var scroll: ScrollContainer
|
||||||
@@ -82,15 +83,18 @@ func _build() -> void:
|
|||||||
|
|
||||||
qbank_sparkline = DashboardSparkline.new()
|
qbank_sparkline = DashboardSparkline.new()
|
||||||
qbank_sparkline.custom_minimum_size = Vector2(0, 34)
|
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 = DashboardSparkline.new()
|
||||||
ccs_sparkline.custom_minimum_size = Vector2(0, 34)
|
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 = DashboardSegmentBar.new()
|
||||||
readiness_segment_bar.custom_minimum_size = Vector2(0, 10)
|
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 = ProgressBar.new()
|
||||||
accuracy_progress_bar.show_percentage = false
|
accuracy_progress_bar.show_percentage = false
|
||||||
@@ -98,7 +102,8 @@ func _build() -> void:
|
|||||||
accuracy_progress_bar.max_value = 100.0
|
accuracy_progress_bar.max_value = 100.0
|
||||||
accuracy_progress_bar.custom_minimum_size = Vector2(0, 10)
|
accuracy_progress_bar.custom_minimum_size = Vector2(0, 10)
|
||||||
_style_progress(accuracy_progress_bar, Brand.AMBER)
|
_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 = GridContainer.new()
|
||||||
analytics_grid.columns = 2
|
analytics_grid.columns = 2
|
||||||
@@ -415,28 +420,23 @@ func _render_recent(items: Array) -> void:
|
|||||||
recent_list.add_child(row)
|
recent_list.add_child(row)
|
||||||
|
|
||||||
|
|
||||||
func _info_badge(dark: bool) -> Control:
|
func _info_badge(dark: bool, tooltip: String) -> Control:
|
||||||
var badge := PanelContainer.new()
|
var badge := DashboardInfoBadge.new()
|
||||||
badge.custom_minimum_size = Vector2(15, 15)
|
badge.custom_minimum_size = Vector2(16, 16)
|
||||||
badge.size_flags_vertical = Control.SIZE_SHRINK_BEGIN
|
badge.size_flags_vertical = Control.SIZE_SHRINK_BEGIN
|
||||||
var box := StyleBoxFlat.new()
|
badge.mouse_filter = Control.MOUSE_FILTER_STOP
|
||||||
box.bg_color = Color.TRANSPARENT
|
badge.tooltip_text = tooltip
|
||||||
box.border_color = Brand.muted_color(dark)
|
badge.set_ring_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)
|
|
||||||
var glyph := _label("i", 10, Brand.muted_color(dark), true)
|
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.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||||
glyph.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
glyph.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||||||
|
glyph.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||||
badge.add_child(glyph)
|
badge.add_child(glyph)
|
||||||
return badge
|
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 dark := UIThemeService.is_dark_mode()
|
||||||
var card_content := VBoxContainer.new()
|
var card_content := VBoxContainer.new()
|
||||||
card_content.custom_minimum_size = Vector2(220, 0)
|
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.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
title_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
title_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||||
header.add_child(title_label)
|
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)
|
var value := _label("--", 28, Brand.text_color(dark), true)
|
||||||
card_content.add_child(value)
|
card_content.add_child(value)
|
||||||
|
|||||||
@@ -258,19 +258,19 @@ func _render() -> void:
|
|||||||
return
|
return
|
||||||
var account: Dictionary = AccountProfileService.account
|
var account: Dictionary = AccountProfileService.account
|
||||||
var prefs: Dictionary = AccountProfileService.preferences
|
var prefs: Dictionary = AccountProfileService.preferences
|
||||||
var username := str(account.get("username", AuthState.username))
|
var username := string_field(account, "username", AuthState.username)
|
||||||
var display_name := str(account.get("displayName", "")).strip_edges()
|
var display_name := string_field(account, "displayName").strip_edges()
|
||||||
profile_name.text = username if display_name.is_empty() else display_name
|
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]
|
profile_identity.text = "@%s" % username if email.is_empty() else "@%s | %s" % [username, email]
|
||||||
var role := str(account.get("role", AuthState.role)).capitalize()
|
var role := string_field(account, "role", AuthState.role).capitalize()
|
||||||
var created := _date_only(str(account.get("createdUtc", "")))
|
var created := _date_only(string_field(account, "createdUtc"))
|
||||||
profile_meta.text = role if created.is_empty() else "%s | Member since %s" % [role, created]
|
profile_meta.text = role if created.is_empty() else "%s | Member since %s" % [role, created]
|
||||||
_render_connected_accounts(account.get("connectedProviders", []), account.get("availableProviders", []))
|
_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["exam"] as Label).text = _date_only(string_field(prefs, "targetExamDate"), "Not scheduled")
|
||||||
(identity_values["stage"] as Label).text = _friendly(str(prefs.get("preparationStage", "pre_dedicated")))
|
(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["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
|
var data: Dictionary = DashboardService.data
|
||||||
_set_metric("qbank", str(int(data.get("questionBankRating", QuestionBankService.overall_elo))))
|
_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("ccs", str(int(data.get("ccsRating", CCSService.rating.get("elo", 1000)))))
|
||||||
@@ -353,7 +353,7 @@ func _render_outcome() -> void:
|
|||||||
outcome_delete.visible = false
|
outcome_delete.visible = false
|
||||||
return
|
return
|
||||||
outcome_score.value = int(outcome.get("officialScore", 200))
|
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_consent.button_pressed = true
|
||||||
outcome_status.text = "Saved score %d for %s." % [int(outcome_score.value), outcome_date.text]
|
outcome_status.text = "Saved score %d for %s." % [int(outcome_score.value), outcome_date.text]
|
||||||
outcome_delete.visible = true
|
outcome_delete.visible = true
|
||||||
@@ -363,12 +363,12 @@ func _open_profile_editor() -> void:
|
|||||||
var dialog := _dialog("Edit profile")
|
var dialog := _dialog("Edit profile")
|
||||||
var display := LineEdit.new()
|
var display := LineEdit.new()
|
||||||
display.placeholder_text = "Display name"
|
display.placeholder_text = "Display name"
|
||||||
display.text = str(AccountProfileService.account.get("displayName", ""))
|
display.text = string_field(AccountProfileService.account, "displayName")
|
||||||
_style_field(display)
|
_style_field(display)
|
||||||
dialog.add_field(display)
|
dialog.add_field(display)
|
||||||
var email := LineEdit.new()
|
var email := LineEdit.new()
|
||||||
email.placeholder_text = "Email address"
|
email.placeholder_text = "Email address"
|
||||||
email.text = str(AccountProfileService.account.get("email", ""))
|
email.text = string_field(AccountProfileService.account, "email")
|
||||||
_style_field(email)
|
_style_field(email)
|
||||||
dialog.add_field(email)
|
dialog.add_field(email)
|
||||||
dialog.confirmed.connect(func(): AccountProfileService.update_account(display.text, email.text))
|
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 dialog := _dialog("Edit study identity")
|
||||||
var exam := LineEdit.new()
|
var exam := LineEdit.new()
|
||||||
exam.placeholder_text = "Target exam date: YYYY-MM-DD"
|
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)
|
_style_field(exam)
|
||||||
dialog.add_field(exam)
|
dialog.add_field(exam)
|
||||||
var stage := OptionButton.new()
|
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)
|
dialog.add_field(stage)
|
||||||
var goal := OptionButton.new()
|
var goal := OptionButton.new()
|
||||||
for hours in [5, 10, 15, 20, 25]: goal.add_item("%d hours per week" % hours, hours)
|
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)))
|
_select_id(goal, int(prefs.get("weeklyGoalHours", 10)))
|
||||||
dialog.add_field(goal)
|
dialog.add_field(goal)
|
||||||
var mode := OptionButton.new()
|
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.add_field(mode)
|
||||||
dialog.confirmed.connect(func():
|
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))))
|
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()
|
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:
|
func _date_only(value: String, fallback: String = "") -> String:
|
||||||
if value.is_empty() or value == "<null>": return fallback
|
if value.is_empty(): return fallback
|
||||||
return value.left(10)
|
return value.left(10)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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, "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(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.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")
|
print("outcome_profile_test: PASS")
|
||||||
quit(0)
|
quit(0)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user