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
@@ -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)