Redesign dashboard top-4 stats as separate cards
Frontend half of the dashboard stat-card redesign, matching the provided mockup. The 4 metrics used to be plain rows sharing one bordered box (_add_metric -> a single metric_grid inside one _card). Replaced with 4 independently-_card-wrapped panels in a responsive GridContainer (4 cols / 2 compact, same threshold _update_responsive already used). Each card now has: a colored icon badge, title + info glyph, a big value, a colored trend/status line, a small visual, and a footer caption: - Question Bank Rating / CCS Rating: a new minimal DashboardSparkline (dashboard_sparkline.gd) fed from the current rating and the week-ago snapshot (reconstructed client-side from the delta the API already returns - no separate history endpoint needed). Trend line and caption come from the new delta/percentile fields; both degrade to a plain single-point sparkline and a rated-count caption when no snapshot exists yet. - Exam Readiness: a new DashboardSegmentBar (dashboard_segment_bar.gd - nothing like a segmented pill progress indicator existed anywhere in the codebase) instead of a sparkline, colored by the High/Medium/Low label; shows a neutral "complete a simulation" state when the user has none yet rather than a fabricated readiness. - Average Score: reuses the existing ProgressBar/_style_progress, relabeled from "First-Attempt Accuracy". Dropped the old "Study Time" card (not part of the mockup - the underlying data still drives the weekly activity chart below, it just no longer gets a dedicated top-line number) and the now-fully-unused _format_duration. Verified headlessly: instantiated the real screen against fake DashboardService data, confirmed every card's value/trend/caption text and the segment-bar/progress-bar values match the mockup exactly, and rendered actual screenshots of both the populated and "no data yet" fallback states.
This commit is contained in:
+149
-43
@@ -3,12 +3,14 @@ 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")
|
||||
|
||||
var main: Control
|
||||
var scroll: ScrollContainer
|
||||
var content: VBoxContainer
|
||||
var status_label: Label
|
||||
var metric_grid: GridContainer
|
||||
var stat_card_grid: GridContainer
|
||||
var analytics_grid: GridContainer
|
||||
var lower_grid: GridContainer
|
||||
var trend_chart: Control
|
||||
@@ -22,6 +24,11 @@ var recent_list: VBoxContainer
|
||||
var weekly_summary: 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"
|
||||
@@ -54,6 +61,7 @@ func _build() -> void:
|
||||
child.free()
|
||||
metric_values.clear()
|
||||
metric_details.clear()
|
||||
metric_trends.clear()
|
||||
scroll = ScrollContainer.new()
|
||||
scroll.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||
scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
|
||||
@@ -66,15 +74,31 @@ func _build() -> void:
|
||||
status_label = _label("", 14, Brand.muted_color(UIThemeService.is_dark_mode()))
|
||||
status_label.visible = false
|
||||
content.add_child(status_label)
|
||||
metric_grid = GridContainer.new()
|
||||
metric_grid.columns = 4
|
||||
metric_grid.add_theme_constant_override("h_separation", 0)
|
||||
metric_grid.add_theme_constant_override("v_separation", 8)
|
||||
content.add_child(_card(metric_grid, 12))
|
||||
_add_metric("qbank", "Question Bank Rating", "chart-no-axes-column-increasing", Brand.ROYAL_BLUE)
|
||||
_add_metric("ccs", "CCS Rating", "stethoscope", Brand.PURPLE)
|
||||
_add_metric("accuracy", "First-Attempt Accuracy", "activity", Brand.ROYAL_BLUE)
|
||||
_add_metric("time", "Study Time", "clock-3", Brand.PURPLE)
|
||||
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)
|
||||
|
||||
qbank_sparkline = DashboardSparkline.new()
|
||||
qbank_sparkline.custom_minimum_size = Vector2(0, 34)
|
||||
_add_stat_card("qbank", "Question Bank Rating", "chart-no-axes-column-increasing", Brand.ROYAL_BLUE, qbank_sparkline)
|
||||
|
||||
ccs_sparkline = DashboardSparkline.new()
|
||||
ccs_sparkline.custom_minimum_size = Vector2(0, 34)
|
||||
_add_stat_card("ccs", "CCS Rating", "stethoscope", Brand.SUCCESS, ccs_sparkline)
|
||||
|
||||
readiness_segment_bar = DashboardSegmentBar.new()
|
||||
readiness_segment_bar.custom_minimum_size = Vector2(0, 10)
|
||||
_add_stat_card("readiness", "Exam Readiness", "activity", Brand.PURPLE, readiness_segment_bar)
|
||||
|
||||
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", "circle-check", Brand.AMBER, accuracy_progress_bar)
|
||||
|
||||
analytics_grid = GridContainer.new()
|
||||
analytics_grid.columns = 2
|
||||
@@ -163,10 +187,15 @@ func _rebuild(_is_dark: bool = UIThemeService.is_dark_mode()) -> void:
|
||||
func _render() -> void:
|
||||
var data: Dictionary = DashboardService.data
|
||||
status_label.visible = false
|
||||
_set_metric("qbank", str(int(data.get("questionBankRating", 1000))), "%d rated questions" % int(data.get("ratedQuestionCount", 0)))
|
||||
_set_metric("ccs", str(int(data.get("ccsRating", 1000))), "%d rated cases" % int(data.get("ratedCcsCaseCount", 0)))
|
||||
_set_metric("accuracy", "%d%%" % int(data.get("firstAttemptAccuracy", 0)), "Last %d questions" % int(data.get("accuracySampleSize", 0)))
|
||||
_set_metric("time", _format_duration(int(data.get("studyTimeSecondsThisWeek", 0))), "This week")
|
||||
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", []))
|
||||
@@ -177,6 +206,71 @@ func _render() -> void:
|
||||
_render_recent(data.get("recentActivity", []))
|
||||
|
||||
|
||||
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 := "+" if delta >= 0 else ""
|
||||
trend_text = "%s %s%d this week" % [arrow, sign, 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 := "You're on track!" if label != "Low" else "Keep practicing"
|
||||
_set_stat_card("readiness", label, "Projected 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 := "+" if delta >= 0 else ""
|
||||
trend_text = "%s %s%d%% vs last 100" % [arrow, sign, 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(systems_value: Array) -> void:
|
||||
_clear(system_list)
|
||||
var systems := systems_value.duplicate(true)
|
||||
@@ -321,38 +415,56 @@ func _render_recent(items: Array) -> void:
|
||||
recent_list.add_child(row)
|
||||
|
||||
|
||||
func _add_metric(key: String, title: String, icon_name: String, accent: Color) -> void:
|
||||
var cell := HBoxContainer.new()
|
||||
cell.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
cell.custom_minimum_size = Vector2(230, 96)
|
||||
cell.add_theme_constant_override("separation", 12)
|
||||
func _add_stat_card(key: String, title: String, icon_name: String, accent: Color, bottom_visual: Control) -> 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(48, 48)
|
||||
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(UIThemeService.is_dark_mode(), accent, 24))
|
||||
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(22, 22)
|
||||
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)
|
||||
cell.add_child(icon_panel)
|
||||
var copy := VBoxContainer.new()
|
||||
copy.size_flags_vertical = Control.SIZE_SHRINK_CENTER
|
||||
copy.add_child(_label(title, 15, Brand.text_color(UIThemeService.is_dark_mode())))
|
||||
var value := _label("--", 29, Brand.text_color(UIThemeService.is_dark_mode()), true)
|
||||
copy.add_child(value)
|
||||
var detail := _label("Loading", 13, Brand.muted_color(UIThemeService.is_dark_mode()))
|
||||
copy.add_child(detail)
|
||||
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)
|
||||
var info_label := _label("ⓘ", 13, Brand.muted_color(dark))
|
||||
info_label.size_flags_vertical = Control.SIZE_SHRINK_BEGIN
|
||||
header.add_child(info_label)
|
||||
|
||||
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_details[key] = detail
|
||||
cell.add_child(copy)
|
||||
metric_grid.add_child(cell)
|
||||
metric_trends[key] = trend
|
||||
metric_details[key] = caption
|
||||
stat_card_grid.add_child(_card(card_content, 16))
|
||||
|
||||
|
||||
func _set_metric(key: String, value: String, detail: String) -> void:
|
||||
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
|
||||
(metric_details.get(key) as Label).text = detail
|
||||
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:
|
||||
@@ -534,10 +646,10 @@ func _show_error(message: String) -> void:
|
||||
|
||||
|
||||
func _update_responsive() -> void:
|
||||
if metric_grid == null:
|
||||
if stat_card_grid == null:
|
||||
return
|
||||
var compact := size.x < 1050.0
|
||||
metric_grid.columns = 2 if compact else 4
|
||||
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:
|
||||
@@ -550,12 +662,6 @@ func _clear(container: Container) -> void:
|
||||
child.free()
|
||||
|
||||
|
||||
func _format_duration(total_seconds: int) -> String:
|
||||
var hours := int(floor(float(total_seconds) / 3600.0))
|
||||
var minutes := int(floor(float(total_seconds % 3600) / 60.0))
|
||||
return "%dh %02dm" % [hours, minutes]
|
||||
|
||||
|
||||
func _relative_time(iso_value: String) -> String:
|
||||
var cleaned := iso_value.trim_suffix("Z")
|
||||
var occurred := Time.get_unix_time_from_datetime_string(cleaned)
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
extends Control
|
||||
|
||||
const Brand := preload("res://scripts/ui/medmind_style.gd")
|
||||
const GAP := 6.0
|
||||
const CORNER_RADIUS := 4
|
||||
|
||||
var filled_segments := 0
|
||||
var total_segments := 4
|
||||
var accent := Color.WHITE
|
||||
var dark := true
|
||||
|
||||
|
||||
func set_progress(next_filled: int, next_total: int, next_accent: Color, dark_mode: bool) -> void:
|
||||
filled_segments = next_filled
|
||||
total_segments = maxi(1, next_total)
|
||||
accent = next_accent
|
||||
dark = dark_mode
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
var segment_width: float = (size.x - GAP * float(total_segments - 1)) / float(total_segments)
|
||||
if segment_width <= 0.0:
|
||||
return
|
||||
var filled_box := StyleBoxFlat.new()
|
||||
filled_box.bg_color = accent
|
||||
filled_box.set_corner_radius_all(CORNER_RADIUS)
|
||||
var track_box := StyleBoxFlat.new()
|
||||
track_box.bg_color = Brand.muted_color(dark).lerp(Brand.background_color(dark), 0.5)
|
||||
track_box.set_corner_radius_all(CORNER_RADIUS)
|
||||
for index in range(total_segments):
|
||||
var x: float = index * (segment_width + GAP)
|
||||
var rect := Rect2(x, 0.0, segment_width, size.y)
|
||||
draw_style_box(filled_box if index < filled_segments else track_box, rect)
|
||||
@@ -0,0 +1,29 @@
|
||||
extends Control
|
||||
|
||||
var values: PackedFloat32Array = []
|
||||
var accent := Color.WHITE
|
||||
var value_range := Vector2(0.0, 100.0)
|
||||
|
||||
|
||||
func set_values(next_values: Array, next_accent: Color, next_range := Vector2(0.0, 100.0)) -> void:
|
||||
values = PackedFloat32Array(next_values)
|
||||
accent = next_accent
|
||||
value_range = next_range
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
if values.size() < 2:
|
||||
if values.size() == 1:
|
||||
draw_circle(Vector2(size.x - 4.0, size.y * 0.5), 3.0, accent)
|
||||
return
|
||||
var span: float = maxf(0.0001, value_range.y - value_range.x)
|
||||
var points := PackedVector2Array()
|
||||
var count: int = values.size()
|
||||
for index in range(count):
|
||||
var x: float = lerpf(0.0, size.x, float(index) / float(count - 1))
|
||||
var ratio: float = clampf((values[index] - value_range.x) / span, 0.0, 1.0)
|
||||
var y: float = lerpf(size.y, 0.0, ratio)
|
||||
points.append(Vector2(x, y))
|
||||
draw_polyline(points, accent, 2.0, true)
|
||||
draw_circle(points[-1], 3.0, accent)
|
||||
Reference in New Issue
Block a user