912 lines
23 KiB
TypeScript
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",
|
|
}
|
|
}
|
|
}
|