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.
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user