Close the gap between the flashcards study screen and the mockup
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.
This commit is contained in:
@@ -32,4 +32,23 @@ public static class FlashcardScheduling
|
||||
}
|
||||
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<string> poolIds, ISet<string> exclude, bool shuffle, Func<int, int> randomIndex)
|
||||
{
|
||||
if (!shuffle)
|
||||
{
|
||||
for (var i = 0; i < poolIds.Count; i++)
|
||||
if (!exclude.Contains(poolIds[i])) return i;
|
||||
return null;
|
||||
}
|
||||
var candidates = new List<int>();
|
||||
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)];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,10 @@ public sealed class FlashcardStudyService(
|
||||
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;
|
||||
@@ -19,17 +23,23 @@ public sealed class FlashcardStudyService(
|
||||
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.
|
||||
// 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 due = await schedules.GetDueAsync(userId, noteIds, now, studyDayStart, reviewBudget + exclude.Count + 1);
|
||||
card = due.FirstOrDefault(v => !exclude.Contains(v.Id!));
|
||||
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 fresh = await schedules.GetNewAsync(userId, noteIds, studyDayStart, newBudget + exclude.Count + 1);
|
||||
card = fresh.FirstOrDefault(v => !exclude.Contains(v.Id!));
|
||||
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));
|
||||
|
||||
Reference in New Issue
Block a user