feat(flashcards): révision SM-2, génération IA et page /revision
Livre US-FLASHCARDS avec decks, session de révision, stats et migration Prisma. Finalise le Web Clipper (i18n 15 langues) et corrige les erreurs ESLint bloquant la CI. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
80
memento-note/app/api/flashcards/stats/route.ts
Normal file
80
memento-note/app/api/flashcards/stats/route.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { auth } from '@/auth'
|
||||
import prisma from '@/lib/prisma'
|
||||
import { isCardMastered } from '@/lib/flashcards/sm2'
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const userId = session.user.id
|
||||
const now = new Date()
|
||||
|
||||
const [reviews, allCards] = await Promise.all([
|
||||
prisma.flashcardReview.findMany({
|
||||
where: { card: { deck: { userId } } },
|
||||
select: { reviewedAt: true, grade: true },
|
||||
orderBy: { reviewedAt: 'desc' },
|
||||
take: 500,
|
||||
}),
|
||||
prisma.flashcard.findMany({
|
||||
where: { deck: { userId } },
|
||||
select: { id: true, interval: true, easinessFactor: true, front: true, deck: { select: { name: true } } },
|
||||
}),
|
||||
])
|
||||
|
||||
const heatmapMap = new Map<string, number>()
|
||||
for (const r of reviews) {
|
||||
const day = r.reviewedAt.toISOString().slice(0, 10)
|
||||
heatmapMap.set(day, (heatmapMap.get(day) || 0) + 1)
|
||||
}
|
||||
const heatmap = Array.from(heatmapMap.entries())
|
||||
.map(([date, count]) => ({ date, count }))
|
||||
.sort((a, b) => a.date.localeCompare(b.date))
|
||||
.slice(-90)
|
||||
|
||||
const totalCards = allCards.length
|
||||
const masteredCount = allCards.filter((c) => isCardMastered(c.interval)).length
|
||||
const retentionRate = totalCards > 0 ? Math.round((masteredCount / totalCards) * 100) : 0
|
||||
|
||||
const weekMs = 7 * 24 * 60 * 60 * 1000
|
||||
const retentionByWeek: { week: string; rate: number }[] = []
|
||||
for (let i = 7; i >= 0; i--) {
|
||||
const weekEnd = new Date(now.getTime() - i * weekMs)
|
||||
const weekStart = new Date(weekEnd.getTime() - weekMs)
|
||||
const cardsAtWeek = allCards.filter((c) => c.interval >= 7 * (8 - i))
|
||||
const masteredAtWeek = cardsAtWeek.filter((c) => isCardMastered(c.interval)).length
|
||||
const rate = cardsAtWeek.length > 0
|
||||
? Math.round((masteredAtWeek / cardsAtWeek.length) * 100)
|
||||
: retentionRate
|
||||
retentionByWeek.push({
|
||||
week: weekStart.toISOString().slice(0, 10),
|
||||
rate,
|
||||
})
|
||||
}
|
||||
|
||||
const difficultCards = [...allCards]
|
||||
.sort((a, b) => a.easinessFactor - b.easinessFactor)
|
||||
.slice(0, 5)
|
||||
.map((c) => ({
|
||||
id: c.id,
|
||||
front: c.front.slice(0, 120),
|
||||
easinessFactor: c.easinessFactor,
|
||||
deckName: c.deck.name,
|
||||
}))
|
||||
|
||||
return NextResponse.json({
|
||||
heatmap,
|
||||
retentionRate,
|
||||
retentionByWeek,
|
||||
difficultCards,
|
||||
totalReviews: reviews.length,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[flashcards/stats]', error)
|
||||
return NextResponse.json({ error: 'Failed to load stats' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user