Files
step3/api/QuestionsApi/Services/Flashcards/FlashcardStudyService.cs
T
hz 48a8c8361d Add flashcards feature with spaced repetition scheduling
Adds a full flashcards system: FSRS-based scheduling, cloze/basic
card types, CSV import/export, media storage, and study UI, wired
into navigation and the dashboard alongside existing question flows.
2026-07-21 15:08:52 -05:00

113 lines
5.2 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));
return new FlashcardDashboardCounts { Due = due, NewAvailable = newAvailable, ReviewedThisWeek = reviewedThisWeek };
}
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));
result.Add(new FlashcardDeckSummary
{
Deck = deck,
Total = deckNoteIds.Count,
Due = due,
New = fresh
});
}
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));
}
}