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:
hz
2026-07-21 23:20:14 -05:00
parent 72fb6e4776
commit 70aa425fde
10 changed files with 600 additions and 23 deletions
@@ -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));