From de4e7c4c6e7fb0957f1fab3c19cb3909e4af4f9b Mon Sep 17 00:00:00 2001 From: Stefan Hardegger Date: Sat, 22 Nov 2025 14:28:26 +0100 Subject: [PATCH] learning randomization --- src/actions/learning.ts | 813 ++++++++++++++++++++ src/app/(app)/collections/[id]/page.tsx | 54 +- src/app/(app)/dashboard/page.tsx | 25 +- src/app/(app)/learn/[collectionId]/page.tsx | 347 +++++++++ src/lib/learning/sm2.test.ts | 79 +- src/lib/learning/sm2.ts | 46 +- 6 files changed, 1265 insertions(+), 99 deletions(-) create mode 100644 src/actions/learning.ts create mode 100644 src/app/(app)/learn/[collectionId]/page.tsx diff --git a/src/actions/learning.ts b/src/actions/learning.ts new file mode 100644 index 0000000..adf9967 --- /dev/null +++ b/src/actions/learning.ts @@ -0,0 +1,813 @@ +"use server" + +import { revalidatePath } from "next/cache" +import { prisma } from "@/lib/prisma" +import { auth } from "@/lib/auth" +import { Difficulty } from "@prisma/client" +import { + selectCardsForSession, + calculateCorrectAnswer, + calculateIncorrectAnswer, + generateWrongAnswers, + shuffleOptions, + INITIAL_PROGRESS, + type SelectableCard, + type HanziOption, +} from "@/lib/learning/sm2" +import { z } from "zod" + +/** + * Standard action result type + */ +export type ActionResult = { + success: boolean + data?: T + message?: string + errors?: Record +} + +// ============================================================================ +// VALIDATION SCHEMAS +// ============================================================================ + +const startLearningSessionSchema = z.object({ + collectionId: z.string().optional(), + cardsCount: z.number().int().positive().default(20), +}) + +const submitAnswerSchema = z.object({ + sessionId: z.string().min(1), + hanziId: z.string().min(1), + selectedPinyin: z.string().min(1), + correct: z.boolean(), + timeSpentMs: z.number().int().min(0), +}) + +const endSessionSchema = z.object({ + sessionId: z.string().min(1), +}) + +const updateCardDifficultySchema = z.object({ + hanziId: z.string().min(1), + difficulty: z.nativeEnum(Difficulty), +}) + +const removeFromLearningSchema = z.object({ + hanziId: z.string().min(1), +}) + +// ============================================================================ +// LEARNING ACTIONS +// ============================================================================ + +/** + * Start a learning session + * + * Selects due cards using SM-2 algorithm and creates a session. + * If no collectionId provided, selects from all user's collections. + * + * @param collectionId - Optional collection to learn from + * @param cardsCount - Number of cards to include (default: user preference) + * @returns Session with cards and answer options + */ +export async function startLearningSession( + collectionId?: string, + cardsCount?: number +): Promise +}>> { + try { + const session = await auth() + if (!session?.user?.id) { + return { + success: false, + message: "Authentication required", + } + } + + // Get user preferences + const preferences = await prisma.userPreference.findUnique({ + where: { userId: session.user.id }, + }) + + const cardsPerSession = cardsCount || preferences?.cardsPerSession || 20 + + const validation = startLearningSessionSchema.safeParse({ + collectionId, + cardsCount: cardsPerSession, + }) + + if (!validation.success) { + return { + success: false, + message: "Validation failed", + errors: validation.error.flatten().fieldErrors, + } + } + + // Get user's hanzi progress for due cards + const whereClause: any = { + userId: session.user.id, + } + + // If collectionId provided, filter by collection + if (collectionId) { + whereClause.hanzi = { + collectionItems: { + some: { + collectionId, + }, + }, + } + } + + const userProgress = await prisma.userHanziProgress.findMany({ + where: whereClause, + include: { + hanzi: { + include: { + forms: { + where: { isDefault: true }, + include: { + transcriptions: { + where: { type: "pinyin" }, + }, + }, + }, + hskLevels: true, + }, + }, + }, + }) + + // Convert to SelectableCard format for SM-2 algorithm + const selectableCards: SelectableCard[] = userProgress.map(progress => ({ + id: progress.hanziId, + nextReviewDate: progress.nextReviewDate, + incorrectCount: progress.incorrectCount, + consecutiveCorrect: progress.consecutiveCorrect, + manualDifficulty: progress.manualDifficulty as Difficulty, + })) + + // Select cards using SM-2 algorithm + const selectedCards = selectCardsForSession(selectableCards, cardsPerSession) + + // If not enough due cards, add new cards from collection + if (selectedCards.length < cardsPerSession) { + const neededCards = cardsPerSession - selectedCards.length + + // Find hanzi not yet in user progress + const newHanziWhereClause: any = { + id: { + notIn: userProgress.map(p => p.hanziId), + }, + } + + if (collectionId) { + newHanziWhereClause.collectionItems = { + some: { + collectionId, + }, + } + } + + const newHanzi = await prisma.hanzi.findMany({ + where: newHanziWhereClause, + include: { + forms: { + where: { isDefault: true }, + include: { + transcriptions: { + where: { type: "pinyin" }, + }, + }, + }, + hskLevels: true, + }, + take: neededCards, + }) + + // Create initial progress for new cards + const now = new Date() + for (const hanzi of newHanzi) { + await prisma.userHanziProgress.create({ + data: { + userId: session.user.id, + hanziId: hanzi.id, + ...INITIAL_PROGRESS, + nextReviewDate: now, + }, + }) + + selectedCards.push({ + id: hanzi.id, + nextReviewDate: now, + incorrectCount: 0, + consecutiveCorrect: 0, + manualDifficulty: Difficulty.MEDIUM, + }) + } + } + + if (selectedCards.length === 0) { + return { + success: false, + message: "No cards available to learn", + } + } + + // Create learning session + const learningSession = await prisma.learningSession.create({ + data: { + userId: session.user.id, + collectionId: collectionId || null, + cardsReviewed: selectedCards.length, + }, + }) + + // Get full hanzi details for selected cards + const selectedHanziIds = selectedCards.map(c => c.id) + const hanziDetails = await prisma.hanzi.findMany({ + where: { + id: { in: selectedHanziIds }, + }, + include: { + forms: { + where: { isDefault: true }, + include: { + transcriptions: { + where: { type: "pinyin" }, + }, + meanings: true, + }, + }, + hskLevels: true, + }, + }) + + // Generate answer options for each card + const cards = [] + for (const hanzi of hanziDetails) { + const defaultForm = hanzi.forms[0] + if (!defaultForm) continue + + const pinyinTranscription = defaultForm.transcriptions[0] + if (!pinyinTranscription) continue + + const correctPinyin = pinyinTranscription.value + + // Get HSK level for this hanzi + const hskLevel = hanzi.hskLevels[0]?.level || "new-1" + + // Get other hanzi from same HSK level for wrong answers + const sameHskHanzi = await prisma.hanzi.findMany({ + where: { + id: { not: hanzi.id }, + hskLevels: { + some: { + level: hskLevel, + }, + }, + }, + include: { + forms: { + where: { isDefault: true }, + include: { + transcriptions: { + where: { type: "pinyin" }, + }, + }, + }, + }, + take: 10, // Get extra to ensure enough unique options + }) + + // Convert to HanziOption format + const hanziOptions: HanziOption[] = sameHskHanzi + .map(h => { + const form = h.forms[0] + const pinyin = form?.transcriptions[0] + if (!form || !pinyin) return null + return { + id: h.id, + simplified: h.simplified, + pinyin: pinyin.value, + hskLevel: hskLevel, + } + }) + .filter((h): h is HanziOption => h !== null) + + // Generate wrong answers + let wrongAnswers: string[] = [] + try { + wrongAnswers = generateWrongAnswers( + { + id: hanzi.id, + simplified: hanzi.simplified, + pinyin: correctPinyin, + hskLevel, + }, + hanziOptions + ) + } catch (error) { + // If not enough options, use random ones + wrongAnswers = hanziOptions + .slice(0, 3) + .map(o => o.pinyin) + .filter(p => p !== correctPinyin) + + // Fill with placeholders if still not enough + while (wrongAnswers.length < 3) { + wrongAnswers.push(`Option ${wrongAnswers.length + 1}`) + } + } + + // Shuffle all options + const allOptions = shuffleOptions([correctPinyin, ...wrongAnswers]) + + // Get English meaning (first meaning) + const meaning = defaultForm.meanings[0]?.meaning || "" + + cards.push({ + hanziId: hanzi.id, + simplified: hanzi.simplified, + options: allOptions, + correctPinyin, + meaning, + }) + } + + return { + success: true, + data: { + sessionId: learningSession.id, + cards, + }, + message: `Learning session started with ${cards.length} cards`, + } + } catch (error) { + console.error("Start learning session error:", error) + return { + success: false, + message: "Failed to start learning session", + } + } +} + +/** + * Submit an answer for a card + * + * Records the answer, updates SM-2 progress, and creates a session review. + * + * @param sessionId - Current session ID + * @param hanziId - Hanzi being reviewed + * @param selectedPinyin - User's selected answer + * @param correct - Whether answer was correct + * @param timeSpentMs - Time spent on this card in milliseconds + * @returns Updated progress information + */ +export async function submitAnswer( + sessionId: string, + hanziId: string, + selectedPinyin: string, + correct: boolean, + timeSpentMs: number +): Promise> { + try { + const session = await auth() + if (!session?.user?.id) { + return { + success: false, + message: "Authentication required", + } + } + + const validation = submitAnswerSchema.safeParse({ + sessionId, + hanziId, + selectedPinyin, + correct, + timeSpentMs, + }) + + if (!validation.success) { + return { + success: false, + message: "Validation failed", + errors: validation.error.flatten().fieldErrors, + } + } + + // Verify session belongs to user + const learningSession = await prisma.learningSession.findUnique({ + where: { id: sessionId }, + }) + + if (!learningSession || learningSession.userId !== session.user.id) { + return { + success: false, + message: "Invalid session", + } + } + + // Get current progress + const progress = await prisma.userHanziProgress.findUnique({ + where: { + userId_hanziId: { + userId: session.user.id, + hanziId, + }, + }, + }) + + if (!progress) { + return { + success: false, + message: "Progress not found", + } + } + + // Calculate new progress using SM-2 algorithm + const reviewDate = new Date() + const updatedProgress = correct + ? calculateCorrectAnswer( + { + easeFactor: progress.easeFactor, + interval: progress.interval, + consecutiveCorrect: progress.consecutiveCorrect, + incorrectCount: progress.incorrectCount, + nextReviewDate: progress.nextReviewDate, + }, + reviewDate + ) + : calculateIncorrectAnswer( + { + easeFactor: progress.easeFactor, + interval: progress.interval, + consecutiveCorrect: progress.consecutiveCorrect, + incorrectCount: progress.incorrectCount, + nextReviewDate: progress.nextReviewDate, + }, + reviewDate + ) + + // Update progress + await prisma.userHanziProgress.update({ + where: { + userId_hanziId: { + userId: session.user.id, + hanziId, + }, + }, + data: { + easeFactor: updatedProgress.easeFactor, + interval: updatedProgress.interval, + consecutiveCorrect: updatedProgress.consecutiveCorrect, + incorrectCount: updatedProgress.incorrectCount, + nextReviewDate: updatedProgress.nextReviewDate, + }, + }) + + // Create session review record + await prisma.sessionReview.create({ + data: { + sessionId, + hanziId, + isCorrect: correct, + responseTime: timeSpentMs, + }, + }) + + // Update session counts + await prisma.learningSession.update({ + where: { id: sessionId }, + data: { + correctAnswers: correct + ? { increment: 1 } + : undefined, + incorrectAnswers: !correct + ? { increment: 1 } + : undefined, + }, + }) + + return { + success: true, + data: { + easeFactor: updatedProgress.easeFactor, + interval: updatedProgress.interval, + consecutiveCorrect: updatedProgress.consecutiveCorrect, + nextReviewDate: updatedProgress.nextReviewDate, + }, + message: correct ? "Correct answer!" : "Incorrect answer", + } + } catch (error) { + console.error("Submit answer error:", error) + return { + success: false, + message: "Failed to submit answer", + } + } +} + +/** + * End a learning session + * + * Marks the session as complete and returns summary statistics. + * + * @param sessionId - Session to end + * @returns Session summary with stats + */ +export async function endSession( + sessionId: string +): Promise> { + try { + const session = await auth() + if (!session?.user?.id) { + return { + success: false, + message: "Authentication required", + } + } + + const validation = endSessionSchema.safeParse({ sessionId }) + + if (!validation.success) { + return { + success: false, + message: "Validation failed", + errors: validation.error.flatten().fieldErrors, + } + } + + // Verify session belongs to user + const learningSession = await prisma.learningSession.findUnique({ + where: { id: sessionId }, + }) + + if (!learningSession || learningSession.userId !== session.user.id) { + return { + success: false, + message: "Invalid session", + } + } + + if (learningSession.endedAt) { + return { + success: false, + message: "Session already completed", + } + } + + // Mark session as complete + const endedAt = new Date() + await prisma.learningSession.update({ + where: { id: sessionId }, + data: { endedAt }, + }) + + // Calculate summary stats + const totalCards = learningSession.cardsReviewed + const correctCount = learningSession.correctAnswers + const incorrectCount = learningSession.incorrectAnswers + const accuracyPercent = + totalCards > 0 ? Math.round((correctCount / totalCards) * 100) : 0 + const durationMs = endedAt.getTime() - learningSession.startedAt.getTime() + const durationMinutes = Math.round(durationMs / 1000 / 60) + + revalidatePath("/dashboard") + revalidatePath("/progress") + + return { + success: true, + data: { + totalCards, + correctCount, + incorrectCount, + accuracyPercent, + durationMinutes, + }, + message: "Session completed successfully", + } + } catch (error) { + console.error("End session error:", error) + return { + success: false, + message: "Failed to end session", + } + } +} + +/** + * Get count of due cards + * + * Returns counts of cards due now, today, and this week. + * + * @returns Due card counts + */ +export async function getDueCards(): Promise> { + try { + const session = await auth() + if (!session?.user?.id) { + return { + success: false, + message: "Authentication required", + } + } + + const now = new Date() + const endOfToday = new Date(now) + endOfToday.setHours(23, 59, 59, 999) + const endOfWeek = new Date(now) + endOfWeek.setDate(endOfWeek.getDate() + 7) + + const [dueNow, dueToday, dueThisWeek] = await Promise.all([ + prisma.userHanziProgress.count({ + where: { + userId: session.user.id, + nextReviewDate: { + lte: now, + }, + manualDifficulty: { + not: Difficulty.SUSPENDED, + }, + }, + }), + prisma.userHanziProgress.count({ + where: { + userId: session.user.id, + nextReviewDate: { + lte: endOfToday, + }, + manualDifficulty: { + not: Difficulty.SUSPENDED, + }, + }, + }), + prisma.userHanziProgress.count({ + where: { + userId: session.user.id, + nextReviewDate: { + lte: endOfWeek, + }, + manualDifficulty: { + not: Difficulty.SUSPENDED, + }, + }, + }), + ]) + + return { + success: true, + data: { + dueNow, + dueToday, + dueThisWeek, + }, + } + } catch (error) { + console.error("Get due cards error:", error) + return { + success: false, + message: "Failed to get due cards", + } + } +} + +/** + * Update manual difficulty for a card + * + * Allows user to manually mark cards as EASY, NORMAL, HARD, or SUSPENDED. + * + * @param hanziId - Hanzi to update + * @param difficulty - New difficulty level + * @returns Success status + */ +export async function updateCardDifficulty( + hanziId: string, + difficulty: Difficulty +): Promise { + try { + const session = await auth() + if (!session?.user?.id) { + return { + success: false, + message: "Authentication required", + } + } + + const validation = updateCardDifficultySchema.safeParse({ hanziId, difficulty }) + + if (!validation.success) { + return { + success: false, + message: "Validation failed", + errors: validation.error.flatten().fieldErrors, + } + } + + await prisma.userHanziProgress.update({ + where: { + userId_hanziId: { + userId: session.user.id, + hanziId, + }, + }, + data: { + manualDifficulty: difficulty, + }, + }) + + revalidatePath("/learn") + revalidatePath("/dashboard") + + return { + success: true, + message: `Card difficulty updated to ${difficulty}`, + } + } catch (error) { + console.error("Update card difficulty error:", error) + return { + success: false, + message: "Failed to update card difficulty", + } + } +} + +/** + * Remove a card from learning + * + * Suspends the card so it won't appear in future sessions. + * + * @param hanziId - Hanzi to remove + * @returns Success status + */ +export async function removeFromLearning(hanziId: string): Promise { + try { + const session = await auth() + if (!session?.user?.id) { + return { + success: false, + message: "Authentication required", + } + } + + const validation = removeFromLearningSchema.safeParse({ hanziId }) + + if (!validation.success) { + return { + success: false, + message: "Validation failed", + errors: validation.error.flatten().fieldErrors, + } + } + + await prisma.userHanziProgress.update({ + where: { + userId_hanziId: { + userId: session.user.id, + hanziId, + }, + }, + data: { + manualDifficulty: Difficulty.SUSPENDED, + }, + }) + + revalidatePath("/learn") + revalidatePath("/dashboard") + + return { + success: true, + message: "Card removed from learning", + } + } catch (error) { + console.error("Remove from learning error:", error) + return { + success: false, + message: "Failed to remove card from learning", + } + } +} diff --git a/src/app/(app)/collections/[id]/page.tsx b/src/app/(app)/collections/[id]/page.tsx index 2a17d65..1a13f3e 100644 --- a/src/app/(app)/collections/[id]/page.tsx +++ b/src/app/(app)/collections/[id]/page.tsx @@ -313,32 +313,40 @@ export default function CollectionDetailPage() {

