diff --git a/api/QuestionsApi.Tests/FlashcardSchedulingTests.cs b/api/QuestionsApi.Tests/FlashcardSchedulingTests.cs index a139938..eed51c1 100644 --- a/api/QuestionsApi.Tests/FlashcardSchedulingTests.cs +++ b/api/QuestionsApi.Tests/FlashcardSchedulingTests.cs @@ -102,4 +102,76 @@ public sealed class FlashcardSchedulingTests Assert.Equal(1, FlashcardScheduling.ComputeStreakDays(timestamps, Today)); } + + [Fact] + public void PickIndex_NonShuffle_ReturnsFirstNonExcluded() + { + var pool = new List { "a", "b", "c" }; + + var picked = FlashcardScheduling.PickIndex(pool, new HashSet { "a" }, shuffle: false, n => throw new InvalidOperationException("should not randomize")); + + Assert.Equal(1, picked); + } + + [Fact] + public void PickIndex_NonShuffle_EverythingExcluded_ReturnsNull() + { + var pool = new List { "a", "b" }; + + var picked = FlashcardScheduling.PickIndex(pool, new HashSet { "a", "b" }, shuffle: false, n => 0); + + Assert.Null(picked); + } + + [Fact] + public void PickIndex_EmptyPool_ReturnsNullRegardlessOfShuffle() + { + Assert.Null(FlashcardScheduling.PickIndex([], new HashSet(), shuffle: false, n => 0)); + Assert.Null(FlashcardScheduling.PickIndex([], new HashSet(), shuffle: true, n => 0)); + } + + [Fact] + public void PickIndex_Shuffle_StubReturningFirstCandidate_PicksFirstNonExcluded() + { + var pool = new List { "a", "b", "c" }; + + var picked = FlashcardScheduling.PickIndex(pool, new HashSet { "a" }, shuffle: true, n => 0); + + // Candidates (excluding "a") are ["b", "c"]; stub index 0 -> "b" -> original pool index 1. + Assert.Equal(1, picked); + } + + [Fact] + public void PickIndex_Shuffle_CanReturnSomethingOtherThanTheOldestCandidate() + { + var pool = new List { "a", "b", "c" }; + + // Stub picks the last candidate instead of always the first (oldest), proving this isn't + // hardcoded to FirstOrDefault-style behavior. + var picked = FlashcardScheduling.PickIndex(pool, new HashSet(), shuffle: true, n => n - 1); + + Assert.Equal(2, picked); + } + + [Fact] + public void PickIndex_Shuffle_ExcludedItemsDoNotShiftTheOriginalIndexMapping() + { + var pool = new List { "a", "b", "c", "d" }; + + // Candidates (excluding "b") are ["a", "c", "d"] at original indices [0, 2, 3]; stub + // index 2 should map back to original index 3 ("d"), not 2 ("c"). + var picked = FlashcardScheduling.PickIndex(pool, new HashSet { "b" }, shuffle: true, n => 2); + + Assert.Equal(3, picked); + } + + [Fact] + public void PickIndex_Shuffle_EverythingExcluded_ReturnsNull() + { + var pool = new List { "a", "b" }; + + var picked = FlashcardScheduling.PickIndex(pool, new HashSet { "a", "b" }, shuffle: true, n => 0); + + Assert.Null(picked); + } } diff --git a/api/QuestionsApi/Models/FlashcardModels.cs b/api/QuestionsApi/Models/FlashcardModels.cs index e4fdbba..6531d97 100644 --- a/api/QuestionsApi/Models/FlashcardModels.cs +++ b/api/QuestionsApi/Models/FlashcardModels.cs @@ -202,6 +202,7 @@ public sealed class FlashcardStudyRequest public string DeckId { get; set; } = ""; public List ExcludeCardIds { get; set; } = []; public DateTime StudyDayStartUtc { get; set; } = DateTime.UtcNow.Date; + public bool Shuffle { get; set; } } public sealed class FlashcardBulkNoteIdsRequest diff --git a/api/QuestionsApi/Services/Flashcards/FlashcardScheduling.cs b/api/QuestionsApi/Services/Flashcards/FlashcardScheduling.cs index f206c70..663eb48 100644 --- a/api/QuestionsApi/Services/Flashcards/FlashcardScheduling.cs +++ b/api/QuestionsApi/Services/Flashcards/FlashcardScheduling.cs @@ -32,4 +32,23 @@ public static class FlashcardScheduling } return streak; } + + // Picks which candidate in an already-sorted pool becomes the next card. Non-shuffle: the + // first not already excluded this session (today's deterministic oldest-first behavior). + // Shuffle: a random pick among the non-excluded candidates - `randomIndex(n)` must return a + // value in [0, n); production passes Random.Shared.Next, tests pass a fixed stub so the + // excluded-id-to-original-index mapping is verifiable without relying on real randomness. + public static int? PickIndex(IReadOnlyList poolIds, ISet exclude, bool shuffle, Func randomIndex) + { + if (!shuffle) + { + for (var i = 0; i < poolIds.Count; i++) + if (!exclude.Contains(poolIds[i])) return i; + return null; + } + var candidates = new List(); + for (var i = 0; i < poolIds.Count; i++) + if (!exclude.Contains(poolIds[i])) candidates.Add(i); + return candidates.Count == 0 ? null : candidates[randomIndex(candidates.Count)]; + } } diff --git a/api/QuestionsApi/Services/Flashcards/FlashcardStudyService.cs b/api/QuestionsApi/Services/Flashcards/FlashcardStudyService.cs index b590b4f..dfd4fbb 100644 --- a/api/QuestionsApi/Services/Flashcards/FlashcardStudyService.cs +++ b/api/QuestionsApi/Services/Flashcards/FlashcardStudyService.cs @@ -11,6 +11,10 @@ public sealed class FlashcardStudyService( FlashcardScheduleStore schedules, FlashcardSettingsStore settingsStore) { + // Extra rows fetched beyond the budget when shuffling, so there's an actual pool to + // randomize over instead of just enough rows to skip past this session's exclusions. + private const int ShuffleCandidatePoolPadding = 40; + public async Task GetNextCardAsync(string userId, FlashcardStudyRequest request) { var now = DateTime.UtcNow; @@ -19,17 +23,23 @@ public sealed class FlashcardStudyService( var (newBudget, reviewBudget) = await ResolveBudgetsAsync(userId, request.DeckId, studyDayStart); var exclude = request.ExcludeCardIds.Where(v => !string.IsNullOrWhiteSpace(v)).ToHashSet(StringComparer.Ordinal); - // Overdue/due cards first (oldest due first), then new cards. + // Overdue/due cards first (oldest due first, or shuffled within the due pool), then new + // cards (oldest first, or shuffled within the new pool) - shuffle never lets a new card + // jump ahead of a due one, it only randomizes selection within each pool. FlashcardSchedule? card = null; if (reviewBudget > 0) { - var due = await schedules.GetDueAsync(userId, noteIds, now, studyDayStart, reviewBudget + exclude.Count + 1); - card = due.FirstOrDefault(v => !exclude.Contains(v.Id!)); + var dueLimit = (request.Shuffle ? Math.Max(reviewBudget, ShuffleCandidatePoolPadding) : reviewBudget) + exclude.Count + 1; + var due = await schedules.GetDueAsync(userId, noteIds, now, studyDayStart, dueLimit); + var pickedIndex = FlashcardScheduling.PickIndex(due.Select(v => v.Id!).ToList(), exclude, request.Shuffle, Random.Shared.Next); + if (pickedIndex is int dueIndex) card = due[dueIndex]; } if (card is null && newBudget > 0) { - var fresh = await schedules.GetNewAsync(userId, noteIds, studyDayStart, newBudget + exclude.Count + 1); - card = fresh.FirstOrDefault(v => !exclude.Contains(v.Id!)); + var newLimit = (request.Shuffle ? Math.Max(newBudget, ShuffleCandidatePoolPadding) : newBudget) + exclude.Count + 1; + var fresh = await schedules.GetNewAsync(userId, noteIds, studyDayStart, newLimit); + var pickedIndex = FlashcardScheduling.PickIndex(fresh.Select(v => v.Id!).ToList(), exclude, request.Shuffle, Random.Shared.Next); + if (pickedIndex is int newIndex) card = fresh[newIndex]; } var dueRemaining = checked((int)await schedules.CountDueAsync(userId, noteIds, now, studyDayStart)); diff --git a/scripts/autoload/flashcard_service.gd b/scripts/autoload/flashcard_service.gd index 7497090..005db90 100644 --- a/scripts/autoload/flashcard_service.gd +++ b/scripts/autoload/flashcard_service.gd @@ -95,7 +95,7 @@ func current_study_day_start_utc() -> String: # ---------- Next card / study session ---------- -func request_next_card(deck_id: String, exclude_ids: Array, study_day_start_utc: String) -> void: +func request_next_card(deck_id: String, exclude_ids: Array, study_day_start_utc: String, shuffle: bool = false) -> void: if loading_next or not AuthState.can_send_authenticated_request(): next_card_failed.emit("Login is required to study flashcards.") return @@ -103,7 +103,8 @@ func request_next_card(deck_id: String, exclude_ids: Array, study_day_start_utc: var payload := { "deckId": deck_id, "excludeCardIds": exclude_ids, - "studyDayStartUtc": study_day_start_utc + "studyDayStartUtc": study_day_start_utc, + "shuffle": shuffle } var error := next_request.request( "%s/study/next" % api_url, _headers(), HTTPClient.METHOD_POST, JSON.stringify(payload)) diff --git a/scripts/autoload/flashcard_service.gd.uid b/scripts/autoload/flashcard_service.gd.uid new file mode 100644 index 0000000..82e5b60 --- /dev/null +++ b/scripts/autoload/flashcard_service.gd.uid @@ -0,0 +1 @@ +uid://46eks1fy86s4 diff --git a/scripts/ui/circular_progress.gd.uid b/scripts/ui/circular_progress.gd.uid new file mode 100644 index 0000000..00b6e52 --- /dev/null +++ b/scripts/ui/circular_progress.gd.uid @@ -0,0 +1 @@ +uid://co8k1cl8jr45x diff --git a/scripts/ui/flashcard_cloze.gd.uid b/scripts/ui/flashcard_cloze.gd.uid new file mode 100644 index 0000000..2e937ee --- /dev/null +++ b/scripts/ui/flashcard_cloze.gd.uid @@ -0,0 +1 @@ +uid://d0v44e6aprd1a diff --git a/scripts/ui/flashcard_prefill_dialog.gd.uid b/scripts/ui/flashcard_prefill_dialog.gd.uid new file mode 100644 index 0000000..7d190cf --- /dev/null +++ b/scripts/ui/flashcard_prefill_dialog.gd.uid @@ -0,0 +1 @@ +uid://cy1cu55mkv0wd diff --git a/scripts/ui/flashcards_hub.gd b/scripts/ui/flashcards_hub.gd index ea62ff1..3144102 100644 --- a/scripts/ui/flashcards_hub.gd +++ b/scripts/ui/flashcards_hub.gd @@ -17,7 +17,6 @@ var deck_selector: OptionButton var deck_ids: Array = [] var new_deck_button: Button var study_button: Button -var export_button: Button var import_button: Button var search_input: LineEdit var type_filter: OptionButton @@ -41,7 +40,6 @@ var editor_tags_input: LineEdit var editor_hint_label: Label var editor_error_label: Label -var export_menu: PanelContainer var import_menu: PanelContainer var dropdown_backdrop: Control @@ -100,7 +98,6 @@ func _ready() -> void: FlashcardService.notes_loaded.connect(_on_notes_loaded) FlashcardService.note_saved.connect(_on_note_saved) FlashcardService.request_failed.connect(_on_request_failed) - FlashcardService.export_ready.connect(_on_export_ready) FlashcardService.import_preview_ready.connect(_on_import_preview_ready) FlashcardService.import_committed.connect(_on_import_committed) UIThemeService.theme_changed.connect(func(_dark: bool): _refresh_status()) @@ -228,20 +225,6 @@ func _build_toolbar() -> void: FlashcardService.pick_import_file("json")], ]) - export_button = Button.new() - export_button.text = "Export" - export_button.custom_minimum_size = Vector2(0, 44) - export_button.pressed.connect(func(): _toggle_dropdown(export_menu, export_button)) - row.add_child(export_button) - export_menu = _build_dropdown([ - ["Export CSV", func(): - status_label.text = "Preparing export..." - FlashcardService.export_csv()], - ["Export JSON", func(): - status_label.text = "Preparing export..." - FlashcardService.export_json()], - ]) - func _style_primary_button(button: Button) -> void: var normal := StyleBoxFlat.new() @@ -301,8 +284,6 @@ func _toggle_dropdown(menu: PanelContainer, anchor: Control) -> void: func _close_dropdowns() -> void: - if export_menu != null: - export_menu.visible = false if import_menu != null: import_menu.visible = false if dropdown_backdrop != null: @@ -684,10 +665,6 @@ func _start_study() -> void: main.show_screen("flashcards_study", _selected_deck_id()) -func _on_export_ready(location: String) -> void: - status_label.text = "Exported to %s." % location - - # ---------- Import preview / commit ---------- func _build_import_overlay() -> void: @@ -813,8 +790,6 @@ func _refresh_status() -> void: editor_deck_label.add_theme_color_override("font_color", Brand.muted_color(dark)) if import_panel != null: import_panel.add_theme_stylebox_override("panel", Brand.panel_box(dark)) - if export_menu != null: - export_menu.add_theme_stylebox_override("panel", Brand.panel_box(dark)) if import_menu != null: import_menu.add_theme_stylebox_override("panel", Brand.panel_box(dark)) if list_card != null: diff --git a/scripts/ui/flashcards_hub.gd.uid b/scripts/ui/flashcards_hub.gd.uid new file mode 100644 index 0000000..3b87fb9 --- /dev/null +++ b/scripts/ui/flashcards_hub.gd.uid @@ -0,0 +1 @@ +uid://dkqsqq7b0dlgb diff --git a/scripts/ui/flashcards_study.gd b/scripts/ui/flashcards_study.gd index 15627ba..b2aa911 100644 --- a/scripts/ui/flashcards_study.gd +++ b/scripts/ui/flashcards_study.gd @@ -6,6 +6,7 @@ const Cloze := preload("res://scripts/ui/flashcard_cloze.gd") const CircularProgress := preload("res://scripts/ui/circular_progress.gd") const MASTERY_STABILITY_LABEL := "Cards graduate to \"mastered\" once they've held up in review for a few weeks straight." +const PROGRESS_PIP_COUNT := 5 var main @@ -24,7 +25,26 @@ var awaiting_next := false var due_remaining := 0 var new_remaining := 0 +# Session history: every card that leaves the "live" slot (graded, skipped, buried, or +# suspended) is pushed here, so Previous/Next can page back through what happened this session. +# Invariant: history_cursor == session_history.size() means "at the live card"; anything less +# means we're viewing a past entry in read-only mode. +var session_history: Array = [] +var history_cursor := 0 +var session_total := -1 +var shuffle_enabled := false + +# Shadow copy of the true in-flight card, populated only while browsing history so the live +# card is never lost and can be restored exactly via _return_to_live(). +var live_note: Dictionary = {} +var live_card: Dictionary = {} +var live_rating_options: Array = [] +var live_revealed := false +var live_due_remaining := 0 +var live_new_remaining := 0 + var progress_label: Label +var progress_pips: Array = [] var paused_banner: Label var main_row: HBoxContainer var card_panel: PanelContainer @@ -34,13 +54,19 @@ var answer_caption: Label var back_label: Label var extra_caption: Label var extra_label: Label +var card_position_label: Label var reveal_button: Button var grade_row: HBoxContainer var grade_buttons: Dictionary = {} var undo_button: Button +var previous_button: Button +var next_button: Button +var shuffle_button: Button var options_button: Button var options_menu: PanelContainer var options_backdrop: Control +var options_bury_button: Button +var options_suspend_button: Button var empty_label: Label var compact_layout := false @@ -82,6 +108,9 @@ func _ready() -> void: func on_show(payload: Variant = null) -> void: deck_id = str(payload) if payload != null else "" seen_card_ids.clear() + session_history.clear() + history_cursor = 0 + session_total = -1 study_day_start_utc = FlashcardService.current_study_day_start_utc() current_note = {} current_card = {} @@ -91,6 +120,7 @@ func on_show(payload: Variant = null) -> void: undo_button.visible = false _close_options_menu() _render_loading() + _update_footer_button_states() _load_next() FlashcardService.fetch_decks() FlashcardService.fetch_counts() @@ -114,6 +144,17 @@ func _build() -> void: progress_label.add_theme_font_size_override("font_size", 15) top_row.add_child(progress_label) + var pips_row := HBoxContainer.new() + pips_row.alignment = BoxContainer.ALIGNMENT_CENTER + pips_row.add_theme_constant_override("separation", 6) + root.add_child(pips_row) + progress_pips = [] + for i in PROGRESS_PIP_COUNT: + var pip := PanelContainer.new() + pip.custom_minimum_size = Vector2(48, 6) + pips_row.add_child(pip) + progress_pips.append(pip) + paused_banner = Label.new() paused_banner.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER paused_banner.add_theme_color_override("font_color", Style.AMBER) @@ -188,6 +229,16 @@ func _build() -> void: extra_label.visible = false extra_block.add_child(extra_label) + var position_row := HBoxContainer.new() + card_content.add_child(position_row) + var position_spacer := Control.new() + position_spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL + position_row.add_child(position_spacer) + card_position_label = Label.new() + card_position_label.add_theme_font_size_override("font_size", 12) + card_position_label.visible = false + position_row.add_child(card_position_label) + reveal_button = Button.new() reveal_button.text = "Show Answer (Space)" reveal_button.custom_minimum_size = Vector2(0, 52) @@ -208,23 +259,44 @@ func _build() -> void: grade_row.add_child(button) grade_buttons[spec[0]] = button - var footer_row := HBoxContainer.new() - footer_row.add_theme_constant_override("separation", 10) - center_column.add_child(footer_row) undo_button = Button.new() - undo_button.text = "Undo last review" - undo_button.custom_minimum_size = Vector2(0, 36) + undo_button.text = "Undo this rating" + undo_button.custom_minimum_size = Vector2(0, 40) undo_button.visible = false undo_button.pressed.connect(_undo) - footer_row.add_child(undo_button) - var footer_spacer := Control.new() - footer_spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL - footer_row.add_child(footer_spacer) + center_column.add_child(undo_button) + + var footer_center := CenterContainer.new() + center_column.add_child(footer_center) + var footer_row := HBoxContainer.new() + footer_row.add_theme_constant_override("separation", 10) + footer_center.add_child(footer_row) + + previous_button = Button.new() + previous_button.text = "‹ Previous" + previous_button.custom_minimum_size = Vector2(120, 44) + previous_button.pressed.connect(_go_previous) + footer_row.add_child(previous_button) + + shuffle_button = Button.new() + shuffle_button.text = "Shuffle" + shuffle_button.toggle_mode = true + shuffle_button.custom_minimum_size = Vector2(110, 44) + shuffle_button.toggled.connect(func(pressed: bool): shuffle_enabled = pressed) + footer_row.add_child(shuffle_button) + _build_options_menu(footer_row) + next_button = Button.new() + next_button.text = "Next ›" + next_button.custom_minimum_size = Vector2(120, 44) + next_button.pressed.connect(_go_next) + footer_row.add_child(next_button) + _build_stats_panel() refresh_theme_controls() + _update_footer_button_states() func _caption_label(text: String) -> Label: @@ -267,6 +339,13 @@ func _side_panel_box(dark: bool) -> StyleBoxFlat: return box +func _pip_box(filled: bool, dark: bool) -> StyleBoxFlat: + var box := StyleBoxFlat.new() + box.bg_color = Brand.ROYAL_BLUE if filled else (Color("26324a") if dark else Color("e2e8f0")) + box.set_corner_radius_all(3) + return box + + func _panel_caption(text: String) -> Label: var label := Label.new() label.text = text @@ -279,6 +358,7 @@ func _panel_caption(text: String) -> Label: func _build_deck_panel() -> void: deck_panel = PanelContainer.new() deck_panel.custom_minimum_size = Vector2(220, 0) + deck_panel.size_flags_vertical = Control.SIZE_SHRINK_BEGIN main_row.add_child(deck_panel) var content := VBoxContainer.new() content.add_theme_constant_override("separation", 10) @@ -315,6 +395,7 @@ func _build_deck_panel() -> void: func _build_stats_panel() -> void: stats_panel = PanelContainer.new() stats_panel.custom_minimum_size = Vector2(220, 0) + stats_panel.size_flags_vertical = Control.SIZE_SHRINK_BEGIN main_row.add_child(stats_panel) var content := VBoxContainer.new() content.add_theme_constant_override("separation", 10) @@ -373,7 +454,7 @@ func _stat_row(parent: VBoxContainer, caption: String) -> Label: return value -# ---------- Options menu (suspend / bury current card) ---------- +# ---------- Options menu ---------- func _build_options_menu(footer_row: HBoxContainer) -> void: options_backdrop = Control.new() @@ -388,7 +469,7 @@ func _build_options_menu(footer_row: HBoxContainer) -> void: options_button = Button.new() options_button.text = "Options" - options_button.custom_minimum_size = Vector2(0, 36) + options_button.custom_minimum_size = Vector2(0, 44) options_button.pressed.connect(_toggle_options_menu) footer_row.add_child(options_button) @@ -399,27 +480,31 @@ func _build_options_menu(footer_row: HBoxContainer) -> void: var list := VBoxContainer.new() list.add_theme_constant_override("separation", 2) options_menu.add_child(list) - for item in [ - ["Bury card until tomorrow", func(): _bury_current_card()], - ["Suspend card", func(): _suspend_current_card()], - ]: - var button := Button.new() - button.text = str(item[0]) - button.custom_minimum_size = Vector2(200, 40) - button.alignment = HORIZONTAL_ALIGNMENT_LEFT - var action: Callable = item[1] - button.pressed.connect(func(): - _close_options_menu() - action.call()) - list.add_child(button) + + _options_menu_item(list, "Manage Flashcards", func(): main.show_screen("flashcards_hub")) + options_bury_button = _options_menu_item(list, "Bury card until tomorrow", func(): _bury_current_card()) + options_suspend_button = _options_menu_item(list, "Suspend card", func(): _suspend_current_card()) + + +func _options_menu_item(list: VBoxContainer, text: String, action: Callable) -> Button: + var button := Button.new() + button.text = text + button.custom_minimum_size = Vector2(200, 40) + button.alignment = HORIZONTAL_ALIGNMENT_LEFT + button.pressed.connect(func(): + _close_options_menu() + action.call()) + list.add_child(button) + return button func _toggle_options_menu() -> void: if options_menu.visible: _close_options_menu() return - if current_card.is_empty(): - return + var at_live_with_card := history_cursor == session_history.size() and not current_card.is_empty() + options_bury_button.disabled = not at_live_with_card + options_suspend_button.disabled = not at_live_with_card options_menu.visible = true options_backdrop.visible = true options_menu.position = options_button.get_global_position() - Vector2(0, options_menu.size.y + 4) @@ -433,10 +518,11 @@ func _close_options_menu() -> void: func _bury_current_card() -> void: - if current_card.is_empty(): + if history_cursor != session_history.size() or current_card.is_empty(): return var card_id := str(current_card.get("id", "")) FlashcardService.bury_cards([card_id], _bury_until_tomorrow_utc()) + _push_history("buried", card_id) if not seen_card_ids.has(card_id): seen_card_ids.append(card_id) _load_next() @@ -444,10 +530,11 @@ func _bury_current_card() -> void: func _suspend_current_card() -> void: - if current_card.is_empty(): + if history_cursor != session_history.size() or current_card.is_empty(): return var card_id := str(current_card.get("id", "")) FlashcardService.suspend_cards([card_id], true) + _push_history("suspended", card_id) if not seen_card_ids.has(card_id): seen_card_ids.append(card_id) _load_next() @@ -469,10 +556,18 @@ func refresh_theme_controls() -> void: answer_caption.add_theme_color_override("font_color", Brand.ROYAL_BLUE if dark else Brand.PURPLE) extra_caption.add_theme_color_override("font_color", Brand.muted_color(dark)) extra_label.add_theme_color_override("font_color", Brand.muted_color(dark)) + card_position_label.add_theme_color_override("font_color", Brand.muted_color(dark)) _style_ghost_button(undo_button, dark) + previous_button.add_theme_stylebox_override("normal", Brand.secondary_button_box(dark)) + previous_button.add_theme_stylebox_override("hover", Brand.secondary_button_box(dark, true)) + next_button.add_theme_stylebox_override("normal", Brand.secondary_button_box(dark)) + next_button.add_theme_stylebox_override("hover", Brand.secondary_button_box(dark, true)) + options_button.add_theme_stylebox_override("normal", Brand.secondary_button_box(dark)) + options_button.add_theme_stylebox_override("hover", Brand.secondary_button_box(dark, true)) deck_panel.add_theme_stylebox_override("panel", _side_panel_box(dark)) stats_panel.add_theme_stylebox_override("panel", _side_panel_box(dark)) options_menu.add_theme_stylebox_override("panel", Brand.panel_box(dark)) + _update_progress_pips(history_cursor) _refresh_deck_panel() _refresh_stats_panel() @@ -548,9 +643,125 @@ func _refresh_stats_panel() -> void: daily_goal_bar.value = float(reviews_today) +# ---------- Session history / navigation ---------- + +func _push_history(action: String, card_id: String, rating: String = "", client_review_id: String = "") -> void: + var entry := { + "note": current_note, "card": current_card, "rating_options": rating_options, + "action": action, "card_id": card_id + } + if not rating.is_empty(): + entry["rating"] = rating + entry["client_review_id"] = client_review_id + session_history.append(entry) + history_cursor = session_history.size() + + +func _snapshot_live_before_browsing() -> void: + live_note = current_note + live_card = current_card + live_rating_options = rating_options + live_revealed = revealed + live_due_remaining = due_remaining + live_new_remaining = new_remaining + + +func _return_to_live() -> void: + history_cursor = session_history.size() + current_note = live_note + current_card = live_card + rating_options = live_rating_options + due_remaining = live_due_remaining + new_remaining = live_new_remaining + undo_button.visible = false + revealed = false + if current_note.is_empty() and current_card.is_empty(): + _render_empty({"dueRemaining": due_remaining, "newRemaining": new_remaining}) + else: + _render_card() + if live_revealed: + _reveal() + _update_card_position_label(history_cursor) + _update_progress_pips(history_cursor) + _update_footer_button_states() + + +func _render_history_entry(index: int) -> void: + var entry: Dictionary = session_history[index] + current_note = entry.get("note", {}) + current_card = entry.get("card", {}) + rating_options = entry.get("rating_options", []) + revealed = false + _render_card() + _reveal() + grade_row.visible = false + reveal_button.visible = false + var is_graded_entry: bool = entry.get("action", "") == "graded" + var is_last_graded_card: bool = str(entry.get("card_id", "")) == last_card_id + var has_undoable_review: bool = not last_client_review_id.is_empty() + undo_button.visible = is_graded_entry and is_last_graded_card and has_undoable_review + undo_button.disabled = false + undo_button.text = "Undo this rating" + _update_card_position_label(index) + _update_progress_pips(index) + _update_footer_button_states() + + +func _go_previous() -> void: + if history_cursor <= 0: + return + if history_cursor == session_history.size(): + _snapshot_live_before_browsing() + history_cursor -= 1 + _render_history_entry(history_cursor) + + +func _go_next() -> void: + if history_cursor >= session_history.size(): + _skip_current_card() + return + history_cursor += 1 + if history_cursor >= session_history.size(): + _return_to_live() + else: + _render_history_entry(history_cursor) + + +func _skip_current_card() -> void: + if current_card.is_empty() or awaiting_next: + return + var card_id := str(current_card.get("id", "")) + _push_history("skipped", card_id) + if not seen_card_ids.has(card_id): + seen_card_ids.append(card_id) + _load_next() + _render_loading() + + +func _update_card_position_label(position_index: int) -> void: + card_position_label.visible = session_total > 0 + if session_total > 0: + card_position_label.text = "Card %d of %d" % [position_index + 1, maxi(session_total, position_index + 1)] + + +func _update_progress_pips(position_index: int) -> void: + var dark := UIThemeService.is_dark_mode() + var fraction := 0.0 if session_total <= 0 else float(position_index) / float(session_total) + var filled := clampi(floori(fraction * progress_pips.size()), 0, progress_pips.size()) + for i in progress_pips.size(): + var pip: PanelContainer = progress_pips[i] + pip.add_theme_stylebox_override("panel", _pip_box(i < filled, dark)) + + +func _update_footer_button_states() -> void: + var at_live := history_cursor == session_history.size() + previous_button.disabled = history_cursor <= 0 + next_button.disabled = at_live and current_card.is_empty() + + func _load_next() -> void: awaiting_next = true - FlashcardService.request_next_card(deck_id, seen_card_ids, study_day_start_utc) + FlashcardService.request_next_card(deck_id, seen_card_ids, study_day_start_utc, shuffle_enabled) func _on_next_card_loaded(card: Dictionary) -> void: @@ -561,15 +772,22 @@ func _on_next_card_loaded(card: Dictionary) -> void: current_note = {} current_card = {} _render_empty(card) + _update_footer_button_states() return current_note = note current_card = schedule rating_options = card.get("ratingOptions", []) due_remaining = int(card.get("dueRemaining", 0)) new_remaining = int(card.get("newRemaining", 0)) + if session_total < 0: + session_total = due_remaining + new_remaining + history_cursor = session_history.size() revealed = false loaded_started_ms = Time.get_ticks_msec() _render_card() + _update_card_position_label(history_cursor) + _update_progress_pips(history_cursor) + _update_footer_button_states() func _on_next_card_failed(message: String) -> void: @@ -652,15 +870,15 @@ func _interval_label_for(rating: String) -> String: func _grade(rating: String) -> void: - if current_card.is_empty(): + if current_card.is_empty() or history_cursor != session_history.size(): return var card_id := str(current_card.get("id", "")) var expected_revision := int(current_card.get("revision", 0)) var duration_seconds := int(float(Time.get_ticks_msec() - loaded_started_ms) / 1000.0) - last_client_review_id = FlashcardService.submit_review(card_id, rating, expected_revision, duration_seconds, study_day_start_utc) + var client_review_id := FlashcardService.submit_review(card_id, rating, expected_revision, duration_seconds, study_day_start_utc) + last_client_review_id = client_review_id last_card_id = card_id - undo_button.visible = true - undo_button.disabled = false + _push_history("graded", card_id, rating, client_review_id) if not seen_card_ids.has(card_id): seen_card_ids.append(card_id) _load_next() @@ -677,10 +895,13 @@ func _undo() -> void: return undo_button.disabled = true FlashcardService.undo_last_review(last_client_review_id, func(_restored: Dictionary): + var undone_card_id := last_card_id last_client_review_id = "" - undo_button.visible = false - seen_card_ids.erase(last_card_id) + last_card_id = "" + seen_card_ids.erase(undone_card_id) + _return_to_live() _load_next() + _render_loading() FlashcardService.fetch_counts()) @@ -700,6 +921,8 @@ func _input(event: InputEvent) -> void: return if not (event is InputEventKey) or not event.pressed or event.echo: return + if history_cursor != session_history.size(): + return if not revealed: if event.keycode == KEY_SPACE: _reveal() diff --git a/scripts/ui/flashcards_study.gd.uid b/scripts/ui/flashcards_study.gd.uid new file mode 100644 index 0000000..d340f2b --- /dev/null +++ b/scripts/ui/flashcards_study.gd.uid @@ -0,0 +1 @@ +uid://cd26fs2ry8iij diff --git a/scripts/ui/main.gd b/scripts/ui/main.gd index f923c6d..45668d3 100644 --- a/scripts/ui/main.gd +++ b/scripts/ui/main.gd @@ -455,7 +455,7 @@ func _on_navigation_pressed(key: String) -> void: "question_bank": show_screen("selection", "question_bank") "exams": show_screen("simulation_history") "ccs": show_screen("ccs_hub") - "flashcards": show_screen("flashcards_hub") + "flashcards": show_screen("flashcards_study") "performance": show_screen("profile") "study_plan": show_screen("map") "settings": show_screen("settings") diff --git a/tests/flashcard_cloze_test.gd.uid b/tests/flashcard_cloze_test.gd.uid new file mode 100644 index 0000000..807771d --- /dev/null +++ b/tests/flashcard_cloze_test.gd.uid @@ -0,0 +1 @@ +uid://c7vg16bm3svqg diff --git a/tests/flashcard_navigation_test.gd.uid b/tests/flashcard_navigation_test.gd.uid new file mode 100644 index 0000000..50903d9 --- /dev/null +++ b/tests/flashcard_navigation_test.gd.uid @@ -0,0 +1 @@ +uid://brao3s40oeuax diff --git a/tests/flashcard_outbox_test.gd.uid b/tests/flashcard_outbox_test.gd.uid new file mode 100644 index 0000000..37f1ade --- /dev/null +++ b/tests/flashcard_outbox_test.gd.uid @@ -0,0 +1 @@ +uid://ckoxu1ks8sr1d diff --git a/tests/flashcard_session_navigation_test.gd b/tests/flashcard_session_navigation_test.gd new file mode 100644 index 0000000..3f77290 --- /dev/null +++ b/tests/flashcard_session_navigation_test.gd @@ -0,0 +1,173 @@ +extends SceneTree + +# Exercises the study screen's session-history navigation (skip / Previous / Next / undo-in- +# history) and the layout/footer-state fixes added alongside it. The screen is added to the +# tree (mirrors flashcard_theme_responsive_test.gd) so _input()'s tree-visibility guard behaves +# realistically; every FlashcardService call this drives (request_next_card/bury_cards/ +# suspend_cards) short-circuits before touching the network because AuthState reports no +# logged-in session in this headless context, matching the "no network" style of the other +# flashcard tests. + +var failures := 0 + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + var host := Control.new() + host.size = Vector2(1280, 800) + root.add_child(host) + + var Study := load("res://scripts/ui/flashcards_study.gd") + var screen = Study.new() + host.add_child(screen) + await process_frame + + # ---------- Layout: side panels must hug their content, not stretch full height ---------- + _assert(screen.deck_panel.size_flags_vertical == Control.SIZE_SHRINK_BEGIN, + "the current-deck panel should not stretch to the row's full height") + _assert(screen.stats_panel.size_flags_vertical == Control.SIZE_SHRINK_BEGIN, + "the spaced-repetition panel should not stretch to the row's full height") + + # ---------- Footer button states at session start (no history, no live card) ---------- + _assert(screen.previous_button.disabled, "Previous should be disabled with no history to step back into") + _assert(screen.next_button.disabled, "Next should be disabled with no live card to skip and no history to page through") + + screen.current_note = {"type": "basic", "prompt": "P3(live)", "answer": "A3", "extra": ""} + screen.current_card = {"id": "card-3", "variant": 0, "revision": 1} + screen.rating_options = [] + screen.due_remaining = 5 + screen.new_remaining = 3 + screen.session_total = 10 + screen._update_footer_button_states() + _assert(not screen.next_button.disabled, "Next should enable once a live card is present, to allow skipping it") + _assert(screen.previous_button.disabled, "Previous should still be disabled before any history exists") + + # ---------- Options menu opens even with no live card, and disables Bury/Suspend ---------- + screen.current_note = {} + screen.current_card = {} + screen._toggle_options_menu() + _assert(screen.options_menu.visible, "Options should open even with no live card (e.g. deck exhausted)") + _assert(screen.options_bury_button.disabled and screen.options_suspend_button.disabled, + "Bury/Suspend should disable when there is no live card to act on") + screen._close_options_menu() + + screen.current_note = {"type": "basic", "prompt": "P3(live)", "answer": "A3", "extra": ""} + screen.current_card = {"id": "card-3", "variant": 0, "revision": 1} + screen._toggle_options_menu() + _assert(not screen.options_bury_button.disabled and not screen.options_suspend_button.disabled, + "Bury/Suspend should enable once a live card is present") + screen._close_options_menu() + + # ---------- Undo-in-history only surfaces for the matching most-recently-graded entry ---------- + screen.session_history = [{ + "note": {"type": "basic", "prompt": "P1", "answer": "A1", "extra": ""}, + "card": {"id": "card-1", "variant": 0, "revision": 1}, + "rating_options": [], "action": "graded", "card_id": "card-1", + "rating": "good", "client_review_id": "review-1" + }] + screen.last_card_id = "card-99" + screen.last_client_review_id = "review-1" + screen._render_history_entry(0) + _assert(not screen.undo_button.visible, + "undo should not surface when the browsed entry's card id doesn't match the last graded card") + + screen.last_card_id = "" + screen.last_client_review_id = "" + screen._render_history_entry(0) + _assert(not screen.undo_button.visible, + "undo should not surface when there is no outstanding undoable review at all") + + # ---------- Skip: pushes a "skipped" history entry, excludes the card, no grading occurs ---------- + screen.seen_card_ids = [] + screen.session_history = [] + screen.history_cursor = 0 + screen.current_note = {"type": "basic", "prompt": "P3(live)", "answer": "A3", "extra": ""} + screen.current_card = {"id": "card-3", "variant": 0, "revision": 1} + screen.awaiting_next = false + screen._skip_current_card() + _assert(screen.session_history.size() == 1 and screen.session_history[0]["action"] == "skipped", + "skipping the live card should push a 'skipped' history entry") + _assert(screen.seen_card_ids.has("card-3"), "skipping should still exclude the card from reappearing this session") + + # ---------- Previous/Next paging through a hand-seeded history, back to live ---------- + screen.seen_card_ids = [] + screen.last_card_id = "card-1" + screen.last_client_review_id = "review-1" + screen.session_history = [ + { + "note": {"type": "basic", "prompt": "P1", "answer": "A1", "extra": ""}, + "card": {"id": "card-1", "variant": 0, "revision": 1}, + "rating_options": [], "action": "graded", "card_id": "card-1", + "rating": "good", "client_review_id": "review-1" + }, + { + "note": {"type": "basic", "prompt": "P2", "answer": "A2", "extra": ""}, + "card": {"id": "card-2", "variant": 0, "revision": 1}, + "rating_options": [], "action": "skipped", "card_id": "card-2" + } + ] + screen.history_cursor = screen.session_history.size() + screen.current_note = {"type": "basic", "prompt": "P3(live)", "answer": "A3", "extra": ""} + screen.current_card = {"id": "card-3", "variant": 0, "revision": 1} + screen.rating_options = [] + screen.due_remaining = 5 + screen.new_remaining = 3 + screen.revealed = false + + screen._go_previous() + _assert(screen.front_label.text == "P2", "the first Previous press should show the most recently added history entry") + _assert(not screen.grade_row.visible, "grading buttons should stay hidden while browsing history") + _assert(not screen.undo_button.visible, "a skipped history entry should never offer 'undo this rating'") + + screen._go_previous() + _assert(screen.front_label.text == "P1", "a second Previous press should step further back in history") + _assert(screen.undo_button.visible, "the specific most-recently-graded entry should surface 'undo this rating'") + + screen._go_previous() + _assert(screen.history_cursor == 0, "Previous should refuse to step before the start of history") + + screen._go_next() + _assert(screen.front_label.text == "P2", "Next while browsing history should step forward, not skip the live card") + + screen._go_next() + _assert(screen.front_label.text == "P3(live)" and screen.history_cursor == screen.session_history.size(), + "stepping past the last history entry should return to the live card") + _assert(screen.reveal_button.visible and not screen.grade_row.visible, + "returning to a live, unrevealed card should restore the reveal prompt") + + screen._go_next() + _assert(screen.session_history.size() == 3 and screen.session_history[2]["action"] == "skipped" and screen.session_history[2]["card_id"] == "card-3", + "Next while already at the live card should skip it (session-only, dual-meaning behavior)") + _assert(screen.seen_card_ids.has("card-3"), "skipping via Next should still exclude the card this session") + + # ---------- Keyboard shortcuts are ignored while browsing history ---------- + screen.session_history = [{ + "note": {"type": "basic", "prompt": "P1", "answer": "A1", "extra": ""}, + "card": {"id": "card-1", "variant": 0, "revision": 1}, + "rating_options": [], "action": "graded", "card_id": "card-1" + }] + screen.history_cursor = 0 + screen.current_note = {"type": "basic", "prompt": "P1", "answer": "A1", "extra": ""} + screen.current_card = {"id": "card-1", "variant": 0, "revision": 1} + screen.revealed = true + var key_event := InputEventKey.new() + key_event.keycode = KEY_1 + key_event.pressed = true + screen._input(key_event) + _assert(screen.session_history.size() == 1, + "a grading keystroke while browsing history should be ignored instead of grading the historical card") + + screen.free() + host.queue_free() + await process_frame + quit(1 if failures > 0 else 0) + + +func _assert(condition: bool, message: String) -> void: + if condition: + return + failures += 1 + push_error(message) diff --git a/tests/flashcard_study_render_test.gd.uid b/tests/flashcard_study_render_test.gd.uid new file mode 100644 index 0000000..2db7158 --- /dev/null +++ b/tests/flashcard_study_render_test.gd.uid @@ -0,0 +1 @@ +uid://b4g4ry3jt2gxn diff --git a/tests/flashcard_theme_responsive_test.gd b/tests/flashcard_theme_responsive_test.gd index 2d9f130..25d0441 100644 --- a/tests/flashcard_theme_responsive_test.gd +++ b/tests/flashcard_theme_responsive_test.gd @@ -51,9 +51,9 @@ func _run() -> void: var light_icon_box := (light_row.get_child(0) as PanelContainer).get_theme_stylebox("panel") as StyleBoxFlat _assert(light_icon_box.bg_color.is_equal_approx(Brand.icon_circle_box(false, Brand.ROYAL_BLUE, 20).bg_color), "a basic card's type icon should use its light accent styling") - var light_menu_box := hub.export_menu.get_theme_stylebox("panel") as StyleBoxFlat + var light_menu_box := hub.import_menu.get_theme_stylebox("panel") as StyleBoxFlat _assert(light_menu_box.bg_color.is_equal_approx(Brand.panel_box(false).bg_color), - "the export menu panel should also restyle to the light surface after a theme toggle") + "the import menu panel should also restyle to the light surface after a theme toggle") theme_service.set_dark_mode(prior_dark) diff --git a/tests/flashcard_theme_responsive_test.gd.uid b/tests/flashcard_theme_responsive_test.gd.uid new file mode 100644 index 0000000..ce9b26a --- /dev/null +++ b/tests/flashcard_theme_responsive_test.gd.uid @@ -0,0 +1 @@ +uid://y8dt7drjo221