From 09b670d5d4b7ab6bb2573c94d32a65daa8fc7ede Mon Sep 17 00:00:00 2001 From: hz Date: Wed, 22 Jul 2026 00:43:52 -0500 Subject: [PATCH] Close the gap between the flashcards study screen and the mockup Add real session-history navigation (Previous/Next/Shuffle), compact side panels, and a segmented progress row to match the mockup layout. Flashcards now opens straight into Study, with library management moved into the Options menu ("Manage Flashcards") and Export hidden from the UI (Import stays). Shuffle is backed by a real, due-before-new randomized pick on the backend, not a decorative toggle. --- .../FlashcardSchedulingTests.cs | 72 +++++ api/QuestionsApi/Models/FlashcardModels.cs | 1 + .../Flashcards/FlashcardScheduling.cs | 19 ++ .../Flashcards/FlashcardStudyService.cs | 20 +- scripts/autoload/flashcard_service.gd | 5 +- scripts/autoload/flashcard_service.gd.uid | 1 + scripts/ui/circular_progress.gd.uid | 1 + scripts/ui/flashcard_cloze.gd.uid | 1 + scripts/ui/flashcard_prefill_dialog.gd.uid | 1 + scripts/ui/flashcards_hub.gd | 25 -- scripts/ui/flashcards_hub.gd.uid | 1 + scripts/ui/flashcards_study.gd | 293 +++++++++++++++--- scripts/ui/flashcards_study.gd.uid | 1 + scripts/ui/main.gd | 2 +- tests/flashcard_cloze_test.gd.uid | 1 + tests/flashcard_navigation_test.gd.uid | 1 + tests/flashcard_outbox_test.gd.uid | 1 + tests/flashcard_session_navigation_test.gd | 173 +++++++++++ tests/flashcard_study_render_test.gd.uid | 1 + tests/flashcard_theme_responsive_test.gd | 4 +- tests/flashcard_theme_responsive_test.gd.uid | 1 + 21 files changed, 555 insertions(+), 70 deletions(-) create mode 100644 scripts/autoload/flashcard_service.gd.uid create mode 100644 scripts/ui/circular_progress.gd.uid create mode 100644 scripts/ui/flashcard_cloze.gd.uid create mode 100644 scripts/ui/flashcard_prefill_dialog.gd.uid create mode 100644 scripts/ui/flashcards_hub.gd.uid create mode 100644 scripts/ui/flashcards_study.gd.uid create mode 100644 tests/flashcard_cloze_test.gd.uid create mode 100644 tests/flashcard_navigation_test.gd.uid create mode 100644 tests/flashcard_outbox_test.gd.uid create mode 100644 tests/flashcard_session_navigation_test.gd create mode 100644 tests/flashcard_study_render_test.gd.uid create mode 100644 tests/flashcard_theme_responsive_test.gd.uid 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