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.
This commit is contained in:
@@ -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<DateTime> { Today.AddHours(9) };
|
||||
|
||||
Assert.Equal(1, FlashcardScheduling.ComputeStreakDays(timestamps, Today));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeStreakDays_ThreeConsecutiveDaysEndingToday_IsThree()
|
||||
{
|
||||
var timestamps = new List<DateTime>
|
||||
{
|
||||
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<DateTime> { 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<DateTime>
|
||||
{
|
||||
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<DateTime> { Today.AddDays(-3).AddHours(8) };
|
||||
|
||||
Assert.Equal(0, FlashcardScheduling.ComputeStreakDays(timestamps, Today));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeStreakDays_MultipleReviewsSameDay_CountOnceTowardTheStreak()
|
||||
{
|
||||
var timestamps = new List<DateTime> { Today.AddHours(7), Today.AddHours(8), Today.AddHours(9) };
|
||||
|
||||
Assert.Equal(1, FlashcardScheduling.ComputeStreakDays(timestamps, Today));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -138,6 +138,38 @@ public sealed class FlashcardScheduleStore
|
||||
public Task<long> CountReviewsSinceAsync(string userId, DateTime sinceUtc) =>
|
||||
_reviewLogs.CountDocumentsAsync(v => v.UserId == userId && v.ReviewedUtc >= sinceUtc && v.UndoneUtc == null);
|
||||
|
||||
public Task<long> CountByStateAsync(string userId, IReadOnlyCollection<string> 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<long> CountMasteredAsync(string userId, IReadOnlyCollection<string> 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<List<DateTime>> 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<int> 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));
|
||||
|
||||
@@ -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<DateTime> 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<List<FlashcardDeckSummary>> 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<List<string>> ResolveNoteIdsAsync(string userId, string deckId)
|
||||
{
|
||||
var activeNotes = string.IsNullOrWhiteSpace(deckId)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
+335
-18
@@ -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:
|
||||
|
||||
+10
-3
@@ -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":
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user