- {canModify && ( -
- - {collection.hanziCount > 0 && ( +
+ + Start Learning + + {canModify && ( + <> - )} - -
- )} + {collection.hanziCount > 0 && ( + + )} + + + )} +
{selectionMode && selectedHanziIds.size > 0 && ( diff --git a/src/app/(app)/dashboard/page.tsx b/src/app/(app)/dashboard/page.tsx index bc0c399..a522fcc 100644 --- a/src/app/(app)/dashboard/page.tsx +++ b/src/app/(app)/dashboard/page.tsx @@ -132,6 +132,17 @@ export default async function DashboardPage() { Quick Actions
+ +

+ Start Learning +

+

+ Begin a learning session with all cards +

+ - -

- Create Collection -

-

- Start a new hanzi collection -

-
-

- More features coming soon: Learning sessions, progress tracking, and more! -

diff --git a/src/app/(app)/learn/[collectionId]/page.tsx b/src/app/(app)/learn/[collectionId]/page.tsx new file mode 100644 index 0000000..91683c2 --- /dev/null +++ b/src/app/(app)/learn/[collectionId]/page.tsx @@ -0,0 +1,347 @@ +"use client" + +import { useState, useEffect, useCallback } from "react" +import { useParams, useRouter } from "next/navigation" +import { startLearningSession, submitAnswer, endSession } from "@/actions/learning" + +interface Card { + hanziId: string + simplified: string + options: string[] + correctPinyin: string + meaning: string +} + +interface SessionSummary { + totalCards: number + correctCount: number + incorrectCount: number + accuracyPercent: number + durationMinutes: number +} + +export default function LearnPage() { + const params = useParams() + const router = useRouter() + const collectionId = params.collectionId as string + + // Session state + const [sessionId, setSessionId] = useState(null) + const [cards, setCards] = useState([]) + const [currentIndex, setCurrentIndex] = useState(0) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + // Card state + const [selectedOption, setSelectedOption] = useState(null) + const [showFeedback, setShowFeedback] = useState(false) + const [isCorrect, setIsCorrect] = useState(false) + const [answerStartTime, setAnswerStartTime] = useState(Date.now()) + + // Summary state + const [showSummary, setShowSummary] = useState(false) + const [summary, setSummary] = useState(null) + + const currentCard = cards[currentIndex] + const progress = ((currentIndex / cards.length) * 100) || 0 + + // Start learning session + useEffect(() => { + const startSession = async () => { + setLoading(true) + setError(null) + + const collectionIdParam = collectionId === "all" ? undefined : collectionId + + const result = await startLearningSession(collectionIdParam) + + if (result.success && result.data) { + setSessionId(result.data.sessionId) + setCards(result.data.cards) + setAnswerStartTime(Date.now()) + } else { + setError(result.message || "Failed to start learning session") + } + + setLoading(false) + } + + startSession() + }, [collectionId]) + + // Handle answer selection + const handleSelectAnswer = useCallback((index: number) => { + if (showFeedback) return // Prevent changing answer after submission + + setSelectedOption(index) + }, [showFeedback]) + + // Submit answer + const handleSubmitAnswer = useCallback(async () => { + if (selectedOption === null || !currentCard || !sessionId) return + if (showFeedback) return // Already submitted + + const selectedPinyin = currentCard.options[selectedOption] + const correct = selectedPinyin === currentCard.correctPinyin + const timeSpentMs = Date.now() - answerStartTime + + setIsCorrect(correct) + setShowFeedback(true) + + // Submit to backend + await submitAnswer( + sessionId, + currentCard.hanziId, + selectedPinyin, + correct, + timeSpentMs + ) + }, [selectedOption, currentCard, sessionId, showFeedback, answerStartTime]) + + // Continue to next card or end session + const handleContinue = useCallback(async () => { + if (currentIndex < cards.length - 1) { + setCurrentIndex(prev => prev + 1) + setSelectedOption(null) + setShowFeedback(false) + setAnswerStartTime(Date.now()) + } else { + // End session and show summary + if (sessionId) { + const result = await endSession(sessionId) + if (result.success && result.data) { + setSummary(result.data) + } + } + setShowSummary(true) + } + }, [currentIndex, cards.length, sessionId]) + + // Keyboard shortcuts + useEffect(() => { + const handleKeyPress = (e: KeyboardEvent) => { + // Numbers 1-4 for answer selection + if (["1", "2", "3", "4"].includes(e.key)) { + const index = parseInt(e.key) - 1 + if (index < currentCard?.options.length) { + if (!showFeedback) { + handleSelectAnswer(index) + // Auto-submit after selection + setTimeout(() => { + handleSubmitAnswer() + }, 100) + } + } + } + + // Space to continue + if (e.key === " " && showFeedback) { + e.preventDefault() + handleContinue() + } + } + + window.addEventListener("keydown", handleKeyPress) + return () => window.removeEventListener("keydown", handleKeyPress) + }, [currentCard, showFeedback, handleSelectAnswer, handleSubmitAnswer, handleContinue]) + + // Loading state + if (loading) { + return ( +
+
+
+

Loading cards...

+
+
+ ) + } + + // Error state + if (error) { + return ( +
+
+
+ + + +
+

No Cards Available

+

{error}

+ +
+
+ ) + } + + // Summary screen + if (showSummary && summary) { + return ( +
+
+
+
🎉
+

