Files
step3/scripts/ui/dashboard_screen.gd
T

1065 lines
45 KiB
GDScript

extends Control
const Brand := preload("res://scripts/ui/medmind_style.gd")
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
var content: VBoxContainer
var status_label: Label
var stat_card_grid: GridContainer
var analytics_grid: GridContainer
var lower_grid: GridContainer
var trend_chart: Control
var rating_toggle: Button
var accuracy_toggle: Button
var activity_chart: Control
var system_list: VBoxContainer
var focus_content: VBoxContainer
var review_list: VBoxContainer
var recent_list: VBoxContainer
var weekly_summary: VBoxContainer
var mission_content: VBoxContainer
var weekly_goal_content: VBoxContainer
var rank_content: VBoxContainer
var metric_values: Dictionary = {}
var metric_details: Dictionary = {}
var metric_trends: Dictionary = {}
var qbank_sparkline: Control
var ccs_sparkline: Control
var readiness_segment_bar: Control
var accuracy_progress_bar: ProgressBar
var pending_review_launch := false
var review_queue_service: Variant
var trend_mode := "rating"
var mastery_rows: Array[BoxContainer] = []
var mission_rows: Array[BoxContainer] = []
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)
DailyMissionService.missions_changed.connect(_render_missions)
DailyMissionService.missions_failed.connect(_show_mission_error)
WeeklyGoalService.goal_changed.connect(_render_weekly_goal)
WeeklyGoalService.goal_failed.connect(_show_weekly_goal_error)
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()
func on_show(_payload: Variant = null) -> void:
if DashboardService.data.is_empty():
_show_loading()
else:
_render()
DashboardService.refresh()
DailyMissionService.refresh()
WeeklyGoalService.refresh()
func _build() -> void:
for child in get_children():
remove_child(child)
child.free()
metric_values.clear()
metric_details.clear()
metric_trends.clear()
mastery_rows.clear()
mission_rows.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)
content = VBoxContainer.new()
content.size_flags_horizontal = Control.SIZE_EXPAND_FILL
content.add_theme_constant_override("separation", 14)
scroll.add_child(content)
status_label = _label("", 14, Brand.muted_color(UIThemeService.is_dark_mode()))
status_label.visible = false
content.add_child(status_label)
stat_card_grid = GridContainer.new()
stat_card_grid.columns = 4
stat_card_grid.add_theme_constant_override("h_separation", 12)
stat_card_grid.add_theme_constant_override("v_separation", 12)
content.add_child(stat_card_grid)
mission_content = VBoxContainer.new()
mission_content.add_theme_constant_override("separation", 10)
content.add_child(_card(mission_content, 16))
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,
"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,
"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,
"Heuristic pass probability from your most recent completed exam simulation.")
accuracy_progress_bar = ProgressBar.new()
accuracy_progress_bar.show_percentage = false
accuracy_progress_bar.min_value = 0.0
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,
"Your first-attempt accuracy across your most recent 100 answered questions.")
analytics_grid = GridContainer.new()
analytics_grid.columns = 2
analytics_grid.add_theme_constant_override("h_separation", 12)
analytics_grid.add_theme_constant_override("v_separation", 12)
content.add_child(analytics_grid)
var performance := VBoxContainer.new()
performance.size_flags_horizontal = Control.SIZE_EXPAND_FILL
performance.add_theme_constant_override("separation", 10)
var trend_header := HBoxContainer.new()
trend_header.add_child(_section_title("Performance trend"))
var trend_space := Control.new()
trend_space.size_flags_horizontal = Control.SIZE_EXPAND_FILL
trend_header.add_child(trend_space)
var trend_group := ButtonGroup.new()
trend_group.allow_unpress = false
rating_toggle = _trend_toggle("Rating", "rating", trend_group)
accuracy_toggle = _trend_toggle("Correct %", "accuracy", trend_group)
trend_header.add_child(rating_toggle)
trend_header.add_child(accuracy_toggle)
performance.add_child(trend_header)
trend_chart = TrendChart.new()
trend_chart.custom_minimum_size = Vector2(520, 190)
trend_chart.size_flags_horizontal = Control.SIZE_EXPAND_FILL
performance.add_child(trend_chart)
performance.add_child(HSeparator.new())
var system_header := HBoxContainer.new()
system_header.add_child(_section_title("System mastery path"))
var header_space := Control.new()
header_space.size_flags_horizontal = Control.SIZE_EXPAND_FILL
system_header.add_child(header_space)
var breakdown := Button.new()
breakdown.text = "View full breakdown"
breakdown.flat = true
breakdown.pressed.connect(func(): main.show_screen("profile"))
system_header.add_child(breakdown)
performance.add_child(system_header)
system_list = VBoxContainer.new()
system_list.add_theme_constant_override("separation", 8)
performance.add_child(system_list)
analytics_grid.add_child(_card(performance, 18))
var right_column := VBoxContainer.new()
right_column.add_theme_constant_override("separation", 12)
right_column.size_flags_horizontal = Control.SIZE_EXPAND_FILL
analytics_grid.add_child(right_column)
rank_content = VBoxContainer.new()
rank_content.add_theme_constant_override("separation", 9)
right_column.add_child(_card(rank_content, 16))
focus_content = VBoxContainer.new()
focus_content.add_theme_constant_override("separation", 10)
right_column.add_child(_card(focus_content, 16))
review_list = VBoxContainer.new()
review_list.add_theme_constant_override("separation", 7)
right_column.add_child(_card(review_list, 16))
lower_grid = GridContainer.new()
lower_grid.columns = 2
lower_grid.add_theme_constant_override("h_separation", 12)
lower_grid.add_theme_constant_override("v_separation", 12)
content.add_child(lower_grid)
var weekly := VBoxContainer.new()
weekly.add_theme_constant_override("separation", 8)
weekly.add_child(_section_title("Weekly activity"))
var weekly_row := HBoxContainer.new()
weekly_row.add_theme_constant_override("separation", 18)
weekly.add_child(weekly_row)
activity_chart = ActivityChart.new()
activity_chart.custom_minimum_size = Vector2(360, 150)
activity_chart.size_flags_horizontal = Control.SIZE_EXPAND_FILL
weekly_row.add_child(activity_chart)
weekly_summary = VBoxContainer.new()
weekly_summary.custom_minimum_size.x = 180
weekly_summary.add_theme_constant_override("separation", 10)
weekly_row.add_child(weekly_summary)
weekly.add_child(HSeparator.new())
weekly_goal_content = VBoxContainer.new()
weekly_goal_content.add_theme_constant_override("separation", 8)
weekly.add_child(weekly_goal_content)
lower_grid.add_child(_card(weekly, 16))
recent_list = VBoxContainer.new()
recent_list.add_theme_constant_override("separation", 6)
lower_grid.add_child(_card(recent_list, 16))
_update_responsive()
if not DashboardService.data.is_empty():
_render()
func _rebuild(_is_dark: bool = UIThemeService.is_dark_mode()) -> void:
_build()
func _render() -> void:
var data: Dictionary = DashboardService.data
status_label.visible = false
var trend: Array = data.get("performanceTrend", [])
_render_rating_card("qbank", qbank_sparkline, Brand.ROYAL_BLUE, data.get("questionBankRating", 1000),
data.get("questionBankRatingDeltaThisWeek"), data.get("questionBankPercentile"), "%d rated questions" % int(data.get("ratedQuestionCount", 0)),
_trend_series(trend, "questionBankRating"))
_render_rating_card("ccs", ccs_sparkline, Brand.SUCCESS, data.get("ccsRating", 1000),
data.get("ccsRatingDeltaThisWeek"), data.get("ccsPercentile"), "%d rated cases" % int(data.get("ratedCcsCaseCount", 0)),
_trend_series(trend, "ccsRating"))
_render_readiness_card(data)
_render_accuracy_card(data)
trend_chart.set_series(data.get("performanceTrend", []), trend_mode, UIThemeService.is_dark_mode())
_render_systems(data.get("systems", []))
_render_focus(data.get("systems", []))
_render_review(data)
_render_rank(data.get("progression", {}))
var weekly: Array = data.get("weeklyActivity", [])
activity_chart.set_days(weekly, UIThemeService.is_dark_mode())
_render_weekly(data, weekly)
_render_recent(data.get("recentActivity", []))
_render_missions()
_render_weekly_goal()
func _render_rank(value: Variant) -> void:
if rank_content == null:
return
_clear(rank_content)
rank_content.add_child(_section_title("Clinical progression"))
var progression: Dictionary = value if typeof(value) == TYPE_DICTIONARY else {}
if progression.is_empty():
rank_content.add_child(_label("Loading rank progress...", 13, Brand.muted_color(UIThemeService.is_dark_mode())))
return
var title := _label(str(progression.get("displayName", "Case Scout III")), 23, Brand.PURPLE, true)
rank_content.add_child(title)
var next_name = progression.get("nextRankName")
if next_name == null:
rank_content.add_child(_label("Highest division earned", 12, Brand.SUCCESS, true))
else:
rank_content.add_child(_label("Next: %s %s" % [str(next_name), str(progression.get("nextDivision", ""))], 12, Brand.muted_color(UIThemeService.is_dark_mode())))
var requirements: Array = progression.get("nextRequirements", [])
for requirement_value in requirements.slice(0, mini(3, requirements.size())):
var requirement: Dictionary = requirement_value
var row := HBoxContainer.new()
var label := _label(str(requirement.get("label", "Progress")), 11, Brand.text_color(UIThemeService.is_dark_mode()))
label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
row.add_child(label)
row.add_child(_label("%d / %d" % [int(requirement.get("current", 0)), int(requirement.get("required", 0))], 11,
Brand.SUCCESS if bool(requirement.get("complete", false)) else Brand.ROYAL_BLUE, true))
rank_content.add_child(row)
var view := Button.new()
view.text = "View ladder and records"
view.flat = true
view.alignment = HORIZONTAL_ALIGNMENT_LEFT
view.pressed.connect(func(): main.show_screen("profile"))
rank_content.add_child(view)
func _render_missions() -> void:
if mission_content == null:
return
_clear(mission_content)
mission_rows.clear()
var header := HBoxContainer.new()
header.add_child(_section_title("Daily missions"))
var spacer := Control.new()
spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL
header.add_child(spacer)
header.add_child(_label("Resets at 00:00 UTC", 11, Brand.muted_color(UIThemeService.is_dark_mode())))
mission_content.add_child(header)
var missions: Array = DailyMissionService.state.get("missions", [])
if missions.is_empty():
mission_content.add_child(_label("Loading today's focused study missions...", 14, Brand.muted_color(UIThemeService.is_dark_mode())))
return
var replacement_available := bool(DailyMissionService.state.get("replacementAvailable", false))
for mission_value in missions:
var mission: Dictionary = mission_value
var row := BoxContainer.new()
row.vertical = size.x < 700.0
mission_rows.append(row)
row.add_theme_constant_override("separation", 10)
var details := VBoxContainer.new()
details.size_flags_horizontal = Control.SIZE_EXPAND_FILL
var completed := bool(mission.get("completed", false))
var title := _label(str(mission.get("title", "Study mission")), 14,
Brand.SUCCESS if completed else Brand.text_color(UIThemeService.is_dark_mode()), true)
title.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
details.add_child(title)
var progress_value := mini(int(mission.get("progress", 0)), int(mission.get("target", 1)))
var target := maxi(1, int(mission.get("target", 1)))
var progress := ProgressBar.new()
progress.show_percentage = false
progress.max_value = target
progress.value = progress_value
progress.custom_minimum_size.y = 7
_style_progress(progress, Brand.SUCCESS if completed else Brand.ROYAL_BLUE)
details.add_child(progress)
details.add_child(_label("Complete - 3 RZ Coins earned" if completed else "%d of %d" % [progress_value, target],
11, Brand.muted_color(UIThemeService.is_dark_mode())))
row.add_child(details)
var launch := _button("Review" if str(mission.get("type", "")) == "due_reviews" else "Start")
launch.disabled = completed
launch.pressed.connect(_launch_mission.bind(mission))
row.add_child(launch)
if replacement_available and not completed:
var replace := Button.new()
replace.text = "Replace"
replace.flat = true
replace.pressed.connect(_replace_mission.bind(str(mission.get("id", ""))))
row.add_child(replace)
mission_content.add_child(row)
func _launch_mission(mission: Dictionary) -> void:
if str(mission.get("type", "")) == "due_reviews":
_start_review_queue()
return
main.show_screen("setup", {
"mastery_target": true,
"step3_system": str(mission.get("step3System", "")),
"question_count": 10,
"difficulty_min": int(mission.get("difficultyMin", 0)),
"difficulty_max": 5 if int(mission.get("difficultyMin", 0)) > 0 else 0
})
func _replace_mission(mission_id: String) -> void:
DailyMissionService.replace(mission_id)
_render_missions()
func _show_mission_error(message: String) -> void:
if mission_content == null:
return
_clear(mission_content)
mission_content.add_child(_section_title("Daily missions"))
mission_content.add_child(_label(message, 13, Brand.MAGENTA))
func _render_weekly_goal() -> void:
if weekly_goal_content == null:
return
_clear(weekly_goal_content)
var header := HBoxContainer.new()
header.add_child(_section_title("Balanced weekly goal"))
var spacer := Control.new()
spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL
header.add_child(spacer)
header.add_child(_label("Monday-Sunday UTC", 11, Brand.muted_color(UIThemeService.is_dark_mode())))
weekly_goal_content.add_child(header)
var components: Array = WeeklyGoalService.state.get("components", [])
if components.is_empty():
weekly_goal_content.add_child(_label("Loading weekly progress...", 13, Brand.muted_color(UIThemeService.is_dark_mode())))
return
for component_value in components:
var component: Dictionary = component_value
var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 8)
var completed := bool(component.get("completed", false))
var label := _label(str(component.get("label", "Weekly progress")), 12,
Brand.SUCCESS if completed else Brand.text_color(UIThemeService.is_dark_mode()))
label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
row.add_child(label)
row.add_child(_label("%d / %d" % [int(component.get("progress", 0)), int(component.get("target", 1))],
12, Brand.SUCCESS if completed else Brand.ROYAL_BLUE, true))
if not completed:
var action := Button.new()
action.text = "Start"
action.flat = true
action.pressed.connect(_launch_weekly_component.bind(component))
row.add_child(action)
weekly_goal_content.add_child(row)
if bool(WeeklyGoalService.state.get("completed", false)):
weekly_goal_content.add_child(_label("Weekly goal complete - %d RZ Coins earned" % int(WeeklyGoalService.state.get("rewardAmount", 0)),
13, Brand.SUCCESS, true))
else:
weekly_goal_content.add_child(_label("Complete all three for up to 10 RZ Coins.", 11,
Brand.muted_color(UIThemeService.is_dark_mode())))
func _launch_weekly_component(component: Dictionary) -> void:
var type := str(component.get("type", ""))
if type == "reviews":
_start_review_queue()
return
var system := str(WeeklyGoalService.state.get("suggestedSystem", ""))
main.show_screen("setup", {
"mastery_target": not system.is_empty(),
"step3_system": system if not system.is_empty() else "All Systems",
"question_count": 10,
"difficulty_min": 4 if type == "hard_correct" else 0,
"difficulty_max": 5 if type == "hard_correct" else 0
})
func _show_weekly_goal_error(message: String) -> void:
if weekly_goal_content == null:
return
_clear(weekly_goal_content)
weekly_goal_content.add_child(_section_title("Balanced weekly goal"))
weekly_goal_content.add_child(_label(message, 12, Brand.MAGENTA))
func _trend_series(trend: Array, field: String) -> Array:
var series: Array = []
for point in trend:
var value = point.get(field)
if value != null:
series.append(float(value))
return series
func _render_rating_card(key: String, sparkline: Control, accent: Color, rating_value: Variant, delta_value: Variant, percentile_value: Variant, fallback_caption: String, series: Array) -> void:
var dark := UIThemeService.is_dark_mode()
var rating := int(rating_value)
var trend_text := ""
var trend_color := Brand.muted_color(dark)
var caption := fallback_caption
if delta_value != null:
var delta := int(delta_value)
var arrow := "↗" if delta >= 0 else "↘"
var sign_prefix := "+" if delta >= 0 else ""
trend_text = "%s %s%d this week" % [arrow, sign_prefix, delta]
trend_color = Brand.SUCCESS if delta >= 0 else Brand.MAGENTA
if percentile_value != null:
caption = "Top %d%% of users" % int(percentile_value)
_set_stat_card(key, str(rating), trend_text, trend_color, caption)
if series.size() < 2:
sparkline.set_values([float(rating)], accent, Vector2(rating - 10.0, rating + 10.0))
else:
var low: float = series.min()
var high: float = series.max()
var spread := maxf(10.0, (high - low) * 0.5)
sparkline.set_values(series, accent, Vector2(low - spread, high + spread))
func _render_readiness_card(data: Dictionary) -> void:
var dark := UIThemeService.is_dark_mode()
var label_value = data.get("examReadinessLabel")
if label_value == null:
_set_stat_card("readiness", "--", "", Brand.muted_color(dark), "Complete a simulation to see this")
readiness_segment_bar.set_progress(0, 4, Brand.muted_color(dark), dark)
return
var label := str(label_value)
var pass_percent := int(data.get("examReadinessProjectedPassPercent", 0))
var status_color := Brand.SUCCESS if label == "High" else (Brand.AMBER if label == "Medium" else Brand.MAGENTA)
var caption := "Provisional estimate" if label != "Low" else "Keep practicing"
_set_stat_card("readiness", label, "Heuristic pass: %d%%" % pass_percent, status_color, caption)
(metric_values.get("readiness") as Label).add_theme_color_override("font_color", status_color)
readiness_segment_bar.set_progress(int(round(pass_percent / 100.0 * 4.0)), 4, status_color, dark)
func _render_accuracy_card(data: Dictionary) -> void:
var dark := UIThemeService.is_dark_mode()
var accuracy := int(data.get("firstAttemptAccuracy", 0))
var delta_value = data.get("accuracyDeltaVsPreviousSample")
var trend_text := ""
var trend_color := Brand.muted_color(dark)
if delta_value != null:
var delta := int(delta_value)
var arrow := "↗" if delta >= 0 else "↘"
var sign_prefix := "+" if delta >= 0 else ""
trend_text = "%s %s%d%% vs last 100" % [arrow, sign_prefix, delta]
trend_color = Brand.SUCCESS if delta >= 0 else Brand.MAGENTA
_set_stat_card("accuracy", "%d%%" % accuracy, trend_text, trend_color, "Last %d questions" % int(data.get("accuracySampleSize", 0)))
accuracy_progress_bar.value = accuracy
func _render_systems_legacy(systems_value: Array) -> void:
_clear(system_list)
var systems := systems_value.duplicate(true)
if systems.is_empty():
system_list.add_child(_label("Answer questions to build your system breakdown.", 14, Brand.muted_color(UIThemeService.is_dark_mode())))
return
var attempted: Array = systems.filter(func(item: Dictionary): return int(item.get("total", 0)) > 0)
var strongest_system := ""
var weakest_system := ""
if not attempted.is_empty():
var strongest: Dictionary = attempted[0]
var weakest: Dictionary = attempted[0]
for item in attempted:
if int(item.get("accuracy", 0)) > int(strongest.get("accuracy", 0)) or (
int(item.get("accuracy", 0)) == int(strongest.get("accuracy", 0))
and int(item.get("total", 0)) > int(strongest.get("total", 0))):
strongest = item
if int(item.get("accuracy", 0)) < int(weakest.get("accuracy", 0)) or (
int(item.get("accuracy", 0)) == int(weakest.get("accuracy", 0))
and int(item.get("total", 0)) > int(weakest.get("total", 0))):
weakest = item
strongest_system = str(strongest.get("system", ""))
weakest_system = str(weakest.get("system", ""))
for item in systems:
var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 10)
var system_name_label := _label(str(item.get("system", "General")), 13, Brand.text_color(UIThemeService.is_dark_mode()))
system_name_label.custom_minimum_size.x = 170
system_name_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
system_name_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
row.add_child(system_name_label)
var bar := ProgressBar.new()
bar.show_percentage = false
bar.value = int(item.get("accuracy", 0))
bar.custom_minimum_size = Vector2(80, 7)
bar.size_flags_horizontal = Control.SIZE_EXPAND_FILL
bar.size_flags_vertical = Control.SIZE_SHRINK_CENTER
_style_progress(bar, Brand.PURPLE if str(item.get("system", "")) == weakest_system else Brand.ROYAL_BLUE)
row.add_child(bar)
var total := int(item.get("total", 0))
var percentage_text := "%d%%" % int(item.get("accuracy", 0)) if total > 0 else "—"
var percentage := _label(percentage_text, 14, Brand.ROYAL_BLUE, true)
percentage.custom_minimum_size.x = 48
row.add_child(percentage)
var count := _label("%d / %d" % [int(item.get("correct", 0)), total], 12, Brand.muted_color(UIThemeService.is_dark_mode()))
count.custom_minimum_size.x = 58
row.add_child(count)
var system_name := str(item.get("system", ""))
var marker := "Strongest" if system_name == strongest_system else ("Needs work" if system_name == weakest_system else "")
var marker_label := _label(marker, 11, Brand.SUCCESS if marker == "Strongest" else Brand.MAGENTA)
marker_label.custom_minimum_size.x = 72
row.add_child(marker_label)
system_list.add_child(row)
func _render_focus_legacy(systems: Array) -> void:
_clear(focus_content)
focus_content.add_child(_section_title("Study focus"))
if systems.is_empty():
focus_content.add_child(_label("Complete a few questions to receive a focused recommendation.", 14, Brand.muted_color(UIThemeService.is_dark_mode())))
return
var weakest: Dictionary = systems[0]
for item in systems:
if int(item.get("accuracy", 0)) < int(weakest.get("accuracy", 0)):
weakest = item
focus_content.add_child(_label("Recommended focus", 12, Brand.muted_color(UIThemeService.is_dark_mode())))
focus_content.add_child(_label(str(weakest.get("system", "General")), 23, Brand.PURPLE, true))
focus_content.add_child(_label("%d%% accuracy across %d questions" % [int(weakest.get("accuracy", 0)), int(weakest.get("total", 0))], 13, Brand.text_color(UIThemeService.is_dark_mode())))
var progress := ProgressBar.new()
progress.show_percentage = false
progress.value = int(weakest.get("accuracy", 0))
progress.custom_minimum_size.y = 8
_style_progress(progress, Brand.PURPLE)
focus_content.add_child(progress)
var practice := _button("Practice this system")
practice.pressed.connect(func(): main.show_screen("selection", "question_bank"))
focus_content.add_child(practice)
func _render_systems(systems: Array) -> void:
_clear(system_list)
mastery_rows.clear()
if systems.is_empty():
system_list.add_child(_label("Answer questions to begin your mastery path.", 14, Brand.muted_color(UIThemeService.is_dark_mode())))
return
for item in systems:
var container := VBoxContainer.new()
container.add_theme_constant_override("separation", 6)
var row := BoxContainer.new()
row.vertical = false
mastery_rows.append(row)
row.add_theme_constant_override("separation", 10)
var name_label := _label(str(item.get("system", "General")), 13, Brand.text_color(UIThemeService.is_dark_mode()), true)
name_label.custom_minimum_size.x = 190
name_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
name_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
row.add_child(name_label)
var state := str(item.get("masteryState", "unseen"))
var state_label := _label(state.capitalize(), 11, _mastery_color(state), true)
state_label.custom_minimum_size.x = 74
row.add_child(state_label)
var bar := ProgressBar.new()
bar.show_percentage = false
bar.value = int(item.get("masteryScore", 0))
bar.custom_minimum_size = Vector2(80, 7)
bar.size_flags_horizontal = Control.SIZE_EXPAND_FILL
bar.size_flags_vertical = Control.SIZE_SHRINK_CENTER
_style_progress(bar, _mastery_color(state))
row.add_child(bar)
var percentage := _label("%d%%" % int(item.get("masteryScore", 0)) if int(item.get("distinctQuestions", 0)) > 0 else "--", 14, Brand.ROYAL_BLUE, true)
percentage.custom_minimum_size.x = 48
row.add_child(percentage)
var action: Dictionary = item.get("recommendedAction", {})
var action_button := _button(str(action.get("label", "Practice")))
action_button.custom_minimum_size.x = 155
action_button.pressed.connect(_launch_mastery_action.bind(item))
row.add_child(action_button)
container.add_child(row)
var evidence := "%d distinct | %d difficulty 4+ successful" % [int(item.get("distinctQuestions", 0)), int(item.get("difficulty4PlusSuccessful", 0))]
if int(item.get("pendingReviews", 0)) > 0:
evidence += " | %d review(s) pending" % int(item.get("pendingReviews", 0))
elif bool(item.get("refreshDue", false)):
evidence += " | Refresh due"
container.add_child(_label(evidence, 11, Brand.muted_color(UIThemeService.is_dark_mode())))
var requirements := _label(_mastery_requirement(item), 11, Brand.muted_color(UIThemeService.is_dark_mode()))
requirements.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
requirements.visible = false
var requirement_toggle := Button.new()
requirement_toggle.text = "Show requirements"
requirement_toggle.flat = true
requirement_toggle.alignment = HORIZONTAL_ALIGNMENT_LEFT
requirement_toggle.pressed.connect(func() -> void:
requirements.visible = not requirements.visible
requirement_toggle.text = "Hide requirements" if requirements.visible else "Show requirements")
container.add_child(requirement_toggle)
container.add_child(requirements)
system_list.add_child(container)
func _render_focus(systems: Array) -> void:
_clear(focus_content)
focus_content.add_child(_section_title("Study focus"))
if systems.is_empty():
focus_content.add_child(_label("Complete a few questions to receive a focused recommendation.", 14, Brand.muted_color(UIThemeService.is_dark_mode())))
return
var weakest: Dictionary = systems[0]
for item in systems:
if _is_higher_focus(item, weakest):
weakest = item
focus_content.add_child(_label("Recommended focus", 12, Brand.muted_color(UIThemeService.is_dark_mode())))
focus_content.add_child(_label(str(weakest.get("system", "General")), 23, Brand.PURPLE, true))
focus_content.add_child(_label("%s | %d%% mastery across %d questions" % [str(weakest.get("masteryState", "unseen")).capitalize(), int(weakest.get("masteryScore", 0)), int(weakest.get("distinctQuestions", 0))], 13, Brand.text_color(UIThemeService.is_dark_mode())))
var progress := ProgressBar.new()
progress.show_percentage = false
progress.value = int(weakest.get("masteryScore", 0))
progress.custom_minimum_size.y = 8
_style_progress(progress, Brand.PURPLE)
focus_content.add_child(progress)
var action: Dictionary = weakest.get("recommendedAction", {})
var practice := _button(str(action.get("label", "Start learning")))
practice.pressed.connect(_launch_mastery_action.bind(weakest))
focus_content.add_child(practice)
func _mastery_color(state: String) -> Color:
match state:
"mastered": return Brand.SUCCESS
"strong": return Brand.TEAL
"practicing": return Brand.ROYAL_BLUE
"learning": return Brand.AMBER
_: return Brand.muted_color(UIThemeService.is_dark_mode())
func _mastery_requirement(item: Dictionary) -> String:
match str(item.get("masteryState", "unseen")):
"unseen": return "Answer your first question in this system to begin the path."
"learning": return "Reach 5 distinct questions to enter Practicing."
"practicing": return "Strong requires 15 distinct questions and at least 75% mastery."
"strong": return "Mastered requires 25 distinct questions, 85% mastery, 5 successful difficulty 4+ questions, and no unresolved reviews."
_:
return "Complete a difficulty 4-5 refresh when due to keep this mastery current." if bool(item.get("refreshDue", false)) else "This system currently meets every mastery requirement."
func _mastery_priority(item: Dictionary) -> int:
if int(item.get("pendingReviews", 0)) > 0:
return -1000 + int(item.get("masteryScore", 0))
var ranks := {"unseen": 0, "learning": 1, "practicing": 2, "strong": 3, "mastered": 4}
return int(ranks.get(str(item.get("masteryState", "unseen")), 0)) * 100 + int(item.get("masteryScore", 0))
func _is_higher_focus(candidate: Dictionary, current: Dictionary) -> bool:
var candidate_priority := _mastery_priority(candidate)
var current_priority := _mastery_priority(current)
if candidate_priority != current_priority:
return candidate_priority < current_priority
var candidate_date := str(candidate.get("lastEvidenceUtc", ""))
var current_date := str(current.get("lastEvidenceUtc", ""))
if candidate_date.is_empty():
return not current_date.is_empty()
return not current_date.is_empty() and candidate_date < current_date
func _launch_mastery_action(item: Dictionary) -> void:
var action: Dictionary = item.get("recommendedAction", {})
var system_name := str(item.get("system", ""))
if str(action.get("type", "practice")) == "review":
_start_review_queue(system_name)
return
main.show_screen("setup", {
"mastery_target": true,
"step3_system": system_name,
"question_count": int(action.get("questionCount", 10)),
"difficulty_min": int(action.get("difficultyMin", 0)) if action.get("difficultyMin") != null else 0,
"difficulty_max": int(action.get("difficultyMax", 0)) if action.get("difficultyMax") != null else 0
})
func _render_review(data: Dictionary) -> void:
_clear(review_list)
review_list.add_child(_section_title("Review queue"))
var missed_count := int(data.get("missedQuestionCount", 0))
var missed_action := _review_action(
"%d missed questions" % missed_count if missed_count > 0 else "Review queue clear",
"x",
Brand.MAGENTA,
"")
missed_action.disabled = missed_count <= 0 or pending_review_launch
missed_action.pressed.connect(_start_review_queue)
review_list.add_child(missed_action)
if Brand.is_uat_environment():
var dynamic_due := int(data.get("dynamicReviewsDue", 0))
review_list.add_child(_review_action(
"%d dynamic concepts due" % dynamic_due if dynamic_due > 0 else "Dynamic concepts up to date",
"activity", Brand.PURPLE if dynamic_due > 0 else Brand.ROYAL_BLUE, "dynamic_questions"))
if Brand.is_uat_environment():
var flashcards_due := int(data.get("flashcardsDue", 0))
review_list.add_child(_review_action(
"%d flashcards due" % flashcards_due if flashcards_due > 0 else "Flashcards up to date",
"clipboard-plus", Brand.TEAL if flashcards_due > 0 else Brand.ROYAL_BLUE, "flashcards"))
review_list.add_child(_review_action("%d bookmarks" % int(data.get("bookmarkCount", 0)), "bookmark", Brand.ROYAL_BLUE, "bookmarks"))
review_list.add_child(_review_action("%d notes" % int(data.get("noteCount", 0)), "notebook-pen", Brand.AMBER, "notes"))
func _render_weekly(data: Dictionary, days: Array) -> void:
_clear(weekly_summary)
var questions := 0
var cases := 0
for day in days:
questions += int(day.get("questions", 0))
cases += int(day.get("ccsCases", 0))
weekly_summary.add_child(_summary_stat(str(questions), "Questions answered", "notebook-pen", Brand.ROYAL_BLUE))
weekly_summary.add_child(_summary_stat(str(cases), "CCS cases completed", "stethoscope", Brand.PURPLE))
weekly_summary.add_child(_summary_stat("%d-day" % int(data.get("currentStreak", 0)), "Current streak", "sun", Brand.AMBER))
func _render_recent(items: Array) -> void:
_clear(recent_list)
recent_list.add_child(_section_title("Recent activity"))
if items.is_empty():
recent_list.add_child(_label("Your completed study activity will appear here.", 14, Brand.muted_color(UIThemeService.is_dark_mode())))
return
for item in items:
var row := HBoxContainer.new()
row.custom_minimum_size.y = 34
row.add_theme_constant_override("separation", 9)
var type := str(item.get("type", "question"))
var icon_name := "stethoscope" if type == "ccs" else ("clipboard-list" if type == "simulation" else "activity")
var icon := TextureRect.new()
icon.texture = load("res://assets/icons/lucide/%s.svg" % icon_name)
icon.custom_minimum_size = Vector2(20, 20)
icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
row.add_child(icon)
var title := _label(str(item.get("title", "Study activity")), 13, Brand.text_color(UIThemeService.is_dark_mode()))
title.size_flags_horizontal = Control.SIZE_EXPAND_FILL
title.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
row.add_child(title)
row.add_child(_label(str(item.get("detail", "")), 13, Brand.ROYAL_BLUE, true))
row.add_child(_label(_relative_time(str(item.get("occurredUtc", ""))), 11, Brand.muted_color(UIThemeService.is_dark_mode())))
recent_list.add_child(row)
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
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, info_tooltip: String) -> void:
var dark := UIThemeService.is_dark_mode()
var card_content := VBoxContainer.new()
card_content.custom_minimum_size = Vector2(220, 0)
card_content.size_flags_horizontal = Control.SIZE_EXPAND_FILL
card_content.add_theme_constant_override("separation", 8)
var header := HBoxContainer.new()
header.add_theme_constant_override("separation", 10)
card_content.add_child(header)
var icon_panel := PanelContainer.new()
icon_panel.custom_minimum_size = Vector2(40, 40)
icon_panel.size_flags_vertical = Control.SIZE_SHRINK_CENTER
icon_panel.add_theme_stylebox_override("panel", Brand.icon_circle_box(dark, accent, 20, true))
var icon := TextureRect.new()
icon.texture = load("res://assets/icons/lucide/%s.svg" % icon_name)
icon.custom_minimum_size = Vector2(18, 18)
icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
icon_panel.add_child(icon)
header.add_child(icon_panel)
var title_label := _label(title, 14, Brand.text_color(dark))
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, info_tooltip))
var value := _label("--", 28, Brand.text_color(dark), true)
card_content.add_child(value)
var trend := _label("", 13, Brand.muted_color(dark))
card_content.add_child(trend)
card_content.add_child(bottom_visual)
var caption := _label("Loading", 12, Brand.muted_color(dark))
card_content.add_child(caption)
metric_values[key] = value
metric_trends[key] = trend
metric_details[key] = caption
stat_card_grid.add_child(_card(card_content, 16))
func _set_stat_card(key: String, value: String, trend_text: String, trend_color: Color, caption: String) -> void:
(metric_values.get(key) as Label).text = value
var trend_label: Label = metric_trends.get(key)
if trend_label != null:
trend_label.text = trend_text
trend_label.add_theme_color_override("font_color", trend_color)
(metric_details.get(key) as Label).text = caption
func _review_action(text: String, icon_name: String, accent: Color, route: String) -> Button:
var button := _button(text, icon_name, false)
button.alignment = HORIZONTAL_ALIGNMENT_LEFT
button.add_theme_color_override("icon_normal_color", accent)
if not route.is_empty():
button.pressed.connect(func(): main._on_navigation_pressed(route))
return button
func _start_review_queue(step3_system: String = "") -> void:
if pending_review_launch:
return
pending_review_launch = true
_show_loading_message("Preparing your review queue...")
set_meta("pending_review_system", step3_system)
if not StudySessionService.session_started.is_connected(_on_review_session_started):
StudySessionService.session_started.connect(_on_review_session_started)
if not StudySessionService.session_failed.is_connected(_on_review_session_failed):
StudySessionService.session_failed.connect(_on_review_session_failed)
StudySessionService.start("due_reviews", 20, "Tutor", step3_system)
_render_review(DashboardService.data)
func _on_review_session_started() -> void:
if not pending_review_launch or not visible:
return
review_queue_service.start_pass(str(get_meta("pending_review_system", "")),
int(StudySessionService.progress.get("targetValue", 20)))
func _on_review_session_failed(message: String) -> void:
if pending_review_launch and visible:
pending_review_launch = false
_show_error(message)
func _on_review_question_loaded(question: Dictionary) -> void:
if not pending_review_launch or not visible:
return
pending_review_launch = false
if BlockSessionService.start_review_question(question):
main.show_screen("player")
else:
_show_error("The review question could not be prepared.")
func _on_review_question_failed(message: String) -> void:
if not pending_review_launch or not visible:
return
pending_review_launch = false
_show_error(message)
_render_review(DashboardService.data)
func _on_review_pass_completed(_summary: Dictionary) -> void:
if not pending_review_launch or not visible:
return
pending_review_launch = false
DashboardService.refresh()
_show_loading_message("Your review queue is clear.")
func _show_loading_message(message: String) -> void:
status_label.text = message
status_label.add_theme_color_override("font_color", Brand.muted_color(UIThemeService.is_dark_mode()))
status_label.visible = true
func _summary_stat(value: String, caption: String, icon_name: String, accent: Color) -> HBoxContainer:
var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 10)
var icon := TextureRect.new()
icon.texture = load("res://assets/icons/lucide/%s.svg" % icon_name)
icon.custom_minimum_size = Vector2(24, 24)
icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
row.add_child(icon)
var copy := VBoxContainer.new()
copy.add_child(_label(value, 20, accent, true))
copy.add_child(_label(caption, 11, Brand.muted_color(UIThemeService.is_dark_mode())))
row.add_child(copy)
return row
func _card(child: Control, margin: int) -> PanelContainer:
var panel := PanelContainer.new()
panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL
panel.add_theme_stylebox_override("panel", _card_box(margin))
panel.add_child(child)
return panel
func _card_box(margin: int) -> StyleBoxFlat:
var box := StyleBoxFlat.new()
var dark := UIThemeService.is_dark_mode()
box.bg_color = Color("081426") if dark else Color.WHITE
box.border_color = Color("26344f") if dark else Brand.LIGHT_BORDER
box.set_border_width_all(1)
box.set_corner_radius_all(8)
box.content_margin_left = margin
box.content_margin_right = margin
box.content_margin_top = margin
box.content_margin_bottom = margin
return box
func _button(text: String, icon_name: String = "", primary: bool = true) -> 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", 14)
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", 18)
if primary:
var normal := StyleBoxFlat.new()
normal.bg_color = Brand.ROYAL_BLUE
normal.set_corner_radius_all(6)
var hover := normal.duplicate() as StyleBoxFlat
hover.bg_color = Color("3474ee")
button.add_theme_stylebox_override("normal", normal)
button.add_theme_stylebox_override("hover", hover)
button.add_theme_stylebox_override("pressed", normal)
button.add_theme_color_override("font_color", Color.WHITE)
button.add_theme_color_override("icon_normal_color", Color.WHITE)
else:
button.add_theme_stylebox_override("normal", Brand.secondary_button_box(UIThemeService.is_dark_mode()))
return button
func _trend_toggle(text: String, mode: String, group: ButtonGroup) -> Button:
var button := Button.new()
button.text = text
button.toggle_mode = true
button.button_group = group
button.button_pressed = trend_mode == mode
button.custom_minimum_size = Vector2(86, 32)
button.add_theme_font_override("font", Brand.font)
button.add_theme_font_size_override("font_size", 12)
var normal := Brand.secondary_button_box(UIThemeService.is_dark_mode())
var selected := StyleBoxFlat.new()
selected.bg_color = Brand.ROYAL_BLUE
selected.set_corner_radius_all(6)
button.add_theme_stylebox_override("normal", normal)
button.add_theme_stylebox_override("hover", normal)
button.add_theme_stylebox_override("pressed", selected)
button.add_theme_color_override("font_pressed_color", Color.WHITE)
button.pressed.connect(_set_trend_mode.bind(mode))
return button
func _set_trend_mode(mode: String) -> void:
trend_mode = mode
if trend_chart != null and not DashboardService.data.is_empty():
trend_chart.set_series(DashboardService.data.get("performanceTrend", []), trend_mode, UIThemeService.is_dark_mode())
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)
if bold:
label.add_theme_constant_override("outline_size", 0)
return label
func _section_title(text: String) -> Label:
return _label(text, 16, Brand.text_color(UIThemeService.is_dark_mode()), true)
func _style_progress(bar: ProgressBar, accent: Color) -> void:
var background := StyleBoxFlat.new()
background.bg_color = Color("26324a") if UIThemeService.is_dark_mode() 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 _show_loading() -> void:
if status_label != null:
status_label.text = "Loading your progress..."
status_label.visible = true
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 _update_responsive() -> void:
if stat_card_grid == null:
return
var compact := size.x < 1050.0
stat_card_grid.columns = 2 if compact else 4
analytics_grid.columns = 1 if compact else 2
lower_grid.columns = 1 if compact else 2
if trend_chart != null:
trend_chart.custom_minimum_size.x = 300.0 if compact else 520.0
var narrow := size.x < 700.0
for row in mastery_rows:
row.vertical = narrow
for child in row.get_children():
if child is Button:
child.custom_minimum_size.x = 0.0 if narrow else 155.0
for row in mission_rows:
row.vertical = narrow
func _clear(container: Container) -> void:
for child in container.get_children():
container.remove_child(child)
child.free()
func _relative_time(iso_value: String) -> String:
var cleaned := iso_value.trim_suffix("Z")
var occurred := Time.get_unix_time_from_datetime_string(cleaned)
if occurred <= 0:
return ""
var elapsed := maxi(0, int(Time.get_unix_time_from_system() - occurred))
if elapsed < 3600:
return "%dm ago" % maxi(1, int(floor(float(elapsed) / 60.0)))
if elapsed < 86400:
return "%dh ago" % int(floor(float(elapsed) / 3600.0))
return "%dd ago" % int(floor(float(elapsed) / 86400.0))