Files
memohanzi/src/actions/learning.ts
Stefan Hardegger 9a30d7c4e5 milestone 9
2025-11-25 14:16:25 +01:00

912 lines
23 KiB
TypeScript

"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,
},
}
}
// First, get all available new hanzi IDs (lightweight query)
const availableNewHanzi = await prisma.hanzi.findMany({
where: newHanziWhereClause,
select: { id: true },
})
// Randomly select N hanzi IDs from all available
// Fisher-Yates shuffle
const shuffledIds = [...availableNewHanzi]
for (let i = shuffledIds.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
;[shuffledIds[i], shuffledIds[j]] = [shuffledIds[j], shuffledIds[i]]
}
const selectedNewHanziIds = shuffledIds.slice(0, neededCards).map(h => h.id)
// Now fetch full details for the randomly selected hanzi
const newHanzi = await prisma.hanzi.findMany({
where: {
id: { in: selectedNewHanziIds },
},
include: {
forms: {
where: { isDefault: true },
include: {
transcriptions: {
where: { type: "pinyin" },
},
},
},
hskLevels: true,
},
})
// Create initial progress for new cards
const now = new Date()
for (const hanzi of newHanzi) {
await prisma.userHanziProgress.create({
data: {
userId: session.user.id,
hanziId: hanzi.id,
...INITIAL_PROGRESS,
nextReviewDate: now,
},
})
selectedCards.push({
id: hanzi.id,
nextReviewDate: now,
incorrectCount: 0,
consecutiveCorrect: 0,
manualDifficulty: Difficulty.MEDIUM,
})
}
}
if (selectedCards.length === 0) {
return {
success: false,
message: "No cards available to learn",
}
}
// Shuffle final card set (in case new cards were added after initial shuffle)
// Fisher-Yates shuffle
for (let i = selectedCards.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
;[selectedCards[i], selectedCards[j]] = [selectedCards[j], selectedCards[i]]
}
// Create learning session
const learningSession = await prisma.learningSession.create({
data: {
userId: session.user.id,
collectionId: collectionId || null,
cardsReviewed: selectedCards.length,
},
})
// Get full hanzi details for selected cards
const selectedHanziIds = selectedCards.map(c => c.id)
const hanziDetails = await prisma.hanzi.findMany({
where: {
id: { in: selectedHanziIds },
},
include: {
forms: {
where: { isDefault: true },
include: {
transcriptions: {
where: { type: "pinyin" },
},
meanings: true,
},
},
hskLevels: true,
},
})
// Generate answer options for each card
const cards = []
for (const hanzi of hanziDetails) {
const defaultForm = hanzi.forms[0]
if (!defaultForm) continue
const pinyinTranscription = defaultForm.transcriptions[0]
if (!pinyinTranscription) continue
const correctPinyin = pinyinTranscription.value
const characterCount = hanzi.simplified.length
// Get HSK level for this hanzi
const hskLevel = hanzi.hskLevels[0]?.level || "new-1"
// Get ALL available hanzi IDs from same HSK level (lightweight query)
// This prevents always fetching the same alphabetically-first hanzi
const allSameHskIds = await prisma.hanzi.findMany({
where: {
id: { not: hanzi.id },
hskLevels: {
some: {
level: hskLevel,
},
},
},
select: {
id: true,
simplified: true, // Need this for character count filtering
},
})
// Filter to same character count
const sameCharCountIds = allSameHskIds.filter(
h => h.simplified.length === characterCount
)
// Shuffle ALL matching IDs
const shuffledIds = [...sameCharCountIds]
for (let i = shuffledIds.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
;[shuffledIds[i], shuffledIds[j]] = [shuffledIds[j], shuffledIds[i]]
}
// Take first 50 from shuffled (or all if less than 50)
const selectedIds = shuffledIds.slice(0, 50).map(h => h.id)
// Fetch full details for selected IDs
let candidatesForWrongAnswers = await prisma.hanzi.findMany({
where: {
id: { in: selectedIds },
},
include: {
forms: {
where: { isDefault: true },
include: {
transcriptions: {
where: { type: "pinyin" },
},
},
},
},
})
// If not enough candidates, get more from any HSK level with same character count
if (candidatesForWrongAnswers.length < 10) {
const additionalAllIds = await prisma.hanzi.findMany({
where: {
id: {
not: hanzi.id,
notIn: candidatesForWrongAnswers.map(h => h.id),
},
},
select: {
id: true,
simplified: true,
},
})
const additionalSameCharIds = additionalAllIds.filter(
h => h.simplified.length === characterCount
)
// Shuffle additional IDs
const shuffledAdditional = [...additionalSameCharIds]
for (let i = shuffledAdditional.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
;[shuffledAdditional[i], shuffledAdditional[j]] = [shuffledAdditional[j], shuffledAdditional[i]]
}
const additionalSelectedIds = shuffledAdditional.slice(0, 30).map(h => h.id)
const additionalHanzi = await prisma.hanzi.findMany({
where: {
id: { in: additionalSelectedIds },
},
include: {
forms: {
where: { isDefault: true },
include: {
transcriptions: {
where: { type: "pinyin" },
},
},
},
},
})
candidatesForWrongAnswers = [...candidatesForWrongAnswers, ...additionalHanzi]
}
// Convert to HanziOption format
const hanziOptions: HanziOption[] = candidatesForWrongAnswers
.map(h => {
const form = h.forms[0]
const pinyin = form?.transcriptions[0]
if (!form || !pinyin) return null
return {
id: h.id,
simplified: h.simplified,
pinyin: pinyin.value,
hskLevel: hskLevel,
}
})
.filter((h): h is HanziOption => h !== null)
// Generate wrong answers
let wrongAnswers: string[] = []
try {
wrongAnswers = generateWrongAnswers(
{
id: hanzi.id,
simplified: hanzi.simplified,
pinyin: correctPinyin,
hskLevel,
},
hanziOptions
)
} catch (error) {
// If not enough options, use random ones
wrongAnswers = hanziOptions
.slice(0, 3)
.map(o => o.pinyin)
.filter(p => p !== correctPinyin)
// Fill with placeholders if still not enough
while (wrongAnswers.length < 3) {
wrongAnswers.push(`Option ${wrongAnswers.length + 1}`)
}
}
// Shuffle all options
const allOptions = shuffleOptions([correctPinyin, ...wrongAnswers])
// Get English meaning (first meaning)
const meaning = defaultForm.meanings[0]?.meaning || ""
cards.push({
hanziId: hanzi.id,
simplified: hanzi.simplified,
options: allOptions,
correctPinyin,
meaning,
})
}
return {
success: true,
data: {
sessionId: learningSession.id,
cards,
},
message: `Learning session started with ${cards.length} cards`,
}
} catch (error) {
console.error("Start learning session error:", error)
return {
success: false,
message: "Failed to start learning session",
}
}
}
/**
* Submit an answer for a card
*
* Records the answer, updates SM-2 progress, and creates a session review.
*
* @param sessionId - Current session ID
* @param hanziId - Hanzi being reviewed
* @param selectedPinyin - User's selected answer
* @param correct - Whether answer was correct
* @param timeSpentMs - Time spent on this card in milliseconds
* @returns Updated progress information
*/
export async function submitAnswer(
sessionId: string,
hanziId: string,
selectedPinyin: string,
correct: boolean,
timeSpentMs: number
): Promise<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",
}
}
}