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)
|
||||
|
||||
Reference in New Issue
Block a user