System decks/notes are owned by a reserved sentinel user id, reusing all existing per-user store/index plumbing unchanged. Regular users see them under a separate "Official Decks" section, can study them with fully independent per-user FSRS progress (lazily materialized on first visit), but can never edit or archive them - enforced both in the client (no Edit/Archive controls) and server-side (ownership guard on every mutating endpoint). Includes a small idempotent seed command (`dotnet run -- seed-flashcards`) for testing, since there's no in-app authoring UI yet.
159 lines
8.4 KiB
C#
159 lines
8.4 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)
|
|
{
|
|
// Extra rows fetched beyond the budget when shuffling, so there's an actual pool to
|
|
// randomize over instead of just enough rows to skip past this session's exclusions.
|
|
private const int ShuffleCandidatePoolPadding = 40;
|
|
|
|
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, or shuffled within the due pool), then new
|
|
// cards (oldest first, or shuffled within the new pool) - shuffle never lets a new card
|
|
// jump ahead of a due one, it only randomizes selection within each pool.
|
|
FlashcardSchedule? card = null;
|
|
if (reviewBudget > 0)
|
|
{
|
|
var dueLimit = (request.Shuffle ? Math.Max(reviewBudget, ShuffleCandidatePoolPadding) : reviewBudget) + exclude.Count + 1;
|
|
var due = await schedules.GetDueAsync(userId, noteIds, now, studyDayStart, dueLimit);
|
|
var pickedIndex = FlashcardScheduling.PickIndex(due.Select(v => v.Id!).ToList(), exclude, request.Shuffle, Random.Shared.Next);
|
|
if (pickedIndex is int dueIndex) card = due[dueIndex];
|
|
}
|
|
if (card is null && newBudget > 0)
|
|
{
|
|
var newLimit = (request.Shuffle ? Math.Max(newBudget, ShuffleCandidatePoolPadding) : newBudget) + exclude.Count + 1;
|
|
var fresh = await schedules.GetNewAsync(userId, noteIds, studyDayStart, newLimit);
|
|
var pickedIndex = FlashcardScheduling.PickIndex(fresh.Select(v => v.Id!).ToList(), exclude, request.Shuffle, Random.Shared.Next);
|
|
if (pickedIndex is int newIndex) card = fresh[newIndex];
|
|
}
|
|
|
|
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.GetVisibleAsync(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.ListVisibleAsync(userId);
|
|
var result = new List<FlashcardDeckSummary>();
|
|
foreach (var deck in deckList)
|
|
{
|
|
var deckNotes = await notes.ListByDeckVisibleAsync(userId, deck.Id!);
|
|
await EnsureSystemScheduleRowsAsync(userId, deckNotes);
|
|
var deckNoteIds = deckNotes.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.ListAllVisibleActiveAsync(userId)
|
|
: await notes.ListByDeckVisibleAsync(userId, deckId);
|
|
await EnsureSystemScheduleRowsAsync(userId, activeNotes);
|
|
return activeNotes.Select(v => v.Id!).ToList();
|
|
}
|
|
|
|
// Lazily materializes this user's own FlashcardSchedule rows for any note they don't own
|
|
// (i.e. system notes) before it's counted/served. A no-op once a user has touched a given
|
|
// system note (SyncVariantsForStudyingUserAsync is duplicate-key-tolerant and additive-only).
|
|
private async Task EnsureSystemScheduleRowsAsync(string userId, List<FlashcardNote> activeNotes)
|
|
{
|
|
foreach (var note in activeNotes.Where(v => v.IsSystem))
|
|
await schedules.SyncVariantsForStudyingUserAsync(userId, note);
|
|
}
|
|
|
|
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.GetVisibleAsync(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));
|
|
}
|
|
}
|