using FSRS.Core.Enums; using FSRS.Core.Models; using FSRS.Core.Services; using QuestionsApi.Models; namespace QuestionsApi.Services.Flashcards; public sealed class FlashcardScheduleResult { public string State { get; init; } = FlashcardScheduleStates.Learning; public int? Step { get; init; } public double Stability { get; init; } public double Difficulty { get; init; } public DateTime DueUtc { get; init; } public DateTime LastReviewUtc { get; init; } } public interface IFlashcardScheduler { FlashcardScheduleResult Review(FlashcardSchedule schedule, string rating, DateTime reviewedUtc, int durationSeconds); List PreviewRatingOptions(FlashcardSchedule schedule, DateTime nowUtc); } // Wraps FSRS.Core 1.0.7 behind an internal interface, pinned to the repo's fixed defaults // (90% desired retention, 36,500 day max interval, no per-user parameter optimization). // Fuzzing is disabled so scheduling is deterministic: reproducible for tests/support, and // so a review's outcome depends only on stored state, never on wall-clock randomness. public sealed class FsrsFlashcardScheduler : IFlashcardScheduler { public const string AlgorithmVersion = "fsrs-core-1.0.7"; public const double DesiredRetention = 0.90; public const int MaximumIntervalDays = 36_500; private readonly Scheduler _scheduler = new( desiredRetention: DesiredRetention, maximumInterval: MaximumIntervalDays, enableFuzzing: false); public FlashcardScheduleResult Review(FlashcardSchedule schedule, string rating, DateTime reviewedUtc, int durationSeconds) { var card = ToCard(schedule); var (updated, _) = _scheduler.ReviewCard(card, ParseRating(rating), reviewedUtc, Math.Max(0, durationSeconds) * 1000); return FromCard(updated); } public List PreviewRatingOptions(FlashcardSchedule schedule, DateTime nowUtc) { var options = new List(); foreach (var rating in FlashcardRatings.All) { var card = ToCard(schedule); var (updated, _) = _scheduler.ReviewCard(card, ParseRating(rating), nowUtc); options.Add(new FlashcardRatingOption { Rating = rating, DueUtc = updated.Due, IntervalLabel = FormatInterval(updated.Due - nowUtc) }); } return options; } private static Card ToCard(FlashcardSchedule schedule) => schedule.LastReviewUtc is null ? new Card() : new Card( state: ParseState(schedule.State), step: schedule.Step, stability: schedule.Stability, difficulty: schedule.Difficulty, due: schedule.DueUtc, lastReview: schedule.LastReviewUtc); private static FlashcardScheduleResult FromCard(Card card) => new() { State = FormatState(card.State), Step = card.Step, Stability = card.Stability ?? 0, Difficulty = card.Difficulty ?? 0, DueUtc = card.Due, LastReviewUtc = card.LastReview ?? DateTime.UtcNow }; private static Rating ParseRating(string rating) => rating switch { FlashcardRatings.Again => Rating.Again, FlashcardRatings.Hard => Rating.Hard, FlashcardRatings.Good => Rating.Good, FlashcardRatings.Easy => Rating.Easy, _ => throw new ArgumentOutOfRangeException(nameof(rating), rating, "unknown flashcard rating") }; private static State ParseState(string state) => state switch { FlashcardScheduleStates.Review => State.Review, FlashcardScheduleStates.Relearning => State.Relearning, _ => State.Learning }; private static string FormatState(State state) => state switch { State.Review => FlashcardScheduleStates.Review, State.Relearning => FlashcardScheduleStates.Relearning, _ => FlashcardScheduleStates.Learning }; public static string FormatInterval(TimeSpan span) { if (span.TotalMinutes < 1) return "<1m"; if (span.TotalHours < 1) return $"{(int)Math.Round(span.TotalMinutes)}m"; if (span.TotalDays < 1) return $"{(int)Math.Round(span.TotalHours)}h"; if (span.TotalDays < 30) return $"{(int)Math.Round(span.TotalDays)}d"; if (span.TotalDays < 365) return $"{Math.Round(span.TotalDays / 30, 1)}mo"; return $"{Math.Round(span.TotalDays / 365, 1)}y"; } }