From 70aa425fde6644385ca5ec6d800e3a4fcdc28a4d Mon Sep 17 00:00:00 2001 From: hz Date: Tue, 21 Jul 2026 23:20:14 -0500 Subject: [PATCH] Rebuild flashcard study screen from mockup, with real backend-backed stats Study screen now matches the provided mockup's layout: a "Current Deck" panel with a circular mastery-progress ring, and a "Spaced Repetition" panel (New/Due/Learning/Review counts, streak, daily goal) on either side of the card. Kept the app's real global sidebar rather than the mockup's own nav, per direction. Mastery/Streak/Daily Goal weren't tracked anywhere, so this adds real backend support rather than showing fabricated numbers: - Mastered = graduated to review state AND FSRS stability >= 21 days (FlashcardScheduleStore.MasteryStabilityDaysThreshold), stricter than the plain Review-state count - CurrentStreakDays = consecutive UTC days with a review, computed from review-log timestamps in the new pure FlashcardScheduling .ComputeStreakDays (unit tested, habit-streak semantics: doesn't reset to 0 the instant a new day starts before today's first review) - DailyGoal/ReviewsToday reuse the account's existing ReviewsPerDay setting rather than inventing a new one Also removes the exit "X" button in favor of the same Escape/pause menu the question-player flow uses (main.gd's shared app_menu, with an "Exit Study Session" action), and adds a new circular_progress.gd reusable ring control plus a real "Options" menu (bury/suspend the current card) wired to schedule endpoints that had no UI before. --- .../FlashcardSchedulingTests.cs | 64 ++++ api/QuestionsApi/Models/FlashcardModels.cs | 8 + .../Flashcards/FlashcardScheduleStore.cs | 32 ++ .../Flashcards/FlashcardScheduling.cs | 21 ++ .../Flashcards/FlashcardStudyService.cs | 28 +- docs/FLASHCARDS.md | 22 ++ scripts/ui/circular_progress.gd | 50 +++ scripts/ui/flashcards_study.gd | 353 +++++++++++++++++- scripts/ui/main.gd | 13 +- tests/flashcard_theme_responsive_test.gd | 32 ++ 10 files changed, 600 insertions(+), 23 deletions(-) create mode 100644 scripts/ui/circular_progress.gd diff --git a/api/QuestionsApi.Tests/FlashcardSchedulingTests.cs b/api/QuestionsApi.Tests/FlashcardSchedulingTests.cs index bae9ab8..a139938 100644 --- a/api/QuestionsApi.Tests/FlashcardSchedulingTests.cs +++ b/api/QuestionsApi.Tests/FlashcardSchedulingTests.cs @@ -38,4 +38,68 @@ public sealed class FlashcardSchedulingTests { Assert.Equal(expected, FlashcardScheduling.ResolveDailyBudget(globalLimit, deckOverride, consumedToday)); } + + private static readonly DateTime Today = new(2026, 3, 10, 0, 0, 0, DateTimeKind.Utc); + + [Fact] + public void ComputeStreakDays_NoReviews_IsZero() + { + Assert.Equal(0, FlashcardScheduling.ComputeStreakDays([], Today)); + } + + [Fact] + public void ComputeStreakDays_ReviewedTodayOnly_IsOne() + { + var timestamps = new List { Today.AddHours(9) }; + + Assert.Equal(1, FlashcardScheduling.ComputeStreakDays(timestamps, Today)); + } + + [Fact] + public void ComputeStreakDays_ThreeConsecutiveDaysEndingToday_IsThree() + { + var timestamps = new List + { + Today.AddDays(-2).AddHours(8), Today.AddDays(-1).AddHours(20), Today.AddHours(7) + }; + + Assert.Equal(3, FlashcardScheduling.ComputeStreakDays(timestamps, Today)); + } + + [Fact] + public void ComputeStreakDays_NotYetReviewedToday_StillCountsThroughYesterday() + { + // The streak shouldn't visibly reset to 0 the instant a new calendar day begins, before + // the user has had a chance to study today. + var timestamps = new List { Today.AddDays(-2).AddHours(8), Today.AddDays(-1).AddHours(20) }; + + Assert.Equal(2, FlashcardScheduling.ComputeStreakDays(timestamps, Today)); + } + + [Fact] + public void ComputeStreakDays_GapBeforeYesterday_BreaksTheStreak() + { + var timestamps = new List + { + Today.AddDays(-10).AddHours(8), Today.AddDays(-1).AddHours(20), Today.AddHours(7) + }; + + Assert.Equal(2, FlashcardScheduling.ComputeStreakDays(timestamps, Today)); + } + + [Fact] + public void ComputeStreakDays_MissedYesterdayAndToday_IsZero() + { + var timestamps = new List { Today.AddDays(-3).AddHours(8) }; + + Assert.Equal(0, FlashcardScheduling.ComputeStreakDays(timestamps, Today)); + } + + [Fact] + public void ComputeStreakDays_MultipleReviewsSameDay_CountOnceTowardTheStreak() + { + var timestamps = new List { Today.AddHours(7), Today.AddHours(8), Today.AddHours(9) }; + + Assert.Equal(1, FlashcardScheduling.ComputeStreakDays(timestamps, Today)); + } } diff --git a/api/QuestionsApi/Models/FlashcardModels.cs b/api/QuestionsApi/Models/FlashcardModels.cs index 8d90909..e4fdbba 100644 --- a/api/QuestionsApi/Models/FlashcardModels.cs +++ b/api/QuestionsApi/Models/FlashcardModels.cs @@ -175,6 +175,9 @@ public sealed class FlashcardDeckSummary public int Due { get; set; } public int New { get; set; } public int Suspended { get; set; } + public int Learning { get; set; } + public int Review { get; set; } + public int Mastered { get; set; } } public sealed class FlashcardLibraryResponse @@ -266,6 +269,11 @@ public sealed class FlashcardDashboardCounts public int Due { get; set; } public int NewAvailable { get; set; } public int ReviewedThisWeek { get; set; } + public int Learning { get; set; } + public int Review { get; set; } + public int CurrentStreakDays { get; set; } + public int ReviewsToday { get; set; } + public int DailyGoal { get; set; } } public static class FlashcardImportFormats diff --git a/api/QuestionsApi/Services/Flashcards/FlashcardScheduleStore.cs b/api/QuestionsApi/Services/Flashcards/FlashcardScheduleStore.cs index 012f070..038e881 100644 --- a/api/QuestionsApi/Services/Flashcards/FlashcardScheduleStore.cs +++ b/api/QuestionsApi/Services/Flashcards/FlashcardScheduleStore.cs @@ -138,6 +138,38 @@ public sealed class FlashcardScheduleStore public Task CountReviewsSinceAsync(string userId, DateTime sinceUtc) => _reviewLogs.CountDocumentsAsync(v => v.UserId == userId && v.ReviewedUtc >= sinceUtc && v.UndoneUtc == null); + public Task CountByStateAsync(string userId, IReadOnlyCollection noteIds, string state) => + noteIds.Count == 0 + ? Task.FromResult(0L) + : _schedules.CountDocumentsAsync(v => v.UserId == userId + && noteIds.Contains(v.NoteId) + && v.State == state + && !v.IsSuspended); + + // A card counts toward "mastered" once it has graduated to long-term review and reached a + // stability (FSRS's estimate of days until retention drops to ~90%) past this threshold - + // the same rough "won't need attention again soon" cutoff several SRS apps use for a mastery + // stat. Kept separate from the plain Review-state count, which includes recently-graduated + // cards still building stability. + public const double MasteryStabilityDaysThreshold = 21.0; + + public Task CountMasteredAsync(string userId, IReadOnlyCollection noteIds) => + noteIds.Count == 0 + ? Task.FromResult(0L) + : _schedules.CountDocumentsAsync(v => v.UserId == userId + && noteIds.Contains(v.NoteId) + && v.State == FlashcardScheduleStates.Review + && !v.IsSuspended + && v.Stability >= MasteryStabilityDaysThreshold); + + // Bounded lookback (streaks longer than this are vanishingly rare and this keeps the query + // cheap); returns raw timestamps so the caller can compute consecutive-day streaks in the + // user's own calendar-day semantics. + public Task> GetReviewTimestampsSinceAsync(string userId, DateTime sinceUtc) => + _reviewLogs.Find(v => v.UserId == userId && v.ReviewedUtc >= sinceUtc && v.UndoneUtc == null) + .Project(v => v.ReviewedUtc) + .ToListAsync(); + public async Task CountNewIntroducedSinceAsync(string userId, DateTime sinceUtc) => checked((int)await _reviewLogs.CountDocumentsAsync(v => v.UserId == userId && v.ReviewedUtc >= sinceUtc && v.UndoneUtc == null && v.Before.State == FlashcardScheduleStates.New)); diff --git a/api/QuestionsApi/Services/Flashcards/FlashcardScheduling.cs b/api/QuestionsApi/Services/Flashcards/FlashcardScheduling.cs index c471998..f206c70 100644 --- a/api/QuestionsApi/Services/Flashcards/FlashcardScheduling.cs +++ b/api/QuestionsApi/Services/Flashcards/FlashcardScheduling.cs @@ -11,4 +11,25 @@ public static class FlashcardScheduling // already been consumed on the client's current local study day. public static int ResolveDailyBudget(int globalLimit, int? deckOverride, int consumedToday) => Math.Max(0, (deckOverride ?? globalLimit) - consumedToday); + + // A streak counts consecutive calendar days with at least one review, ending either today or + // yesterday (so the streak doesn't visibly reset to 0 the moment a new day starts before + // today's first review) - the same semantics as most habit-streak trackers. + public static int ComputeStreakDays(IReadOnlyCollection reviewTimestamps, DateTime todayUtc) + { + var reviewDays = reviewTimestamps.Select(v => v.Date).ToHashSet(); + var cursor = todayUtc.Date; + if (!reviewDays.Contains(cursor)) + { + cursor = cursor.AddDays(-1); + if (!reviewDays.Contains(cursor)) return 0; + } + var streak = 0; + while (reviewDays.Contains(cursor)) + { + streak++; + cursor = cursor.AddDays(-1); + } + return streak; + } } diff --git a/api/QuestionsApi/Services/Flashcards/FlashcardStudyService.cs b/api/QuestionsApi/Services/Flashcards/FlashcardStudyService.cs index 089839c..b590b4f 100644 --- a/api/QuestionsApi/Services/Flashcards/FlashcardStudyService.cs +++ b/api/QuestionsApi/Services/Flashcards/FlashcardStudyService.cs @@ -58,7 +58,23 @@ public sealed class FlashcardStudyService( var due = checked((int)await schedules.CountDueAsync(userId, noteIds, nowUtc, studyDayStart)); var newAvailable = Math.Min(checked((int)await schedules.CountNewAsync(userId, noteIds, studyDayStart)), newBudget); var reviewedThisWeek = checked((int)await schedules.CountReviewsSinceAsync(userId, weekStartUtc)); - return new FlashcardDashboardCounts { Due = due, NewAvailable = newAvailable, ReviewedThisWeek = reviewedThisWeek }; + var learning = checked((int)await schedules.CountByStateAsync(userId, noteIds, FlashcardScheduleStates.Learning)) + + checked((int)await schedules.CountByStateAsync(userId, noteIds, FlashcardScheduleStates.Relearning)); + var review = checked((int)await schedules.CountByStateAsync(userId, noteIds, FlashcardScheduleStates.Review)); + var reviewsToday = checked((int)await schedules.CountReviewsSinceAsync(userId, studyDayStart)); + var streakTimestamps = await schedules.GetReviewTimestampsSinceAsync(userId, nowUtc.AddDays(-400)); + var settings = await settingsStore.GetAsync(userId); + return new FlashcardDashboardCounts + { + Due = due, + NewAvailable = newAvailable, + ReviewedThisWeek = reviewedThisWeek, + Learning = learning, + Review = review, + CurrentStreakDays = FlashcardScheduling.ComputeStreakDays(streakTimestamps, nowUtc.Date), + ReviewsToday = reviewsToday, + DailyGoal = settings.ReviewsPerDay + }; } public async Task> GetDeckSummariesAsync(string userId, DateTime nowUtc, DateTime studyDayStart) @@ -70,17 +86,25 @@ public sealed class FlashcardStudyService( var deckNoteIds = (await notes.ListByDeckAsync(userId, deck.Id!)).Select(v => v.Id!).ToList(); var due = checked((int)await schedules.CountDueAsync(userId, deckNoteIds, nowUtc, studyDayStart)); var fresh = checked((int)await schedules.CountNewAsync(userId, deckNoteIds, studyDayStart)); + var learning = checked((int)await schedules.CountByStateAsync(userId, deckNoteIds, FlashcardScheduleStates.Learning)) + + checked((int)await schedules.CountByStateAsync(userId, deckNoteIds, FlashcardScheduleStates.Relearning)); + var review = checked((int)await schedules.CountByStateAsync(userId, deckNoteIds, FlashcardScheduleStates.Review)); + var mastered = checked((int)await schedules.CountMasteredAsync(userId, deckNoteIds)); result.Add(new FlashcardDeckSummary { Deck = deck, Total = deckNoteIds.Count, Due = due, - New = fresh + New = fresh, + Learning = learning, + Review = review, + Mastered = mastered }); } return result; } + private async Task> ResolveNoteIdsAsync(string userId, string deckId) { var activeNotes = string.IsNullOrWhiteSpace(deckId) diff --git a/docs/FLASHCARDS.md b/docs/FLASHCARDS.md index 7eef28a..466a0b4 100644 --- a/docs/FLASHCARDS.md +++ b/docs/FLASHCARDS.md @@ -63,6 +63,28 @@ pattern as the rest of the API. Review submission is idempotent on `ClientReview allows reverting the single most recent review on a card (it refuses if the card's revision has moved since). +## Progress metrics + +`GET /decks` (`FlashcardDeckSummary`) and `GET /counts` (`FlashcardDashboardCounts`) expose a few +derived stats the client can't compute itself: + +- **Mastered** (per-deck, in `FlashcardDeckSummary.Mastered`) - a card counts once it's graduated + to `review` state *and* its FSRS stability has reached + `FlashcardScheduleStore.MasteryStabilityDaysThreshold` (21 days), the same rough "won't need + attention again soon" cutoff several SRS apps use. Deliberately stricter than the plain + `Review`-state count, which includes cards that just graduated and haven't proven stable yet. +- **CurrentStreakDays** (global, in `FlashcardDashboardCounts`) - consecutive UTC calendar days + with at least one non-undone review, computed in `FlashcardScheduling.ComputeStreakDays` from a + 400-day lookback of review timestamps. The streak counts through today even before today's + first review (it only breaks once *yesterday* has no review either), matching typical + habit-streak semantics rather than resetting to 0 at midnight. +- **DailyGoal** / **ReviewsToday** (global) - `DailyGoal` is simply the account's configured + `FlashcardSettings.ReviewsPerDay`; `ReviewsToday` is reviews logged since the client's + `studyDayStartUtc` for `/study/next` calls, but since `/counts` has no per-request client-day + concept it uses the server's own UTC calendar day instead - a day-boundary edge case is possible + right around midnight, unlike the study-session endpoints which always trust the client's local + day. + ## Import/export CSV is UTF-8, text-only, headers `stable_id,deck,type,prompt,answer,extra,tags, diff --git a/scripts/ui/circular_progress.gd b/scripts/ui/circular_progress.gd new file mode 100644 index 0000000..2eeb700 --- /dev/null +++ b/scripts/ui/circular_progress.gd @@ -0,0 +1,50 @@ +extends Control + +const Brand := preload("res://scripts/ui/medmind_style.gd") + +var ring_value := 0.0 +var track_color := Color("26324a") +var fill_color := Color("2563eb") +var ring_width := 8.0 +var value_label: Label +var caption_label: Label + + +func _init() -> void: + custom_minimum_size = Vector2(112, 112) + var center := CenterContainer.new() + center.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + center.mouse_filter = Control.MOUSE_FILTER_IGNORE + add_child(center) + var stack := VBoxContainer.new() + stack.add_theme_constant_override("separation", 0) + center.add_child(stack) + value_label = Label.new() + value_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + value_label.add_theme_font_size_override("font_size", 24) + stack.add_child(value_label) + caption_label = Label.new() + caption_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + caption_label.add_theme_font_size_override("font_size", 12) + stack.add_child(caption_label) + + +func configure(fill_fraction: float, value_text: String, caption_text: String, accent: Color, dark: bool) -> void: + ring_value = clampf(fill_fraction, 0.0, 1.0) + track_color = Color("26324a") if dark else Color("e2e8f0") + fill_color = accent + value_label.text = value_text + value_label.add_theme_color_override("font_color", Brand.text_color(dark)) + caption_label.text = caption_text + caption_label.add_theme_color_override("font_color", Brand.muted_color(dark)) + queue_redraw() + + +func _draw() -> void: + var radius := minf(size.x, size.y) / 2.0 - ring_width / 2.0 + if radius <= 0.0: + return + var center := size / 2.0 + draw_arc(center, radius, 0.0, TAU, 64, track_color, ring_width, true) + if ring_value > 0.0: + draw_arc(center, radius, -PI / 2.0, -PI / 2.0 + TAU * ring_value, 64, fill_color, ring_width, true) diff --git a/scripts/ui/flashcards_study.gd b/scripts/ui/flashcards_study.gd index 0fc74dc..15627ba 100644 --- a/scripts/ui/flashcards_study.gd +++ b/scripts/ui/flashcards_study.gd @@ -3,6 +3,9 @@ extends Control const Brand := preload("res://scripts/ui/medmind_style.gd") const Style := preload("res://scripts/ui/ui_style.gd") 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." var main @@ -22,8 +25,8 @@ var due_remaining := 0 var new_remaining := 0 var progress_label: Label -var exit_button: Button var paused_banner: Label +var main_row: HBoxContainer var card_panel: PanelContainer var front_label: Label var divider: ColorRect @@ -35,9 +38,30 @@ var reveal_button: Button var grade_row: HBoxContainer var grade_buttons: Dictionary = {} var undo_button: Button +var options_button: Button +var options_menu: PanelContainer +var options_backdrop: Control var empty_label: Label var compact_layout := false +var deck_panel: PanelContainer +var deck_name_label: Label +var deck_ring: Control +var deck_mastered_label: Label +var change_deck_button: Button + +var stats_panel: PanelContainer +var stats_new_ring: Control +var stats_due_label: Label +var stats_learning_label: Label +var stats_review_label: Label +var streak_label: Label +var daily_goal_label: Label +var daily_goal_bar: ProgressBar + +var decks: Array = [] +var latest_counts: Dictionary = {} + func _ready() -> void: set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) @@ -50,6 +74,8 @@ func _ready() -> void: FlashcardService.review_synced.connect(_on_review_synced) FlashcardService.review_paused.connect(_on_review_paused) FlashcardService.review_resumed.connect(_on_review_resumed) + FlashcardService.decks_changed.connect(_on_decks_changed) + FlashcardService.counts_changed.connect(_on_counts_changed) UIThemeService.theme_changed.connect(func(_dark: bool): refresh_theme_controls()) @@ -63,8 +89,11 @@ func on_show(payload: Variant = null) -> void: last_client_review_id = "" last_card_id = "" undo_button.visible = false + _close_options_menu() _render_loading() _load_next() + FlashcardService.fetch_decks() + FlashcardService.fetch_counts() func on_hide() -> void: @@ -79,20 +108,11 @@ func _build() -> void: var top_row := HBoxContainer.new() root.add_child(top_row) - exit_button = Button.new() - exit_button.icon = preload("res://assets/icons/lucide/x.svg") - exit_button.tooltip_text = "Exit study session" - exit_button.custom_minimum_size = Vector2(44, 44) - exit_button.pressed.connect(func(): main.show_screen("flashcards_hub")) - top_row.add_child(exit_button) progress_label = Label.new() progress_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL progress_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER progress_label.add_theme_font_size_override("font_size", 15) top_row.add_child(progress_label) - var spacer := Control.new() - spacer.custom_minimum_size = Vector2(44, 44) - top_row.add_child(spacer) paused_banner = Label.new() paused_banner.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER @@ -100,10 +120,24 @@ func _build() -> void: paused_banner.visible = false root.add_child(paused_banner) + main_row = HBoxContainer.new() + main_row.size_flags_vertical = Control.SIZE_EXPAND_FILL + main_row.size_flags_horizontal = Control.SIZE_EXPAND_FILL + main_row.add_theme_constant_override("separation", 20) + root.add_child(main_row) + + _build_deck_panel() + + var center_column := VBoxContainer.new() + center_column.size_flags_horizontal = Control.SIZE_EXPAND_FILL + center_column.size_flags_vertical = Control.SIZE_EXPAND_FILL + center_column.add_theme_constant_override("separation", 16) + main_row.add_child(center_column) + var center := CenterContainer.new() center.size_flags_vertical = Control.SIZE_EXPAND_FILL center.size_flags_horizontal = Control.SIZE_EXPAND_FILL - root.add_child(center) + center_column.add_child(center) card_panel = PanelContainer.new() card_panel.custom_minimum_size = Vector2(720, 420) @@ -159,12 +193,12 @@ func _build() -> void: reveal_button.custom_minimum_size = Vector2(0, 52) _style_gradient_button(reveal_button) reveal_button.pressed.connect(_reveal) - root.add_child(reveal_button) + center_column.add_child(reveal_button) grade_row = HBoxContainer.new() grade_row.add_theme_constant_override("separation", 10) grade_row.visible = false - root.add_child(grade_row) + center_column.add_child(grade_row) for spec in [["again", "Again", "1", Style.RED], ["hard", "Hard", "2", Style.AMBER], ["good", "Good", "3", Style.CYAN], ["easy", "Easy", "4", Style.LIME]]: var button := Button.new() button.custom_minimum_size = Vector2(0, 64) @@ -174,12 +208,21 @@ 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.visible = false undo_button.pressed.connect(_undo) - root.add_child(undo_button) + 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) + _build_options_menu(footer_row) + + _build_stats_panel() refresh_theme_controls() @@ -211,6 +254,211 @@ func _style_ghost_button(button: Button, dark: bool) -> void: button.add_theme_color_override("font_hover_color", Brand.text_color(dark)) +func _side_panel_box(dark: bool) -> StyleBoxFlat: + var box := StyleBoxFlat.new() + box.bg_color = Color("081426") if dark else Color.WHITE + box.border_color = Color("26344f") if dark else Brand.LIGHT_BORDER + box.set_border_width_all(1) + box.set_corner_radius_all(10) + box.content_margin_left = 18 + box.content_margin_right = 18 + box.content_margin_top = 18 + box.content_margin_bottom = 18 + return box + + +func _panel_caption(text: String) -> Label: + var label := Label.new() + label.text = text + label.add_theme_font_size_override("font_size", 12) + return label + + +# ---------- Left panel: current deck ---------- + +func _build_deck_panel() -> void: + deck_panel = PanelContainer.new() + deck_panel.custom_minimum_size = Vector2(220, 0) + main_row.add_child(deck_panel) + var content := VBoxContainer.new() + content.add_theme_constant_override("separation", 10) + deck_panel.add_child(content) + + content.add_child(_panel_caption("CURRENT DECK")) + deck_name_label = Label.new() + deck_name_label.text = "All decks" + deck_name_label.add_theme_font_size_override("font_size", 18) + deck_name_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + content.add_child(deck_name_label) + + var ring_center := CenterContainer.new() + content.add_child(ring_center) + deck_ring = CircularProgress.new() + deck_ring.tooltip_text = MASTERY_STABILITY_LABEL + ring_center.add_child(deck_ring) + + deck_mastered_label = Label.new() + deck_mastered_label.text = "-- cards mastered" + deck_mastered_label.add_theme_font_size_override("font_size", 12) + deck_mastered_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + content.add_child(deck_mastered_label) + + change_deck_button = Button.new() + change_deck_button.text = "Change Deck" + change_deck_button.custom_minimum_size = Vector2(0, 44) + change_deck_button.pressed.connect(func(): main.show_screen("flashcards_hub")) + content.add_child(change_deck_button) + + +# ---------- Right panel: spaced repetition stats ---------- + +func _build_stats_panel() -> void: + stats_panel = PanelContainer.new() + stats_panel.custom_minimum_size = Vector2(220, 0) + main_row.add_child(stats_panel) + var content := VBoxContainer.new() + content.add_theme_constant_override("separation", 10) + stats_panel.add_child(content) + + content.add_child(_panel_caption("SPACED REPETITION")) + + var ring_center := CenterContainer.new() + content.add_child(ring_center) + stats_new_ring = CircularProgress.new() + ring_center.add_child(stats_new_ring) + + stats_due_label = _stat_row(content, "Due") + stats_learning_label = _stat_row(content, "Learning") + stats_review_label = _stat_row(content, "Review") + + var streak_row := HBoxContainer.new() + streak_row.add_theme_constant_override("separation", 10) + content.add_child(streak_row) + var streak_caption := VBoxContainer.new() + streak_caption.add_theme_constant_override("separation", 0) + streak_row.add_child(streak_caption) + streak_caption.add_child(_panel_caption("STREAK")) + streak_label = Label.new() + streak_label.text = "-- days" + streak_label.add_theme_font_size_override("font_size", 16) + streak_caption.add_child(streak_label) + + var goal_block := VBoxContainer.new() + goal_block.add_theme_constant_override("separation", 4) + content.add_child(goal_block) + goal_block.add_child(_panel_caption("DAILY GOAL")) + daily_goal_label = Label.new() + daily_goal_label.text = "-- / --" + daily_goal_label.add_theme_font_size_override("font_size", 16) + goal_block.add_child(daily_goal_label) + daily_goal_bar = ProgressBar.new() + daily_goal_bar.custom_minimum_size = Vector2(0, 8) + daily_goal_bar.show_percentage = false + daily_goal_bar.min_value = 0 + daily_goal_bar.max_value = 1 + goal_block.add_child(daily_goal_bar) + + +func _stat_row(parent: VBoxContainer, caption: String) -> Label: + var row := HBoxContainer.new() + parent.add_child(row) + var caption_label := Label.new() + caption_label.text = caption + caption_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL + row.add_child(caption_label) + var value := Label.new() + value.text = "--" + value.add_theme_font_size_override("font_size", 15) + row.add_child(value) + return value + + +# ---------- Options menu (suspend / bury current card) ---------- + +func _build_options_menu(footer_row: HBoxContainer) -> void: + options_backdrop = Control.new() + options_backdrop.visible = false + options_backdrop.top_level = true + options_backdrop.mouse_filter = Control.MOUSE_FILTER_STOP + options_backdrop.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + options_backdrop.gui_input.connect(func(event: InputEvent): + if event is InputEventMouseButton and event.pressed: + _close_options_menu()) + add_child(options_backdrop) + + options_button = Button.new() + options_button.text = "Options" + options_button.custom_minimum_size = Vector2(0, 36) + options_button.pressed.connect(_toggle_options_menu) + footer_row.add_child(options_button) + + options_menu = PanelContainer.new() + options_menu.visible = false + options_menu.top_level = true + add_child(options_menu) + 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) + + +func _toggle_options_menu() -> void: + if options_menu.visible: + _close_options_menu() + return + if current_card.is_empty(): + return + options_menu.visible = true + options_backdrop.visible = true + options_menu.position = options_button.get_global_position() - Vector2(0, options_menu.size.y + 4) + + +func _close_options_menu() -> void: + if options_menu != null: + options_menu.visible = false + if options_backdrop != null: + options_backdrop.visible = false + + +func _bury_current_card() -> void: + if current_card.is_empty(): + return + var card_id := str(current_card.get("id", "")) + FlashcardService.bury_cards([card_id], _bury_until_tomorrow_utc()) + if not seen_card_ids.has(card_id): + seen_card_ids.append(card_id) + _load_next() + _render_loading() + + +func _suspend_current_card() -> void: + if current_card.is_empty(): + return + var card_id := str(current_card.get("id", "")) + FlashcardService.suspend_cards([card_id], true) + if not seen_card_ids.has(card_id): + seen_card_ids.append(card_id) + _load_next() + _render_loading() + + +func _bury_until_tomorrow_utc() -> String: + var study_day_unix := Time.get_unix_time_from_datetime_string(study_day_start_utc.trim_suffix("Z")) + return "%sZ" % Time.get_datetime_string_from_unix_time(study_day_unix + 86400, false) + + func refresh_theme_controls() -> void: if card_panel == null: return @@ -222,15 +470,82 @@ func refresh_theme_controls() -> void: extra_caption.add_theme_color_override("font_color", Brand.muted_color(dark)) extra_label.add_theme_color_override("font_color", Brand.muted_color(dark)) _style_ghost_button(undo_button, dark) + 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)) + _refresh_deck_panel() + _refresh_stats_panel() func _update_responsive_layout() -> void: if card_panel == null: return - compact_layout = size.x < 720.0 - var available := maxf(size.x - 48.0, 240.0) + compact_layout = size.x < 1080.0 + deck_panel.visible = not compact_layout + stats_panel.visible = not compact_layout + var available := maxf((size.x - 48.0) if compact_layout else (size.x - 520.0), 240.0) card_panel.custom_minimum_size.x = minf(720.0, available) - card_panel.custom_minimum_size.y = 340.0 if compact_layout else 420.0 + card_panel.custom_minimum_size.y = 340.0 if size.x < 720.0 else 420.0 + + +# ---------- Deck / stats data ---------- + +func _on_decks_changed(loaded_decks: Array) -> void: + decks = loaded_decks + _refresh_deck_panel() + + +func _refresh_deck_panel() -> void: + if deck_ring == null: + return + var dark := UIThemeService.is_dark_mode() + var name := "All decks" + var total := 0 + var mastered := 0 + if deck_id.is_empty(): + for summary in decks: + total += int(summary.get("total", 0)) + mastered += int(summary.get("mastered", 0)) + else: + for summary in decks: + var deck: Dictionary = summary.get("deck", {}) + if str(deck.get("id", "")) == deck_id: + name = str(deck.get("name", "Deck")) + total = int(summary.get("total", 0)) + mastered = int(summary.get("mastered", 0)) + break + deck_name_label.text = name + var fraction := float(mastered) / float(total) if total > 0 else 0.0 + deck_ring.configure(fraction, "%d%%" % roundi(fraction * 100.0), "Mastery", Brand.ROYAL_BLUE, dark) + deck_mastered_label.text = "%d / %d cards mastered" % [mastered, total] + + +func _on_counts_changed(counts: Dictionary) -> void: + latest_counts = counts + _refresh_stats_panel() + + +func _refresh_stats_panel() -> void: + if stats_new_ring == null or latest_counts.is_empty(): + return + var dark := UIThemeService.is_dark_mode() + var due := int(latest_counts.get("due", 0)) + var new_available := int(latest_counts.get("newAvailable", 0)) + var learning := int(latest_counts.get("learning", 0)) + var review := int(latest_counts.get("review", 0)) + var streak := int(latest_counts.get("currentStreakDays", 0)) + var reviews_today := int(latest_counts.get("reviewsToday", 0)) + var daily_goal := int(latest_counts.get("dailyGoal", 0)) + + var new_fraction := float(new_available) / float(new_available + due) if (new_available + due) > 0 else 0.0 + stats_new_ring.configure(new_fraction, str(new_available), "New", Brand.ROYAL_BLUE, dark) + stats_due_label.text = str(due) + stats_learning_label.text = str(learning) + stats_review_label.text = str(review) + streak_label.text = "%d day%s" % [streak, "" if streak == 1 else "s"] + daily_goal_label.text = "%d / %d" % [reviews_today, daily_goal] + daily_goal_bar.max_value = maxf(1.0, float(daily_goal)) + daily_goal_bar.value = float(reviews_today) func _load_next() -> void: @@ -350,6 +665,7 @@ func _grade(rating: String) -> void: seen_card_ids.append(card_id) _load_next() _render_loading() + FlashcardService.fetch_counts() func _on_review_synced(_card_id: String, _updated_card: Dictionary) -> void: @@ -364,7 +680,8 @@ func _undo() -> void: last_client_review_id = "" undo_button.visible = false seen_card_ids.erase(last_card_id) - _load_next()) + _load_next() + FlashcardService.fetch_counts()) func _on_review_paused(message: String) -> void: diff --git a/scripts/ui/main.gd b/scripts/ui/main.gd index c68ee2e..f923c6d 100644 --- a/scripts/ui/main.gd +++ b/scripts/ui/main.gd @@ -717,7 +717,7 @@ func _input(event: InputEvent) -> void: _close_account_menu() get_viewport().set_input_as_handled() return - if current_screen_name in ["player", "ccs_player"]: + if current_screen_name in ["player", "ccs_player", "flashcards_study"]: _toggle_app_menu() get_viewport().set_input_as_handled() @@ -895,9 +895,13 @@ func _toggle_app_menu() -> void: app_menu.visible = true app_menu.size = app_menu.custom_minimum_size app_menu.position = (get_viewport_rect().size - app_menu.size) * 0.5 - menu_exit_button.visible = (current_screen_name == "player" and BlockSessionService.block_started) or current_screen_name == "ccs_player" + menu_exit_button.visible = (current_screen_name == "player" and BlockSessionService.block_started) or current_screen_name in ["ccs_player", "flashcards_study"] if menu_exit_button.visible: - menu_exit_button.text = "Leave Case" if current_screen_name == "ccs_player" else "Leave (timer continues)" if BlockSessionService.session_kind == "simulation" else "Exit Run" + menu_exit_button.text = ( + "Leave Case" if current_screen_name == "ccs_player" + else "Exit Study Session" if current_screen_name == "flashcards_study" + else "Leave (timer continues)" if BlockSessionService.session_kind == "simulation" + else "Exit Run") Style.style_chip(menu_exit_button, Style.AMBER) if current_screen_name == "player": BlockSessionService.pause_current_question_timer() @@ -914,6 +918,9 @@ func _exit_run() -> void: var ccs_player = screens.get("ccs_player") if ccs_player != null and ccs_player.has_method("leave_case"): ccs_player.leave_case() return + if current_screen_name == "flashcards_study": + show_screen("flashcards_hub") + return var was_core_run := BlockSessionService.session_kind == "core" var was_review_run := BlockSessionService.session_kind == "review" if BlockSessionService.block_started and BlockSessionService.session_kind != "simulation": diff --git a/tests/flashcard_theme_responsive_test.gd b/tests/flashcard_theme_responsive_test.gd index 297e59e..2d9f130 100644 --- a/tests/flashcard_theme_responsive_test.gd +++ b/tests/flashcard_theme_responsive_test.gd @@ -106,6 +106,38 @@ func _run() -> void: _assert(not study.compact_layout, "a wide viewport should restore the study screen's desktop layout") _assert(is_equal_approx(study.card_panel.custom_minimum_size.x, 720.0), "a wide viewport should restore the study card's full 720px width") + _assert(study.deck_panel.visible and study.stats_panel.visible, + "a wide viewport should show the current-deck and spaced-repetition side panels") + + study.size = Vector2(360, 720) + study._update_responsive_layout() + _assert(not study.deck_panel.visible and not study.stats_panel.visible, + "a phone-width viewport should hide the side panels rather than squeeze a 3-column layout into no room") + + study.deck_id = "deck-1" + study.decks = [ + {"deck": {"id": "deck-1", "name": "Nephrology"}, "total": 150, "mastered": 48}, + {"deck": {"id": "deck-2", "name": "Cardiology"}, "total": 40, "mastered": 40}, + ] + study._refresh_deck_panel() + _assert(study.deck_name_label.text == "Nephrology", "the deck panel should show the currently-studied deck's name, not just the first one") + _assert(study.deck_mastered_label.text == "48 / 150 cards mastered", "the deck panel should report mastered/total for the current deck only") + + study.deck_id = "" + study._refresh_deck_panel() + _assert(study.deck_name_label.text == "All decks", "studying with no deck filter should label the panel \"All decks\"") + _assert(study.deck_mastered_label.text == "88 / 190 cards mastered", "\"All decks\" should sum mastered/total across every deck") + + study.latest_counts = { + "due": 5, "newAvailable": 15, "learning": 48, "review": 132, + "currentStreakDays": 1, "reviewsToday": 25, "dailyGoal": 50 + } + study._refresh_stats_panel() + _assert(study.stats_due_label.text == "5" and study.stats_learning_label.text == "48" and study.stats_review_label.text == "132", + "the spaced-repetition panel should reflect the latest due/learning/review counts") + _assert(study.streak_label.text == "1 day", "a streak of exactly one day should use the singular form") + _assert(study.daily_goal_label.text == "25 / 50" and is_equal_approx(study.daily_goal_bar.value, 25.0), + "the daily goal panel should show today's review count against the account's configured goal") study.free() host.queue_free()