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.
137 lines
6.7 KiB
C#
137 lines
6.7 KiB
C#
using QuestionsApi.Models;
|
|
|
|
namespace QuestionsApi.Services.Flashcards;
|
|
|
|
// Composes decks/notes/schedules/settings to answer "what's next" and daily-count questions.
|
|
// Kept separate from the individual stores to avoid a circular dependency (notes need to
|
|
// sync schedule variants; schedules must not need to know about notes/decks).
|
|
public sealed class FlashcardStudyService(
|
|
FlashcardDeckStore decks,
|
|
FlashcardNoteStore notes,
|
|
FlashcardScheduleStore schedules,
|
|
FlashcardSettingsStore settingsStore)
|
|
{
|
|
public async Task<FlashcardStudyCardResponse> GetNextCardAsync(string userId, FlashcardStudyRequest request)
|
|
{
|
|
var now = DateTime.UtcNow;
|
|
var studyDayStart = request.StudyDayStartUtc.Date;
|
|
var noteIds = await ResolveNoteIdsAsync(userId, request.DeckId);
|
|
var (newBudget, reviewBudget) = await ResolveBudgetsAsync(userId, request.DeckId, studyDayStart);
|
|
var exclude = request.ExcludeCardIds.Where(v => !string.IsNullOrWhiteSpace(v)).ToHashSet(StringComparer.Ordinal);
|
|
|
|
// Overdue/due cards first (oldest due first), then new cards.
|
|
FlashcardSchedule? card = null;
|
|
if (reviewBudget > 0)
|
|
{
|
|
var due = await schedules.GetDueAsync(userId, noteIds, now, studyDayStart, reviewBudget + exclude.Count + 1);
|
|
card = due.FirstOrDefault(v => !exclude.Contains(v.Id!));
|
|
}
|
|
if (card is null && newBudget > 0)
|
|
{
|
|
var fresh = await schedules.GetNewAsync(userId, noteIds, studyDayStart, newBudget + exclude.Count + 1);
|
|
card = fresh.FirstOrDefault(v => !exclude.Contains(v.Id!));
|
|
}
|
|
|
|
var dueRemaining = checked((int)await schedules.CountDueAsync(userId, noteIds, now, studyDayStart));
|
|
var newRemaining = checked((int)await schedules.CountNewAsync(userId, noteIds, studyDayStart));
|
|
var response = new FlashcardStudyCardResponse
|
|
{
|
|
DueRemaining = Math.Min(dueRemaining, reviewBudget),
|
|
NewRemaining = Math.Min(newRemaining, newBudget)
|
|
};
|
|
if (card is null) return response;
|
|
|
|
var note = await notes.GetAsync(userId, card.NoteId);
|
|
if (note is null) return response;
|
|
|
|
response.Note = note;
|
|
response.Card = card;
|
|
response.RatingOptions = schedules.PreviewRatingOptions(card, now);
|
|
return response;
|
|
}
|
|
|
|
public async Task<FlashcardDashboardCounts> GetDashboardCountsAsync(string userId, DateTime nowUtc, DateTime weekStartUtc)
|
|
{
|
|
var studyDayStart = nowUtc.Date;
|
|
var noteIds = await ResolveNoteIdsAsync(userId, "");
|
|
var (newBudget, _) = await ResolveBudgetsAsync(userId, "", studyDayStart);
|
|
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));
|
|
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)
|
|
{
|
|
var deckList = await decks.ListAsync(userId);
|
|
var result = new List<FlashcardDeckSummary>();
|
|
foreach (var deck in deckList)
|
|
{
|
|
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,
|
|
Learning = learning,
|
|
Review = review,
|
|
Mastered = mastered
|
|
});
|
|
}
|
|
return result;
|
|
}
|
|
|
|
|
|
private async Task<List<string>> ResolveNoteIdsAsync(string userId, string deckId)
|
|
{
|
|
var activeNotes = string.IsNullOrWhiteSpace(deckId)
|
|
? await notes.ListAllActiveAsync(userId)
|
|
: await notes.ListByDeckAsync(userId, deckId);
|
|
return activeNotes.Select(v => v.Id!).ToList();
|
|
}
|
|
|
|
private async Task<(int NewBudget, int ReviewBudget)> ResolveBudgetsAsync(string userId, string deckId, DateTime studyDayStart)
|
|
{
|
|
var settings = await settingsStore.GetAsync(userId);
|
|
var newLimit = settings.NewCardsPerDay;
|
|
var reviewLimit = settings.ReviewsPerDay;
|
|
if (!string.IsNullOrWhiteSpace(deckId))
|
|
{
|
|
var deck = await decks.GetAsync(userId, deckId);
|
|
if (deck is not null)
|
|
{
|
|
newLimit = deck.NewCardsPerDay ?? newLimit;
|
|
reviewLimit = deck.ReviewsPerDay ?? reviewLimit;
|
|
}
|
|
}
|
|
var reviewedToday = checked((int)await schedules.CountReviewsSinceAsync(userId, studyDayStart));
|
|
var newIntroducedToday = await schedules.CountNewIntroducedSinceAsync(userId, studyDayStart);
|
|
return (
|
|
FlashcardScheduling.ResolveDailyBudget(newLimit, null, newIntroducedToday),
|
|
FlashcardScheduling.ResolveDailyBudget(reviewLimit, null, reviewedToday));
|
|
}
|
|
}
|