Session Complete!

+

Great work!

+
+ +
+
+ Total Cards + {summary.totalCards} +
+ +
+ Correct + {summary.correctCount} +
+ +
+ Incorrect + {summary.incorrectCount} +
+ +
+ Accuracy + {summary.accuracyPercent}% +
+ +
+ Duration + {summary.durationMinutes} min +
+
+ +
+ + +
+
+
+ ) + } + + // Learning card screen + if (!currentCard) { + return
No cards available
+ } + + return ( +
+ {/* Progress bar */} +
+
+
+ + Card {currentIndex + 1} of {cards.length} + + + {Math.round(progress)}% + +
+
+
+
+
+
+ + {/* Main card area */} +
+
+ {/* Feedback overlay */} + {showFeedback && ( +
+
+
+ {isCorrect ? "✓" : "✗"} +
+

+ {isCorrect ? "Correct!" : "Incorrect"} +

+ {!isCorrect && ( +

+ Correct answer: {currentCard.correctPinyin} +

+ )} + {currentCard.meaning && ( +

+ {currentCard.meaning} +

+ )} + +
+
+ )} + + {/* Hanzi display */} +
+
{currentCard.simplified}
+

Select the correct pinyin

+
+ + {/* Answer options in 2x2 grid */} +
+ {currentCard.options.map((option, index) => ( + + ))} +
+ + {/* Keyboard shortcuts hint */} +
+

Press 1-4 to select • Space to continue

+
+
+
+
+ ) +} diff --git a/src/lib/learning/sm2.test.ts b/src/lib/learning/sm2.test.ts index 408bb43..ebe7391 100644 --- a/src/lib/learning/sm2.test.ts +++ b/src/lib/learning/sm2.test.ts @@ -26,8 +26,6 @@ describe("SM-2 Algorithm", () => { expect(INITIAL_PROGRESS.interval).toBe(1) expect(INITIAL_PROGRESS.consecutiveCorrect).toBe(0) expect(INITIAL_PROGRESS.incorrectCount).toBe(0) - expect(INITIAL_PROGRESS.lastReviewDate).toBeNull() - expect(INITIAL_PROGRESS.nextReviewDate).toBeNull() }) }) @@ -51,7 +49,6 @@ describe("SM-2 Algorithm", () => { interval: 1, consecutiveCorrect: 1, incorrectCount: 0, - lastReviewDate: new Date("2025-01-01"), nextReviewDate: new Date("2025-01-02"), } @@ -69,7 +66,6 @@ describe("SM-2 Algorithm", () => { interval: 6, consecutiveCorrect: 2, incorrectCount: 0, - lastReviewDate: new Date("2025-01-02"), nextReviewDate: new Date("2025-01-08"), } @@ -88,7 +84,6 @@ describe("SM-2 Algorithm", () => { interval: 50, consecutiveCorrect: 5, incorrectCount: 2, - lastReviewDate: new Date("2025-01-01"), nextReviewDate: new Date("2025-02-20"), } @@ -130,7 +125,6 @@ describe("SM-2 Algorithm", () => { interval: 365, consecutiveCorrect: 10, incorrectCount: 0, - lastReviewDate: new Date("2025-01-01"), nextReviewDate: new Date("2026-01-01"), } @@ -149,7 +143,6 @@ describe("SM-2 Algorithm", () => { interval: 16, consecutiveCorrect: 3, incorrectCount: 0, - lastReviewDate: new Date("2025-01-01"), nextReviewDate: new Date("2025-01-17"), } @@ -165,7 +158,6 @@ describe("SM-2 Algorithm", () => { interval: 16, consecutiveCorrect: 5, incorrectCount: 1, - lastReviewDate: new Date("2025-01-01"), nextReviewDate: new Date("2025-01-17"), } @@ -180,7 +172,6 @@ describe("SM-2 Algorithm", () => { interval: 6, consecutiveCorrect: 2, incorrectCount: 0, - lastReviewDate: new Date("2025-01-01"), nextReviewDate: new Date("2025-01-07"), } @@ -195,7 +186,6 @@ describe("SM-2 Algorithm", () => { interval: 1, consecutiveCorrect: 0, incorrectCount: 5, - lastReviewDate: new Date("2025-01-01"), nextReviewDate: new Date("2025-01-02"), } @@ -211,7 +201,6 @@ describe("SM-2 Algorithm", () => { interval: 6, consecutiveCorrect: 2, incorrectCount: 0, - lastReviewDate: new Date("2025-01-01"), nextReviewDate: new Date("2025-01-07"), } @@ -226,7 +215,6 @@ describe("SM-2 Algorithm", () => { interval: 6, consecutiveCorrect: 2, incorrectCount: 0, - lastReviewDate: new Date("2025-01-01"), nextReviewDate: new Date("2025-01-07"), } @@ -254,7 +242,6 @@ describe("SM-2 Algorithm", () => { interval: 16, consecutiveCorrect: 3, incorrectCount: 0, - lastReviewDate: new Date("2025-01-01"), nextReviewDate: new Date("2025-01-17"), } @@ -269,7 +256,6 @@ describe("SM-2 Algorithm", () => { interval: result.interval, consecutiveCorrect: result.consecutiveCorrect, incorrectCount: result.incorrectCount, - lastReviewDate: new Date("2025-01-17"), nextReviewDate: result.nextReviewDate, } result = calculateIncorrectAnswer(progress, new Date("2025-01-18")) @@ -282,7 +268,6 @@ describe("SM-2 Algorithm", () => { interval: result.interval, consecutiveCorrect: result.consecutiveCorrect, incorrectCount: result.incorrectCount, - lastReviewDate: new Date("2025-01-18"), nextReviewDate: result.nextReviewDate, } result = calculateIncorrectAnswer(progress, new Date("2025-01-19")) @@ -301,25 +286,25 @@ describe("SM-2 Algorithm", () => { nextReviewDate: new Date("2025-01-14T10:00:00Z"), // Due incorrectCount: 0, consecutiveCorrect: 1, - manualDifficulty: Difficulty.NORMAL, + manualDifficulty: Difficulty.MEDIUM, }, { id: "2", nextReviewDate: new Date("2025-01-16T10:00:00Z"), // Not due incorrectCount: 0, consecutiveCorrect: 1, - manualDifficulty: Difficulty.NORMAL, + manualDifficulty: Difficulty.MEDIUM, }, { id: "3", nextReviewDate: new Date("2025-01-13T10:00:00Z"), // Due incorrectCount: 0, consecutiveCorrect: 1, - manualDifficulty: Difficulty.NORMAL, + manualDifficulty: Difficulty.MEDIUM, }, ] - const selected = selectCardsForSession(cards, 10, now) + const selected = selectCardsForSession(cards, 10, now, false) expect(selected.length).toBe(2) expect(selected.map((c) => c.id)).toContain("1") @@ -334,7 +319,7 @@ describe("SM-2 Algorithm", () => { nextReviewDate: new Date("2025-01-14T10:00:00Z"), incorrectCount: 0, consecutiveCorrect: 1, - manualDifficulty: Difficulty.NORMAL, + manualDifficulty: Difficulty.MEDIUM, }, { id: "2", @@ -345,7 +330,7 @@ describe("SM-2 Algorithm", () => { }, ] - const selected = selectCardsForSession(cards, 10, now) + const selected = selectCardsForSession(cards, 10, now, false) expect(selected.length).toBe(1) expect(selected[0].id).toBe("1") @@ -372,11 +357,11 @@ describe("SM-2 Algorithm", () => { nextReviewDate: new Date("2025-01-14T10:00:00Z"), incorrectCount: 0, consecutiveCorrect: 1, - manualDifficulty: Difficulty.NORMAL, + manualDifficulty: Difficulty.MEDIUM, }, ] - const selected = selectCardsForSession(cards, 10, now) + const selected = selectCardsForSession(cards, 10, now, false) expect(selected[0].id).toBe("hard") expect(selected[1].id).toBe("normal") @@ -390,25 +375,25 @@ describe("SM-2 Algorithm", () => { nextReviewDate: new Date("2025-01-14T10:00:00Z"), incorrectCount: 0, consecutiveCorrect: 1, - manualDifficulty: Difficulty.NORMAL, + manualDifficulty: Difficulty.MEDIUM, }, { id: "2", nextReviewDate: new Date("2025-01-12T10:00:00Z"), incorrectCount: 0, consecutiveCorrect: 1, - manualDifficulty: Difficulty.NORMAL, + manualDifficulty: Difficulty.MEDIUM, }, { id: "3", nextReviewDate: new Date("2025-01-13T10:00:00Z"), incorrectCount: 0, consecutiveCorrect: 1, - manualDifficulty: Difficulty.NORMAL, + manualDifficulty: Difficulty.MEDIUM, }, ] - const selected = selectCardsForSession(cards, 10, now) + const selected = selectCardsForSession(cards, 10, now, false) expect(selected[0].id).toBe("2") // Oldest expect(selected[1].id).toBe("3") @@ -422,25 +407,25 @@ describe("SM-2 Algorithm", () => { nextReviewDate: new Date("2025-01-14T10:00:00Z"), incorrectCount: 1, consecutiveCorrect: 1, - manualDifficulty: Difficulty.NORMAL, + manualDifficulty: Difficulty.MEDIUM, }, { id: "2", nextReviewDate: new Date("2025-01-14T10:00:00Z"), incorrectCount: 3, consecutiveCorrect: 1, - manualDifficulty: Difficulty.NORMAL, + manualDifficulty: Difficulty.MEDIUM, }, { id: "3", nextReviewDate: new Date("2025-01-14T10:00:00Z"), incorrectCount: 2, consecutiveCorrect: 1, - manualDifficulty: Difficulty.NORMAL, + manualDifficulty: Difficulty.MEDIUM, }, ] - const selected = selectCardsForSession(cards, 10, now) + const selected = selectCardsForSession(cards, 10, now, false) expect(selected[0].id).toBe("2") // incorrectCount: 3 expect(selected[1].id).toBe("3") // incorrectCount: 2 @@ -454,25 +439,25 @@ describe("SM-2 Algorithm", () => { nextReviewDate: new Date("2025-01-14T10:00:00Z"), incorrectCount: 0, consecutiveCorrect: 3, - manualDifficulty: Difficulty.NORMAL, + manualDifficulty: Difficulty.MEDIUM, }, { id: "2", nextReviewDate: new Date("2025-01-14T10:00:00Z"), incorrectCount: 0, consecutiveCorrect: 1, - manualDifficulty: Difficulty.NORMAL, + manualDifficulty: Difficulty.MEDIUM, }, { id: "3", nextReviewDate: new Date("2025-01-14T10:00:00Z"), incorrectCount: 0, consecutiveCorrect: 2, - manualDifficulty: Difficulty.NORMAL, + manualDifficulty: Difficulty.MEDIUM, }, ] - const selected = selectCardsForSession(cards, 10, now) + const selected = selectCardsForSession(cards, 10, now, false) expect(selected[0].id).toBe("2") // consecutiveCorrect: 1 expect(selected[1].id).toBe("3") // consecutiveCorrect: 2 @@ -486,18 +471,18 @@ describe("SM-2 Algorithm", () => { nextReviewDate: null, // New card incorrectCount: 0, consecutiveCorrect: 0, - manualDifficulty: Difficulty.NORMAL, + manualDifficulty: Difficulty.MEDIUM, }, { id: "2", nextReviewDate: new Date("2025-01-14T10:00:00Z"), incorrectCount: 0, consecutiveCorrect: 1, - manualDifficulty: Difficulty.NORMAL, + manualDifficulty: Difficulty.MEDIUM, }, ] - const selected = selectCardsForSession(cards, 10, now) + const selected = selectCardsForSession(cards, 10, now, false) expect(selected.length).toBe(2) expect(selected[0].id).toBe("1") // New cards first @@ -510,10 +495,10 @@ describe("SM-2 Algorithm", () => { nextReviewDate: new Date("2025-01-14T10:00:00Z"), incorrectCount: 0, consecutiveCorrect: 1, - manualDifficulty: Difficulty.NORMAL, + manualDifficulty: Difficulty.MEDIUM, })) - const selected = selectCardsForSession(cards, 5, now) + const selected = selectCardsForSession(cards, 5, now, false) expect(selected.length).toBe(5) }) @@ -546,11 +531,11 @@ describe("SM-2 Algorithm", () => { nextReviewDate: new Date("2025-01-12T10:00:00Z"), incorrectCount: 5, consecutiveCorrect: 1, - manualDifficulty: Difficulty.NORMAL, + manualDifficulty: Difficulty.MEDIUM, }, ] - const selected = selectCardsForSession(cards, 10, now) + const selected = selectCardsForSession(cards, 10, now, false) // Expected order: // 1. HARD difficulty has priority @@ -564,7 +549,7 @@ describe("SM-2 Algorithm", () => { }) it("should handle empty card list", () => { - const selected = selectCardsForSession([], 10, now) + const selected = selectCardsForSession([], 10, now, false) expect(selected.length).toBe(0) }) @@ -586,7 +571,7 @@ describe("SM-2 Algorithm", () => { }, ] - const selected = selectCardsForSession(cards, 10, now) + const selected = selectCardsForSession(cards, 10, now, false) expect(selected.length).toBe(0) }) @@ -597,18 +582,18 @@ describe("SM-2 Algorithm", () => { nextReviewDate: new Date("2025-01-16T10:00:00Z"), incorrectCount: 0, consecutiveCorrect: 1, - manualDifficulty: Difficulty.NORMAL, + manualDifficulty: Difficulty.MEDIUM, }, { id: "2", nextReviewDate: new Date("2025-01-17T10:00:00Z"), incorrectCount: 0, consecutiveCorrect: 1, - manualDifficulty: Difficulty.NORMAL, + manualDifficulty: Difficulty.MEDIUM, }, ] - const selected = selectCardsForSession(cards, 10, now) + const selected = selectCardsForSession(cards, 10, now, false) expect(selected.length).toBe(0) }) }) diff --git a/src/lib/learning/sm2.ts b/src/lib/learning/sm2.ts index 0d1470e..28d7952 100644 --- a/src/lib/learning/sm2.ts +++ b/src/lib/learning/sm2.ts @@ -7,6 +7,8 @@ * Reference: https://www.supermemo.com/en/archives1990-2015/english/ol/sm2 */ +import { Difficulty } from "@prisma/client" + /** * Progress data for a single card */ @@ -15,20 +17,17 @@ export interface CardProgress { interval: number // in days consecutiveCorrect: number incorrectCount: number - lastReviewDate: Date | null nextReviewDate: Date | null } /** - * Initial values for a new card + * Initial values for a new card (without nextReviewDate as it's set on creation) */ -export const INITIAL_PROGRESS: CardProgress = { +export const INITIAL_PROGRESS = { easeFactor: 2.5, interval: 1, consecutiveCorrect: 0, incorrectCount: 0, - lastReviewDate: null, - nextReviewDate: null, } /** @@ -122,13 +121,20 @@ export function calculateIncorrectAnswer( } /** - * Difficulty enum matching the Prisma schema + * Re-export Difficulty enum from Prisma for convenience */ -export enum Difficulty { - EASY = "EASY", - NORMAL = "NORMAL", - HARD = "HARD", - SUSPENDED = "SUSPENDED", +export { Difficulty } + +/** + * Shuffle array using Fisher-Yates algorithm + */ +function shuffleArray(array: T[]): T[] { + const shuffled = [...array] + for (let i = shuffled.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)) + ;[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]] + } + return shuffled } /** @@ -160,7 +166,8 @@ export interface SelectableCard { export function selectCardsForSession( cards: SelectableCard[], cardsPerSession: number, - now: Date = new Date() + now: Date = new Date(), + shuffle: boolean = true ): SelectableCard[] { // Filter out suspended cards const activeCards = cards.filter( @@ -177,7 +184,7 @@ export function selectCardsForSession( // Priority by difficulty: HARD > NORMAL > EASY const difficultyPriority = { [Difficulty.HARD]: 0, - [Difficulty.NORMAL]: 1, + [Difficulty.MEDIUM]: 1, [Difficulty.EASY]: 2, [Difficulty.SUSPENDED]: 3, // Should not appear due to filter } @@ -203,11 +210,20 @@ export function selectCardsForSession( } // Sort by consecutiveCorrect ASC (fewer correct = higher priority) - return a.consecutiveCorrect - b.consecutiveCorrect + if (a.consecutiveCorrect !== b.consecutiveCorrect) { + return a.consecutiveCorrect - b.consecutiveCorrect + } + + // Random tiebreaker for cards with equal priority + return Math.random() - 0.5 }) // Limit to cardsPerSession - return sortedCards.slice(0, cardsPerSession) + const selectedCards = sortedCards.slice(0, cardsPerSession) + + // Final shuffle: randomize the order of selected cards for presentation + // This prevents always showing hard/struggling cards first + return shuffle ? shuffleArray(selectedCards) : selectedCards } /**