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 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)
|
||||
|
||||
@@ -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 == "<null>": return fallback
|
||||
if value.is_empty(): return fallback
|
||||
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, "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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user