Files
hz 09b670d5d4 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.
2026-07-22 00:43:52 -05:00

55 lines
2.6 KiB
C#

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<DateTime> 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<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)];
}
}