learning randomization
This commit is contained in:
813
src/actions/learning.ts
Normal file
813
src/actions/learning.ts
Normal file
@@ -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<T = void> = {
|
||||||
|
success: boolean
|
||||||
|
data?: T
|
||||||
|
message?: string
|
||||||
|
errors?: Record<string, string[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 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<ActionResult<{
|
||||||
|
sessionId: string
|
||||||
|
cards: Array<{
|
||||||
|
hanziId: string
|
||||||
|
simplified: string
|
||||||
|
options: string[]
|
||||||
|
correctPinyin: string
|
||||||
|
meaning: string
|
||||||
|
}>
|
||||||
|
}>> {
|
||||||
|
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<ActionResult<{
|
||||||
|
easeFactor: number
|
||||||
|
interval: number
|
||||||
|
consecutiveCorrect: number
|
||||||
|
nextReviewDate: Date
|
||||||
|
}>> {
|
||||||
|
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<ActionResult<{
|
||||||
|
totalCards: number
|
||||||
|
correctCount: number
|
||||||
|
incorrectCount: number
|
||||||
|
accuracyPercent: number
|
||||||
|
durationMinutes: number
|
||||||
|
}>> {
|
||||||
|
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<ActionResult<{
|
||||||
|
dueNow: number
|
||||||
|
dueToday: number
|
||||||
|
dueThisWeek: number
|
||||||
|
}>> {
|
||||||
|
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<ActionResult> {
|
||||||
|
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<ActionResult> {
|
||||||
|
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",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -313,32 +313,40 @@ export default function CollectionDetailPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{canModify && (
|
<div className="flex gap-2">
|
||||||
<div className="flex gap-2">
|
<Link
|
||||||
<button
|
href={`/learn/${collection.id}`}
|
||||||
onClick={() => setShowAddModal(true)}
|
className="bg-green-600 text-white px-4 py-2 rounded-lg hover:bg-green-700 inline-block"
|
||||||
disabled={actionLoading}
|
>
|
||||||
className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 disabled:bg-gray-400"
|
Start Learning
|
||||||
>
|
</Link>
|
||||||
Add Hanzi
|
{canModify && (
|
||||||
</button>
|
<>
|
||||||
{collection.hanziCount > 0 && (
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setSelectionMode(!selectionMode)}
|
onClick={() => setShowAddModal(true)}
|
||||||
className="bg-gray-600 text-white px-4 py-2 rounded-lg hover:bg-gray-700"
|
disabled={actionLoading}
|
||||||
|
className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 disabled:bg-gray-400"
|
||||||
>
|
>
|
||||||
{selectionMode ? "Cancel" : "Select"}
|
Add Hanzi
|
||||||
</button>
|
</button>
|
||||||
)}
|
{collection.hanziCount > 0 && (
|
||||||
<button
|
<button
|
||||||
onClick={handleDeleteCollection}
|
onClick={() => setSelectionMode(!selectionMode)}
|
||||||
disabled={actionLoading}
|
className="bg-gray-600 text-white px-4 py-2 rounded-lg hover:bg-gray-700"
|
||||||
className="bg-red-600 text-white px-4 py-2 rounded-lg hover:bg-red-700 disabled:bg-gray-400"
|
>
|
||||||
>
|
{selectionMode ? "Cancel" : "Select"}
|
||||||
Delete
|
</button>
|
||||||
</button>
|
)}
|
||||||
</div>
|
<button
|
||||||
)}
|
onClick={handleDeleteCollection}
|
||||||
|
disabled={actionLoading}
|
||||||
|
className="bg-red-600 text-white px-4 py-2 rounded-lg hover:bg-red-700 disabled:bg-gray-400"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selectionMode && selectedHanziIds.size > 0 && (
|
{selectionMode && selectedHanziIds.size > 0 && (
|
||||||
|
|||||||
@@ -132,6 +132,17 @@ export default async function DashboardPage() {
|
|||||||
Quick Actions
|
Quick Actions
|
||||||
</h3>
|
</h3>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<Link
|
||||||
|
href="/learn/all"
|
||||||
|
className="bg-white dark:bg-gray-800 p-4 rounded-lg hover:shadow-md transition-shadow border-2 border-blue-500"
|
||||||
|
>
|
||||||
|
<h4 className="font-semibold text-gray-900 dark:text-white mb-1">
|
||||||
|
Start Learning
|
||||||
|
</h4>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Begin a learning session with all cards
|
||||||
|
</p>
|
||||||
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href="/collections"
|
href="/collections"
|
||||||
className="bg-white dark:bg-gray-800 p-4 rounded-lg hover:shadow-md transition-shadow"
|
className="bg-white dark:bg-gray-800 p-4 rounded-lg hover:shadow-md transition-shadow"
|
||||||
@@ -143,17 +154,6 @@ export default async function DashboardPage() {
|
|||||||
View and manage your hanzi collections
|
View and manage your hanzi collections
|
||||||
</p>
|
</p>
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
|
||||||
href="/collections/new"
|
|
||||||
className="bg-white dark:bg-gray-800 p-4 rounded-lg hover:shadow-md transition-shadow"
|
|
||||||
>
|
|
||||||
<h4 className="font-semibold text-gray-900 dark:text-white mb-1">
|
|
||||||
Create Collection
|
|
||||||
</h4>
|
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
|
||||||
Start a new hanzi collection
|
|
||||||
</p>
|
|
||||||
</Link>
|
|
||||||
<Link
|
<Link
|
||||||
href="/hanzi"
|
href="/hanzi"
|
||||||
className="bg-white dark:bg-gray-800 p-4 rounded-lg hover:shadow-md transition-shadow"
|
className="bg-white dark:bg-gray-800 p-4 rounded-lg hover:shadow-md transition-shadow"
|
||||||
@@ -166,9 +166,6 @@ export default async function DashboardPage() {
|
|||||||
</p>
|
</p>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-blue-800 dark:text-blue-400 mt-4">
|
|
||||||
More features coming soon: Learning sessions, progress tracking, and more!
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
347
src/app/(app)/learn/[collectionId]/page.tsx
Normal file
347
src/app/(app)/learn/[collectionId]/page.tsx
Normal file
@@ -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<string | null>(null)
|
||||||
|
const [cards, setCards] = useState<Card[]>([])
|
||||||
|
const [currentIndex, setCurrentIndex] = useState(0)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Card state
|
||||||
|
const [selectedOption, setSelectedOption] = useState<number | null>(null)
|
||||||
|
const [showFeedback, setShowFeedback] = useState(false)
|
||||||
|
const [isCorrect, setIsCorrect] = useState(false)
|
||||||
|
const [answerStartTime, setAnswerStartTime] = useState<number>(Date.now())
|
||||||
|
|
||||||
|
// Summary state
|
||||||
|
const [showSummary, setShowSummary] = useState(false)
|
||||||
|
const [summary, setSummary] = useState<SessionSummary | null>(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 (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
|
||||||
|
<p className="mt-4 text-gray-600 dark:text-gray-400">Loading cards...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error state
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-8 max-w-md">
|
||||||
|
<div className="text-red-600 dark:text-red-400 text-center mb-4">
|
||||||
|
<svg className="w-16 h-16 mx-auto" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-bold text-center mb-4">No Cards Available</h2>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 text-center mb-6">{error}</p>
|
||||||
|
<button
|
||||||
|
onClick={() => router.push("/collections")}
|
||||||
|
className="w-full py-2 px-4 bg-blue-600 hover:bg-blue-700 text-white rounded-md font-medium"
|
||||||
|
>
|
||||||
|
Go to Collections
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Summary screen
|
||||||
|
if (showSummary && summary) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 p-4">
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-8 max-w-md w-full">
|
||||||
|
<div className="text-center mb-6">
|
||||||
|
<div className="text-6xl mb-4">🎉</div>
|
||||||
|
<h2 className="text-3xl font-bold mb-2">Session Complete!</h2>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400">Great work!</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4 mb-6">
|
||||||
|
<div className="flex justify-between items-center p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||||
|
<span className="text-gray-600 dark:text-gray-400">Total Cards</span>
|
||||||
|
<span className="text-2xl font-bold">{summary.totalCards}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-between items-center p-4 bg-green-50 dark:bg-green-900/20 rounded-lg">
|
||||||
|
<span className="text-green-600 dark:text-green-400">Correct</span>
|
||||||
|
<span className="text-2xl font-bold text-green-600 dark:text-green-400">{summary.correctCount}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-between items-center p-4 bg-red-50 dark:bg-red-900/20 rounded-lg">
|
||||||
|
<span className="text-red-600 dark:text-red-400">Incorrect</span>
|
||||||
|
<span className="text-2xl font-bold text-red-600 dark:text-red-400">{summary.incorrectCount}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-between items-center p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
|
||||||
|
<span className="text-blue-600 dark:text-blue-400">Accuracy</span>
|
||||||
|
<span className="text-2xl font-bold text-blue-600 dark:text-blue-400">{summary.accuracyPercent}%</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-between items-center p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||||
|
<span className="text-gray-600 dark:text-gray-400">Duration</span>
|
||||||
|
<span className="text-2xl font-bold">{summary.durationMinutes} min</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<button
|
||||||
|
onClick={() => window.location.reload()}
|
||||||
|
className="w-full py-3 px-4 bg-blue-600 hover:bg-blue-700 text-white rounded-md font-medium"
|
||||||
|
>
|
||||||
|
Start New Session
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => router.push("/dashboard")}
|
||||||
|
className="w-full py-3 px-4 bg-gray-200 hover:bg-gray-300 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-900 dark:text-gray-100 rounded-md font-medium"
|
||||||
|
>
|
||||||
|
Back to Dashboard
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Learning card screen
|
||||||
|
if (!currentCard) {
|
||||||
|
return <div>No cards available</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||||
|
{/* Progress bar */}
|
||||||
|
<div className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<div className="max-w-4xl mx-auto px-4 py-4">
|
||||||
|
<div className="flex justify-between items-center mb-2">
|
||||||
|
<span className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Card {currentIndex + 1} of {cards.length}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-semibold text-blue-600 dark:text-blue-400">
|
||||||
|
{Math.round(progress)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
||||||
|
<div
|
||||||
|
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
|
||||||
|
style={{ width: `${progress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main card area */}
|
||||||
|
<div className="max-w-4xl mx-auto px-4 py-12">
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-8 relative overflow-hidden">
|
||||||
|
{/* Feedback overlay */}
|
||||||
|
{showFeedback && (
|
||||||
|
<div
|
||||||
|
className={`absolute inset-0 flex items-center justify-center z-10 ${
|
||||||
|
isCorrect
|
||||||
|
? "bg-green-500/10 dark:bg-green-500/20"
|
||||||
|
: "bg-red-500/10 dark:bg-red-500/20"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className={`text-8xl mb-4 ${isCorrect ? "text-green-600" : "text-red-600"}`}>
|
||||||
|
{isCorrect ? "✓" : "✗"}
|
||||||
|
</div>
|
||||||
|
<p className={`text-2xl font-bold mb-2 ${isCorrect ? "text-green-600" : "text-red-600"}`}>
|
||||||
|
{isCorrect ? "Correct!" : "Incorrect"}
|
||||||
|
</p>
|
||||||
|
{!isCorrect && (
|
||||||
|
<p className="text-lg text-gray-700 dark:text-gray-300">
|
||||||
|
Correct answer: <span className="font-bold">{currentCard.correctPinyin}</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{currentCard.meaning && (
|
||||||
|
<p className="text-lg text-gray-600 dark:text-gray-400 mt-3 italic">
|
||||||
|
{currentCard.meaning}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={handleContinue}
|
||||||
|
className="mt-6 py-2 px-6 bg-blue-600 hover:bg-blue-700 text-white rounded-md font-medium"
|
||||||
|
>
|
||||||
|
Continue (Space)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Hanzi display */}
|
||||||
|
<div className="text-center mb-12">
|
||||||
|
<div className="text-9xl font-bold mb-4">{currentCard.simplified}</div>
|
||||||
|
<p className="text-gray-500 dark:text-gray-400">Select the correct pinyin</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Answer options in 2x2 grid */}
|
||||||
|
<div className="grid grid-cols-2 gap-4 max-w-2xl mx-auto">
|
||||||
|
{currentCard.options.map((option, index) => (
|
||||||
|
<button
|
||||||
|
key={index}
|
||||||
|
onClick={() => {
|
||||||
|
handleSelectAnswer(index)
|
||||||
|
// Auto-submit after a brief delay
|
||||||
|
setTimeout(() => handleSubmitAnswer(), 100)
|
||||||
|
}}
|
||||||
|
disabled={showFeedback}
|
||||||
|
className={`p-6 text-2xl font-medium rounded-lg transition-all ${
|
||||||
|
selectedOption === index && !showFeedback
|
||||||
|
? "bg-blue-600 text-white scale-105"
|
||||||
|
: showFeedback && option === currentCard.correctPinyin
|
||||||
|
? "bg-green-600 text-white"
|
||||||
|
: showFeedback && selectedOption === index
|
||||||
|
? "bg-red-600 text-white"
|
||||||
|
: "bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 text-gray-900 dark:text-gray-100"
|
||||||
|
} ${showFeedback ? "cursor-not-allowed" : "cursor-pointer"}`}
|
||||||
|
>
|
||||||
|
<div className="text-sm text-gray-500 dark:text-gray-400 mb-1">{index + 1}</div>
|
||||||
|
{option}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Keyboard shortcuts hint */}
|
||||||
|
<div className="mt-8 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
<p>Press 1-4 to select • Space to continue</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -26,8 +26,6 @@ describe("SM-2 Algorithm", () => {
|
|||||||
expect(INITIAL_PROGRESS.interval).toBe(1)
|
expect(INITIAL_PROGRESS.interval).toBe(1)
|
||||||
expect(INITIAL_PROGRESS.consecutiveCorrect).toBe(0)
|
expect(INITIAL_PROGRESS.consecutiveCorrect).toBe(0)
|
||||||
expect(INITIAL_PROGRESS.incorrectCount).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,
|
interval: 1,
|
||||||
consecutiveCorrect: 1,
|
consecutiveCorrect: 1,
|
||||||
incorrectCount: 0,
|
incorrectCount: 0,
|
||||||
lastReviewDate: new Date("2025-01-01"),
|
|
||||||
nextReviewDate: new Date("2025-01-02"),
|
nextReviewDate: new Date("2025-01-02"),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,7 +66,6 @@ describe("SM-2 Algorithm", () => {
|
|||||||
interval: 6,
|
interval: 6,
|
||||||
consecutiveCorrect: 2,
|
consecutiveCorrect: 2,
|
||||||
incorrectCount: 0,
|
incorrectCount: 0,
|
||||||
lastReviewDate: new Date("2025-01-02"),
|
|
||||||
nextReviewDate: new Date("2025-01-08"),
|
nextReviewDate: new Date("2025-01-08"),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,7 +84,6 @@ describe("SM-2 Algorithm", () => {
|
|||||||
interval: 50,
|
interval: 50,
|
||||||
consecutiveCorrect: 5,
|
consecutiveCorrect: 5,
|
||||||
incorrectCount: 2,
|
incorrectCount: 2,
|
||||||
lastReviewDate: new Date("2025-01-01"),
|
|
||||||
nextReviewDate: new Date("2025-02-20"),
|
nextReviewDate: new Date("2025-02-20"),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,7 +125,6 @@ describe("SM-2 Algorithm", () => {
|
|||||||
interval: 365,
|
interval: 365,
|
||||||
consecutiveCorrect: 10,
|
consecutiveCorrect: 10,
|
||||||
incorrectCount: 0,
|
incorrectCount: 0,
|
||||||
lastReviewDate: new Date("2025-01-01"),
|
|
||||||
nextReviewDate: new Date("2026-01-01"),
|
nextReviewDate: new Date("2026-01-01"),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,7 +143,6 @@ describe("SM-2 Algorithm", () => {
|
|||||||
interval: 16,
|
interval: 16,
|
||||||
consecutiveCorrect: 3,
|
consecutiveCorrect: 3,
|
||||||
incorrectCount: 0,
|
incorrectCount: 0,
|
||||||
lastReviewDate: new Date("2025-01-01"),
|
|
||||||
nextReviewDate: new Date("2025-01-17"),
|
nextReviewDate: new Date("2025-01-17"),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,7 +158,6 @@ describe("SM-2 Algorithm", () => {
|
|||||||
interval: 16,
|
interval: 16,
|
||||||
consecutiveCorrect: 5,
|
consecutiveCorrect: 5,
|
||||||
incorrectCount: 1,
|
incorrectCount: 1,
|
||||||
lastReviewDate: new Date("2025-01-01"),
|
|
||||||
nextReviewDate: new Date("2025-01-17"),
|
nextReviewDate: new Date("2025-01-17"),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,7 +172,6 @@ describe("SM-2 Algorithm", () => {
|
|||||||
interval: 6,
|
interval: 6,
|
||||||
consecutiveCorrect: 2,
|
consecutiveCorrect: 2,
|
||||||
incorrectCount: 0,
|
incorrectCount: 0,
|
||||||
lastReviewDate: new Date("2025-01-01"),
|
|
||||||
nextReviewDate: new Date("2025-01-07"),
|
nextReviewDate: new Date("2025-01-07"),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,7 +186,6 @@ describe("SM-2 Algorithm", () => {
|
|||||||
interval: 1,
|
interval: 1,
|
||||||
consecutiveCorrect: 0,
|
consecutiveCorrect: 0,
|
||||||
incorrectCount: 5,
|
incorrectCount: 5,
|
||||||
lastReviewDate: new Date("2025-01-01"),
|
|
||||||
nextReviewDate: new Date("2025-01-02"),
|
nextReviewDate: new Date("2025-01-02"),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,7 +201,6 @@ describe("SM-2 Algorithm", () => {
|
|||||||
interval: 6,
|
interval: 6,
|
||||||
consecutiveCorrect: 2,
|
consecutiveCorrect: 2,
|
||||||
incorrectCount: 0,
|
incorrectCount: 0,
|
||||||
lastReviewDate: new Date("2025-01-01"),
|
|
||||||
nextReviewDate: new Date("2025-01-07"),
|
nextReviewDate: new Date("2025-01-07"),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,7 +215,6 @@ describe("SM-2 Algorithm", () => {
|
|||||||
interval: 6,
|
interval: 6,
|
||||||
consecutiveCorrect: 2,
|
consecutiveCorrect: 2,
|
||||||
incorrectCount: 0,
|
incorrectCount: 0,
|
||||||
lastReviewDate: new Date("2025-01-01"),
|
|
||||||
nextReviewDate: new Date("2025-01-07"),
|
nextReviewDate: new Date("2025-01-07"),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -254,7 +242,6 @@ describe("SM-2 Algorithm", () => {
|
|||||||
interval: 16,
|
interval: 16,
|
||||||
consecutiveCorrect: 3,
|
consecutiveCorrect: 3,
|
||||||
incorrectCount: 0,
|
incorrectCount: 0,
|
||||||
lastReviewDate: new Date("2025-01-01"),
|
|
||||||
nextReviewDate: new Date("2025-01-17"),
|
nextReviewDate: new Date("2025-01-17"),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -269,7 +256,6 @@ describe("SM-2 Algorithm", () => {
|
|||||||
interval: result.interval,
|
interval: result.interval,
|
||||||
consecutiveCorrect: result.consecutiveCorrect,
|
consecutiveCorrect: result.consecutiveCorrect,
|
||||||
incorrectCount: result.incorrectCount,
|
incorrectCount: result.incorrectCount,
|
||||||
lastReviewDate: new Date("2025-01-17"),
|
|
||||||
nextReviewDate: result.nextReviewDate,
|
nextReviewDate: result.nextReviewDate,
|
||||||
}
|
}
|
||||||
result = calculateIncorrectAnswer(progress, new Date("2025-01-18"))
|
result = calculateIncorrectAnswer(progress, new Date("2025-01-18"))
|
||||||
@@ -282,7 +268,6 @@ describe("SM-2 Algorithm", () => {
|
|||||||
interval: result.interval,
|
interval: result.interval,
|
||||||
consecutiveCorrect: result.consecutiveCorrect,
|
consecutiveCorrect: result.consecutiveCorrect,
|
||||||
incorrectCount: result.incorrectCount,
|
incorrectCount: result.incorrectCount,
|
||||||
lastReviewDate: new Date("2025-01-18"),
|
|
||||||
nextReviewDate: result.nextReviewDate,
|
nextReviewDate: result.nextReviewDate,
|
||||||
}
|
}
|
||||||
result = calculateIncorrectAnswer(progress, new Date("2025-01-19"))
|
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
|
nextReviewDate: new Date("2025-01-14T10:00:00Z"), // Due
|
||||||
incorrectCount: 0,
|
incorrectCount: 0,
|
||||||
consecutiveCorrect: 1,
|
consecutiveCorrect: 1,
|
||||||
manualDifficulty: Difficulty.NORMAL,
|
manualDifficulty: Difficulty.MEDIUM,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "2",
|
id: "2",
|
||||||
nextReviewDate: new Date("2025-01-16T10:00:00Z"), // Not due
|
nextReviewDate: new Date("2025-01-16T10:00:00Z"), // Not due
|
||||||
incorrectCount: 0,
|
incorrectCount: 0,
|
||||||
consecutiveCorrect: 1,
|
consecutiveCorrect: 1,
|
||||||
manualDifficulty: Difficulty.NORMAL,
|
manualDifficulty: Difficulty.MEDIUM,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "3",
|
id: "3",
|
||||||
nextReviewDate: new Date("2025-01-13T10:00:00Z"), // Due
|
nextReviewDate: new Date("2025-01-13T10:00:00Z"), // Due
|
||||||
incorrectCount: 0,
|
incorrectCount: 0,
|
||||||
consecutiveCorrect: 1,
|
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.length).toBe(2)
|
||||||
expect(selected.map((c) => c.id)).toContain("1")
|
expect(selected.map((c) => c.id)).toContain("1")
|
||||||
@@ -334,7 +319,7 @@ describe("SM-2 Algorithm", () => {
|
|||||||
nextReviewDate: new Date("2025-01-14T10:00:00Z"),
|
nextReviewDate: new Date("2025-01-14T10:00:00Z"),
|
||||||
incorrectCount: 0,
|
incorrectCount: 0,
|
||||||
consecutiveCorrect: 1,
|
consecutiveCorrect: 1,
|
||||||
manualDifficulty: Difficulty.NORMAL,
|
manualDifficulty: Difficulty.MEDIUM,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "2",
|
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.length).toBe(1)
|
||||||
expect(selected[0].id).toBe("1")
|
expect(selected[0].id).toBe("1")
|
||||||
@@ -372,11 +357,11 @@ describe("SM-2 Algorithm", () => {
|
|||||||
nextReviewDate: new Date("2025-01-14T10:00:00Z"),
|
nextReviewDate: new Date("2025-01-14T10:00:00Z"),
|
||||||
incorrectCount: 0,
|
incorrectCount: 0,
|
||||||
consecutiveCorrect: 1,
|
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[0].id).toBe("hard")
|
||||||
expect(selected[1].id).toBe("normal")
|
expect(selected[1].id).toBe("normal")
|
||||||
@@ -390,25 +375,25 @@ describe("SM-2 Algorithm", () => {
|
|||||||
nextReviewDate: new Date("2025-01-14T10:00:00Z"),
|
nextReviewDate: new Date("2025-01-14T10:00:00Z"),
|
||||||
incorrectCount: 0,
|
incorrectCount: 0,
|
||||||
consecutiveCorrect: 1,
|
consecutiveCorrect: 1,
|
||||||
manualDifficulty: Difficulty.NORMAL,
|
manualDifficulty: Difficulty.MEDIUM,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "2",
|
id: "2",
|
||||||
nextReviewDate: new Date("2025-01-12T10:00:00Z"),
|
nextReviewDate: new Date("2025-01-12T10:00:00Z"),
|
||||||
incorrectCount: 0,
|
incorrectCount: 0,
|
||||||
consecutiveCorrect: 1,
|
consecutiveCorrect: 1,
|
||||||
manualDifficulty: Difficulty.NORMAL,
|
manualDifficulty: Difficulty.MEDIUM,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "3",
|
id: "3",
|
||||||
nextReviewDate: new Date("2025-01-13T10:00:00Z"),
|
nextReviewDate: new Date("2025-01-13T10:00:00Z"),
|
||||||
incorrectCount: 0,
|
incorrectCount: 0,
|
||||||
consecutiveCorrect: 1,
|
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[0].id).toBe("2") // Oldest
|
||||||
expect(selected[1].id).toBe("3")
|
expect(selected[1].id).toBe("3")
|
||||||
@@ -422,25 +407,25 @@ describe("SM-2 Algorithm", () => {
|
|||||||
nextReviewDate: new Date("2025-01-14T10:00:00Z"),
|
nextReviewDate: new Date("2025-01-14T10:00:00Z"),
|
||||||
incorrectCount: 1,
|
incorrectCount: 1,
|
||||||
consecutiveCorrect: 1,
|
consecutiveCorrect: 1,
|
||||||
manualDifficulty: Difficulty.NORMAL,
|
manualDifficulty: Difficulty.MEDIUM,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "2",
|
id: "2",
|
||||||
nextReviewDate: new Date("2025-01-14T10:00:00Z"),
|
nextReviewDate: new Date("2025-01-14T10:00:00Z"),
|
||||||
incorrectCount: 3,
|
incorrectCount: 3,
|
||||||
consecutiveCorrect: 1,
|
consecutiveCorrect: 1,
|
||||||
manualDifficulty: Difficulty.NORMAL,
|
manualDifficulty: Difficulty.MEDIUM,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "3",
|
id: "3",
|
||||||
nextReviewDate: new Date("2025-01-14T10:00:00Z"),
|
nextReviewDate: new Date("2025-01-14T10:00:00Z"),
|
||||||
incorrectCount: 2,
|
incorrectCount: 2,
|
||||||
consecutiveCorrect: 1,
|
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[0].id).toBe("2") // incorrectCount: 3
|
||||||
expect(selected[1].id).toBe("3") // incorrectCount: 2
|
expect(selected[1].id).toBe("3") // incorrectCount: 2
|
||||||
@@ -454,25 +439,25 @@ describe("SM-2 Algorithm", () => {
|
|||||||
nextReviewDate: new Date("2025-01-14T10:00:00Z"),
|
nextReviewDate: new Date("2025-01-14T10:00:00Z"),
|
||||||
incorrectCount: 0,
|
incorrectCount: 0,
|
||||||
consecutiveCorrect: 3,
|
consecutiveCorrect: 3,
|
||||||
manualDifficulty: Difficulty.NORMAL,
|
manualDifficulty: Difficulty.MEDIUM,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "2",
|
id: "2",
|
||||||
nextReviewDate: new Date("2025-01-14T10:00:00Z"),
|
nextReviewDate: new Date("2025-01-14T10:00:00Z"),
|
||||||
incorrectCount: 0,
|
incorrectCount: 0,
|
||||||
consecutiveCorrect: 1,
|
consecutiveCorrect: 1,
|
||||||
manualDifficulty: Difficulty.NORMAL,
|
manualDifficulty: Difficulty.MEDIUM,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "3",
|
id: "3",
|
||||||
nextReviewDate: new Date("2025-01-14T10:00:00Z"),
|
nextReviewDate: new Date("2025-01-14T10:00:00Z"),
|
||||||
incorrectCount: 0,
|
incorrectCount: 0,
|
||||||
consecutiveCorrect: 2,
|
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[0].id).toBe("2") // consecutiveCorrect: 1
|
||||||
expect(selected[1].id).toBe("3") // consecutiveCorrect: 2
|
expect(selected[1].id).toBe("3") // consecutiveCorrect: 2
|
||||||
@@ -486,18 +471,18 @@ describe("SM-2 Algorithm", () => {
|
|||||||
nextReviewDate: null, // New card
|
nextReviewDate: null, // New card
|
||||||
incorrectCount: 0,
|
incorrectCount: 0,
|
||||||
consecutiveCorrect: 0,
|
consecutiveCorrect: 0,
|
||||||
manualDifficulty: Difficulty.NORMAL,
|
manualDifficulty: Difficulty.MEDIUM,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "2",
|
id: "2",
|
||||||
nextReviewDate: new Date("2025-01-14T10:00:00Z"),
|
nextReviewDate: new Date("2025-01-14T10:00:00Z"),
|
||||||
incorrectCount: 0,
|
incorrectCount: 0,
|
||||||
consecutiveCorrect: 1,
|
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.length).toBe(2)
|
||||||
expect(selected[0].id).toBe("1") // New cards first
|
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"),
|
nextReviewDate: new Date("2025-01-14T10:00:00Z"),
|
||||||
incorrectCount: 0,
|
incorrectCount: 0,
|
||||||
consecutiveCorrect: 1,
|
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)
|
expect(selected.length).toBe(5)
|
||||||
})
|
})
|
||||||
@@ -546,11 +531,11 @@ describe("SM-2 Algorithm", () => {
|
|||||||
nextReviewDate: new Date("2025-01-12T10:00:00Z"),
|
nextReviewDate: new Date("2025-01-12T10:00:00Z"),
|
||||||
incorrectCount: 5,
|
incorrectCount: 5,
|
||||||
consecutiveCorrect: 1,
|
consecutiveCorrect: 1,
|
||||||
manualDifficulty: Difficulty.NORMAL,
|
manualDifficulty: Difficulty.MEDIUM,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const selected = selectCardsForSession(cards, 10, now)
|
const selected = selectCardsForSession(cards, 10, now, false)
|
||||||
|
|
||||||
// Expected order:
|
// Expected order:
|
||||||
// 1. HARD difficulty has priority
|
// 1. HARD difficulty has priority
|
||||||
@@ -564,7 +549,7 @@ describe("SM-2 Algorithm", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it("should handle empty card list", () => {
|
it("should handle empty card list", () => {
|
||||||
const selected = selectCardsForSession([], 10, now)
|
const selected = selectCardsForSession([], 10, now, false)
|
||||||
expect(selected.length).toBe(0)
|
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)
|
expect(selected.length).toBe(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -597,18 +582,18 @@ describe("SM-2 Algorithm", () => {
|
|||||||
nextReviewDate: new Date("2025-01-16T10:00:00Z"),
|
nextReviewDate: new Date("2025-01-16T10:00:00Z"),
|
||||||
incorrectCount: 0,
|
incorrectCount: 0,
|
||||||
consecutiveCorrect: 1,
|
consecutiveCorrect: 1,
|
||||||
manualDifficulty: Difficulty.NORMAL,
|
manualDifficulty: Difficulty.MEDIUM,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "2",
|
id: "2",
|
||||||
nextReviewDate: new Date("2025-01-17T10:00:00Z"),
|
nextReviewDate: new Date("2025-01-17T10:00:00Z"),
|
||||||
incorrectCount: 0,
|
incorrectCount: 0,
|
||||||
consecutiveCorrect: 1,
|
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)
|
expect(selected.length).toBe(0)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -7,6 +7,8 @@
|
|||||||
* Reference: https://www.supermemo.com/en/archives1990-2015/english/ol/sm2
|
* Reference: https://www.supermemo.com/en/archives1990-2015/english/ol/sm2
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { Difficulty } from "@prisma/client"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Progress data for a single card
|
* Progress data for a single card
|
||||||
*/
|
*/
|
||||||
@@ -15,20 +17,17 @@ export interface CardProgress {
|
|||||||
interval: number // in days
|
interval: number // in days
|
||||||
consecutiveCorrect: number
|
consecutiveCorrect: number
|
||||||
incorrectCount: number
|
incorrectCount: number
|
||||||
lastReviewDate: Date | null
|
|
||||||
nextReviewDate: 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,
|
easeFactor: 2.5,
|
||||||
interval: 1,
|
interval: 1,
|
||||||
consecutiveCorrect: 0,
|
consecutiveCorrect: 0,
|
||||||
incorrectCount: 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 {
|
export { Difficulty }
|
||||||
EASY = "EASY",
|
|
||||||
NORMAL = "NORMAL",
|
/**
|
||||||
HARD = "HARD",
|
* Shuffle array using Fisher-Yates algorithm
|
||||||
SUSPENDED = "SUSPENDED",
|
*/
|
||||||
|
function shuffleArray<T>(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(
|
export function selectCardsForSession(
|
||||||
cards: SelectableCard[],
|
cards: SelectableCard[],
|
||||||
cardsPerSession: number,
|
cardsPerSession: number,
|
||||||
now: Date = new Date()
|
now: Date = new Date(),
|
||||||
|
shuffle: boolean = true
|
||||||
): SelectableCard[] {
|
): SelectableCard[] {
|
||||||
// Filter out suspended cards
|
// Filter out suspended cards
|
||||||
const activeCards = cards.filter(
|
const activeCards = cards.filter(
|
||||||
@@ -177,7 +184,7 @@ export function selectCardsForSession(
|
|||||||
// Priority by difficulty: HARD > NORMAL > EASY
|
// Priority by difficulty: HARD > NORMAL > EASY
|
||||||
const difficultyPriority = {
|
const difficultyPriority = {
|
||||||
[Difficulty.HARD]: 0,
|
[Difficulty.HARD]: 0,
|
||||||
[Difficulty.NORMAL]: 1,
|
[Difficulty.MEDIUM]: 1,
|
||||||
[Difficulty.EASY]: 2,
|
[Difficulty.EASY]: 2,
|
||||||
[Difficulty.SUSPENDED]: 3, // Should not appear due to filter
|
[Difficulty.SUSPENDED]: 3, // Should not appear due to filter
|
||||||
}
|
}
|
||||||
@@ -203,11 +210,20 @@ export function selectCardsForSession(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Sort by consecutiveCorrect ASC (fewer correct = higher priority)
|
// 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
|
// 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
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user