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 (
+
+ )
+ }
+
+ // 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
}
/**