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:
hz
2026-07-22 00:43:52 -05:00
parent 70aa425fde
commit 09b670d5d4
21 changed files with 555 additions and 70 deletions
@@ -102,4 +102,76 @@ public sealed class FlashcardSchedulingTests
Assert.Equal(1, FlashcardScheduling.ComputeStreakDays(timestamps, Today));
}
[Fact]
public void PickIndex_NonShuffle_ReturnsFirstNonExcluded()
{
var pool = new List<string> { "a", "b", "c" };
var picked = FlashcardScheduling.PickIndex(pool, new HashSet<string> { "a" }, shuffle: false, n => throw new InvalidOperationException("should not randomize"));
Assert.Equal(1, picked);
}
[Fact]
public void PickIndex_NonShuffle_EverythingExcluded_ReturnsNull()
{
var pool = new List<string> { "a", "b" };
var picked = FlashcardScheduling.PickIndex(pool, new HashSet<string> { "a", "b" }, shuffle: false, n => 0);
Assert.Null(picked);
}
[Fact]
public void PickIndex_EmptyPool_ReturnsNullRegardlessOfShuffle()
{
Assert.Null(FlashcardScheduling.PickIndex([], new HashSet<string>(), shuffle: false, n => 0));
Assert.Null(FlashcardScheduling.PickIndex([], new HashSet<string>(), shuffle: true, n => 0));
}
[Fact]
public void PickIndex_Shuffle_StubReturningFirstCandidate_PicksFirstNonExcluded()
{
var pool = new List<string> { "a", "b", "c" };
var picked = FlashcardScheduling.PickIndex(pool, new HashSet<string> { "a" }, shuffle: true, n => 0);
// Candidates (excluding "a") are ["b", "c"]; stub index 0 -> "b" -> original pool index 1.
Assert.Equal(1, picked);
}
[Fact]
public void PickIndex_Shuffle_CanReturnSomethingOtherThanTheOldestCandidate()
{
var pool = new List<string> { "a", "b", "c" };
// Stub picks the last candidate instead of always the first (oldest), proving this isn't
// hardcoded to FirstOrDefault-style behavior.
var picked = FlashcardScheduling.PickIndex(pool, new HashSet<string>(), shuffle: true, n => n - 1);
Assert.Equal(2, picked);
}
[Fact]
public void PickIndex_Shuffle_ExcludedItemsDoNotShiftTheOriginalIndexMapping()
{
var pool = new List<string> { "a", "b", "c", "d" };
// Candidates (excluding "b") are ["a", "c", "d"] at original indices [0, 2, 3]; stub
// index 2 should map back to original index 3 ("d"), not 2 ("c").
var picked = FlashcardScheduling.PickIndex(pool, new HashSet<string> { "b" }, shuffle: true, n => 2);
Assert.Equal(3, picked);
}
[Fact]
public void PickIndex_Shuffle_EverythingExcluded_ReturnsNull()
{
var pool = new List<string> { "a", "b" };
var picked = FlashcardScheduling.PickIndex(pool, new HashSet<string> { "a", "b" }, shuffle: true, n => 0);
Assert.Null(picked);
}
}
@@ -202,6 +202,7 @@ public sealed class FlashcardStudyRequest
public string DeckId { get; set; } = "";
public List<string> ExcludeCardIds { get; set; } = [];
public DateTime StudyDayStartUtc { get; set; } = DateTime.UtcNow.Date;
public bool Shuffle { get; set; }
}
public sealed class FlashcardBulkNoteIdsRequest
@@ -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));