Add question bank mastery paths and gap questions

This commit is contained in:
hz
2026-07-31 23:46:46 -05:00
parent 012d0cf196
commit 8c439c6294
28 changed files with 1409 additions and 121 deletions
+17 -3
View File
@@ -9,6 +9,8 @@ var size_spin: SpinBox
var mode_option: OptionButton
var status_label: Label
var start_button: Button
var requested_count := 10
var targeted_setup := false
func _ready() -> void:
set_anchors_preset(Control.PRESET_FULL_RECT)
@@ -67,7 +69,16 @@ func _ready() -> void:
func on_show(payload: Variant = null) -> void:
selected_system = str(payload) if payload != null else "All Systems"
if typeof(payload) == TYPE_DICTIONARY:
targeted_setup = true
selected_system = str(payload.get("step3_system", "All Systems"))
requested_count = int(payload.get("question_count", 10))
size_spin.value = requested_count
QuestionBankService.load_targeted_questions(selected_system, requested_count,
int(payload.get("difficulty_min", 0)), int(payload.get("difficulty_max", 0)))
else:
targeted_setup = false
selected_system = str(payload) if payload != null else "All Systems"
_refresh_setup()
@@ -77,14 +88,17 @@ func _refresh_setup() -> void:
system_label.text = "Zone: %s" % selected_system
var available := QuestionBankService.get_questions_for_system(selected_system).size()
size_spin.max_value = max(1, available)
size_spin.value = min(size_spin.value, available)
if not QuestionBankService.is_loading and available > 0:
size_spin.value = min(requested_count, available) if targeted_setup else min(size_spin.value, available)
targeted_setup = false
start_button.disabled = available == 0 or QuestionBankService.is_loading
if QuestionBankService.is_loading:
status_label.text = "Question API unavailable. Retrying in %d seconds..." % QuestionBankService.next_retry_seconds if QuestionBankService.load_state == "retrying" else "Loading question bank..."
elif available == 0:
status_label.text = QuestionBankService.last_error if QuestionBankService.last_error != "" else "No questions are available for this zone yet."
else:
status_label.text = "%d questions available." % available
status_label.text = "%d questions available.%s" % [available,
" The requested difficulty range was broadened." if QuestionBankService.targeted_fallback_used else ""]
func _start_block() -> void:
+150 -5
View File
@@ -33,6 +33,7 @@ var accuracy_progress_bar: ProgressBar
var pending_review_launch := false
var review_queue_service: Variant
var trend_mode := "rating"
var mastery_rows: Array[BoxContainer] = []
func _ready() -> void:
@@ -63,6 +64,7 @@ func _build() -> void:
metric_values.clear()
metric_details.clear()
metric_trends.clear()
mastery_rows.clear()
scroll = ScrollContainer.new()
scroll.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
@@ -131,7 +133,7 @@ func _build() -> void:
performance.add_child(trend_chart)
performance.add_child(HSeparator.new())
var system_header := HBoxContainer.new()
system_header.add_child(_section_title("Performance by system"))
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)
@@ -276,7 +278,7 @@ func _render_accuracy_card(data: Dictionary) -> void:
accuracy_progress_bar.value = accuracy
func _render_systems(systems_value: Array) -> void:
func _render_systems_legacy(systems_value: Array) -> void:
_clear(system_list)
var systems := systems_value.duplicate(true)
if systems.is_empty():
@@ -331,7 +333,7 @@ func _render_systems(systems_value: Array) -> void:
system_list.add_child(row)
func _render_focus(systems: Array) -> void:
func _render_focus_legacy(systems: Array) -> void:
_clear(focus_content)
focus_content.add_child(_section_title("Study focus"))
if systems.is_empty():
@@ -355,6 +357,143 @@ func _render_focus(systems: Array) -> void:
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", {
"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"))
@@ -495,12 +634,12 @@ func _review_action(text: String, icon_name: String, accent: Color, route: Strin
return button
func _start_review_queue() -> void:
func _start_review_queue(step3_system: String = "") -> void:
if pending_review_launch:
return
pending_review_launch = true
_show_loading_message("Preparing your review queue...")
review_queue_service.start_pass()
review_queue_service.start_pass(step3_system)
_render_review(DashboardService.data)
@@ -673,6 +812,12 @@ func _update_responsive() -> void:
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
func _clear(container: Container) -> void: