"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, }, } } // First, get all available new hanzi IDs (lightweight query) const availableNewHanzi = await prisma.hanzi.findMany({ where: newHanziWhereClause, select: { id: true }, }) // Randomly select N hanzi IDs from all available // Fisher-Yates shuffle const shuffledIds = [...availableNewHanzi] for (let i = shuffledIds.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)) ;[shuffledIds[i], shuffledIds[j]] = [shuffledIds[j], shuffledIds[i]] } const selectedNewHanziIds = shuffledIds.slice(0, neededCards).map(h => h.id) // Now fetch full details for the randomly selected hanzi const newHanzi = await prisma.hanzi.findMany({ where: { id: { in: selectedNewHanziIds }, }, include: { forms: { where: { isDefault: true }, include: { transcriptions: { where: { type: "pinyin" }, }, }, }, hskLevels: true, }, }) // 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", } } // Shuffle final card set (in case new cards were added after initial shuffle) // Fisher-Yates shuffle for (let i = selectedCards.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)) ;[selectedCards[i], selectedCards[j]] = [selectedCards[j], selectedCards[i]] } // 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 const characterCount = hanzi.simplified.length // Get HSK level for this hanzi const hskLevel = hanzi.hskLevels[0]?.level || "new-1" // Get ALL available hanzi IDs from same HSK level (lightweight query) // This prevents always fetching the same alphabetically-first hanzi const allSameHskIds = await prisma.hanzi.findMany({ where: { id: { not: hanzi.id }, hskLevels: { some: { level: hskLevel, }, }, }, select: { id: true, simplified: true, // Need this for character count filtering }, }) // Filter to same character count const sameCharCountIds = allSameHskIds.filter( h => h.simplified.length === characterCount ) // Shuffle ALL matching IDs const shuffledIds = [...sameCharCountIds] for (let i = shuffledIds.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)) ;[shuffledIds[i], shuffledIds[j]] = [shuffledIds[j], shuffledIds[i]] } // Take first 50 from shuffled (or all if less than 50) const selectedIds = shuffledIds.slice(0, 50).map(h => h.id) // Fetch full details for selected IDs let candidatesForWrongAnswers = await prisma.hanzi.findMany({ where: { id: { in: selectedIds }, }, include: { forms: { where: { isDefault: true }, include: { transcriptions: { where: { type: "pinyin" }, }, }, }, }, }) // If not enough candidates, get more from any HSK level with same character count if (candidatesForWrongAnswers.length < 10) { const additionalAllIds = await prisma.hanzi.findMany({ where: { id: { not: hanzi.id, notIn: candidatesForWrongAnswers.map(h => h.id), }, }, select: { id: true, simplified: true, }, }) const additionalSameCharIds = additionalAllIds.filter( h => h.simplified.length === characterCount ) // Shuffle additional IDs const shuffledAdditional = [...additionalSameCharIds] for (let i = shuffledAdditional.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)) ;[shuffledAdditional[i], shuffledAdditional[j]] = [shuffledAdditional[j], shuffledAdditional[i]] } const additionalSelectedIds = shuffledAdditional.slice(0, 30).map(h => h.id) const additionalHanzi = await prisma.hanzi.findMany({ where: { id: { in: additionalSelectedIds }, }, include: { forms: { where: { isDefault: true }, include: { transcriptions: { where: { type: "pinyin" }, }, }, }, }, }) candidatesForWrongAnswers = [...candidatesForWrongAnswers, ...additionalHanzi] } // Convert to HanziOption format const hanziOptions: HanziOption[] = candidatesForWrongAnswers .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", } } }