Add real session-history navigation (Previous/Next/Shuffle), compact
side panels, and a segmented progress row to match the mockup layout.
Flashcards now opens straight into Study, with library management
moved into the Options menu ("Manage Flashcards") and Export hidden
from the UI (Import stays). Shuffle is backed by a real, due-before-new
randomized pick on the backend, not a decorative toggle.
147 lines
7.6 KiB
C#
147 lines
7.6 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.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));
|
|
}
|
|
}
|