diff --git a/scripts/ui/dashboard_screen.gd b/scripts/ui/dashboard_screen.gd index d77eba6..cbbd81a 100644 --- a/scripts/ui/dashboard_screen.gd +++ b/scripts/ui/dashboard_screen.gd @@ -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) diff --git a/scripts/ui/dashboard_segment_bar.gd b/scripts/ui/dashboard_segment_bar.gd new file mode 100644 index 0000000..d7da91b --- /dev/null +++ b/scripts/ui/dashboard_segment_bar.gd @@ -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) diff --git a/scripts/ui/dashboard_sparkline.gd b/scripts/ui/dashboard_sparkline.gd new file mode 100644 index 0000000..01b3517 --- /dev/null +++ b/scripts/ui/dashboard_sparkline.gd @@ -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)