namespace QuestionsApi.Services.Flashcards; // Small pure helpers pulled out of FlashcardStudyService/FlashcardScheduleStore so the // daily-limit and sibling-bury rules can be unit tested without a live Mongo instance. public static class FlashcardScheduling { // A card reviewed on a given local study day buries its siblings until the next local day. public static DateTime NextStudyDayStart(DateTime studyDayStartUtc) => studyDayStartUtc.Date.AddDays(1); // Effective per-day budget: deck override wins over the global default, minus what's // already been consumed on the client's current local study day. public static int ResolveDailyBudget(int globalLimit, int? deckOverride, int consumedToday) => Math.Max(0, (deckOverride ?? globalLimit) - consumedToday); // A streak counts consecutive calendar days with at least one review, ending either today or // yesterday (so the streak doesn't visibly reset to 0 the moment a new day starts before // today's first review) - the same semantics as most habit-streak trackers. public static int ComputeStreakDays(IReadOnlyCollection reviewTimestamps, DateTime todayUtc) { var reviewDays = reviewTimestamps.Select(v => v.Date).ToHashSet(); var cursor = todayUtc.Date; if (!reviewDays.Contains(cursor)) { cursor = cursor.AddDays(-1); if (!reviewDays.Contains(cursor)) return 0; } var streak = 0; while (reviewDays.Contains(cursor)) { streak++; cursor = cursor.AddDays(-1); } return streak; } // Picks which candidate in an already-sorted pool becomes the next card. Non-shuffle: the // first not already excluded this session (today's deterministic oldest-first behavior). // Shuffle: a random pick among the non-excluded candidates - `randomIndex(n)` must return a // value in [0, n); production passes Random.Shared.Next, tests pass a fixed stub so the // excluded-id-to-original-index mapping is verifiable without relying on real randomness. public static int? PickIndex(IReadOnlyList poolIds, ISet exclude, bool shuffle, Func randomIndex) { if (!shuffle) { for (var i = 0; i < poolIds.Count; i++) if (!exclude.Contains(poolIds[i])) return i; return null; } var candidates = new List(); for (var i = 0; i < poolIds.Count; i++) if (!exclude.Contains(poolIds[i])) candidates.Add(i); return candidates.Count == 0 ? null : candidates[randomIndex(candidates.Count)]; } }