Add flashcards feature with spaced repetition scheduling

Adds a full flashcards system: FSRS-based scheduling, cloze/basic
card types, CSV import/export, media storage, and study UI, wired
into navigation and the dashboard alongside existing question flows.
This commit is contained in:
hz
2026-07-21 15:08:52 -05:00
parent 29b89dc404
commit 48a8c8361d
41 changed files with 4458 additions and 8 deletions
+21 -4
View File
@@ -1,5 +1,6 @@
using MongoDB.Driver;
using QuestionsApi.Models;
using QuestionsApi.Services.Flashcards;
namespace QuestionsApi.Services;
@@ -15,8 +16,15 @@ public sealed class DashboardStore
private readonly IMongoCollection<UserNotepad> _notes;
private readonly QuestionStore _questions;
private readonly ReviewQueueStore _reviewQueue;
private readonly FlashcardStudyService _flashcards;
private readonly FlashcardScheduleStore _flashcardSchedules;
public DashboardStore(MongoDatabaseProvider mongo, QuestionStore questions, ReviewQueueStore reviewQueue)
public DashboardStore(
MongoDatabaseProvider mongo,
QuestionStore questions,
ReviewQueueStore reviewQueue,
FlashcardStudyService flashcards,
FlashcardScheduleStore flashcardSchedules)
{
_attempts = mongo.Database.GetCollection<QuestionAttempt>("QuestionAttempts");
_ratings = mongo.Database.GetCollection<UserRating>("UserRatings");
@@ -28,6 +36,8 @@ public sealed class DashboardStore
_notes = mongo.Database.GetCollection<UserNotepad>("UserNotepads");
_questions = questions;
_reviewQueue = reviewQueue;
_flashcards = flashcards;
_flashcardSchedules = flashcardSchedules;
}
public async Task<DashboardResponse> GetAsync(string userId, DateTime nowUtc)
@@ -45,8 +55,10 @@ public sealed class DashboardStore
var bookmarkCountTask = _bookmarks.CountDocumentsAsync(value => value.UserId == userId);
var notepadTask = _notes.Find(value => value.UserId == userId).FirstOrDefaultAsync();
var reviewAttemptsTask = _reviewQueue.GetReviewsSinceAsync(userId, weekStart);
var flashcardCountsTask = _flashcards.GetDashboardCountsAsync(userId, nowUtc, weekStart);
var flashcardReviewsTask = _flashcardSchedules.GetReviewsSinceAsync(userId, weekStart);
await Task.WhenAll(attemptsTask, ratingTask, ccsRatingTask, ccsAttemptsTask, completedCcsCountTask,
simulationsTask, bookmarkCountTask, notepadTask, reviewAttemptsTask);
simulationsTask, bookmarkCountTask, notepadTask, reviewAttemptsTask, flashcardCountsTask, flashcardReviewsTask);
var attempts = attemptsTask.Result;
var answeredAttempts = attempts.Where(value => !value.IsSkip).ToList();
@@ -71,7 +83,10 @@ public sealed class DashboardStore
QuestionsThisWeek = answeredAttempts.Count(value => value.CreatedUtc >= weekStart),
BookmarkCount = checked((int)bookmarkCountTask.Result),
NoteCount = CountNotes(notepadTask.Result?.Content),
MissedQuestionCount = checked((int)pendingReviewCount)
MissedQuestionCount = checked((int)pendingReviewCount),
FlashcardsDue = flashcardCountsTask.Result.Due,
FlashcardsNewAvailable = flashcardCountsTask.Result.NewAvailable,
FlashcardsReviewedThisWeek = flashcardCountsTask.Result.ReviewedThisWeek
};
response.PerformanceTrend = Enumerable.Range(0, 30).Select(offset =>
@@ -115,12 +130,14 @@ public sealed class DashboardStore
+ dailyCcs.Sum(CcsDurationSeconds)
+ dailySimulations.Sum(value => value.Responses.Sum(item => Math.Max(0, item.ElapsedSeconds)))
+ reviewAttemptsTask.Result.Where(value => value.CreatedUtc.Date == date).Sum(value => value.DurationSeconds)
+ flashcardReviewsTask.Result.Where(value => value.ReviewedUtc.Date == date).Sum(value => value.DurationSeconds)
};
}).ToList();
response.StudyTimeSecondsThisWeek = response.WeeklyActivity.Sum(value => value.StudyTimeSeconds);
var activeDates = attempts.Select(value => value.CreatedUtc.Date)
.Concat(completedCcs.Select(value => value.CompletedUtc!.Value.Date))
.Concat(simulations.Where(value => value.CompletedUtc.HasValue).Select(value => value.CompletedUtc!.Value.Date));
.Concat(simulations.Where(value => value.CompletedUtc.HasValue).Select(value => value.CompletedUtc!.Value.Date))
.Concat(flashcardReviewsTask.Result.Select(value => value.ReviewedUtc.Date));
response.CurrentStreak = CalculateCurrentStreak(activeDates, today);
response.RecentActivity = await BuildRecentActivityAsync(answeredAttempts, questionMap, completedCcs, simulations);
return response;