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 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 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> GetDeckSummariesAsync(string userId, DateTime nowUtc, DateTime studyDayStart) { var deckList = await decks.ListAsync(userId); var result = new List(); 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> 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)); } }