- Botón 'Volver' en todas las sesiones de ejercicios (ExerciseSession) - Botón 'Cambiar perfil' en las 3 home pages (Isabella/Francesca/Sebastián) - Páginas inline de Isabella migradas a ExerciseSession (elimina código duplicado) - APIs con try/catch: curriculum/next, dashboard/summary, fsrs/next, children - Validación childId en curriculum/next (404 si no existe) - /api/health endpoint para Docker healthcheck - .env.example, .dockerignore, README.md creados - docker-compose.yml con healthcheck en app service - Test E2E: health endpoint + childId validation - 62/62 tests pasan, TS exit 0
165 lines
4.8 KiB
TypeScript
165 lines
4.8 KiB
TypeScript
import { NextRequest, NextResponse } from "next/server"
|
|
import { prisma } from "@/lib/db"
|
|
|
|
export async function GET(request: NextRequest) {
|
|
try {
|
|
const childId = request.nextUrl.searchParams.get("childId")
|
|
if (!childId) {
|
|
return NextResponse.json({ error: "childId required" }, { status: 400 })
|
|
}
|
|
|
|
const today = new Date()
|
|
today.setHours(0, 0, 0, 0)
|
|
|
|
const [totalSessions, todaySessions, milestones, fsrsCards, lastSession, weeklySessions, milestoneRecords, curriculumProgress, errorPatterns, allAttempts] =
|
|
await Promise.all([
|
|
prisma.sessionLog.count({ where: { childId } }),
|
|
prisma.sessionLog.count({
|
|
where: { childId, startedAt: { gte: today } },
|
|
}),
|
|
prisma.milestone.count({ where: { childId } }),
|
|
prisma.fsrsCard.findMany({ where: { childId } }),
|
|
prisma.sessionLog.findFirst({
|
|
where: { childId },
|
|
orderBy: { startedAt: "desc" },
|
|
include: { skillAttempts: true },
|
|
}),
|
|
prisma.sessionLog.findMany({
|
|
where: { childId, startedAt: { gte: new Date(Date.now() - 7 * 86400000) } },
|
|
orderBy: { startedAt: "asc" },
|
|
select: { startedAt: true, durationSec: true },
|
|
}),
|
|
prisma.milestone.findMany({
|
|
where: { childId },
|
|
orderBy: { reachedAt: "desc" },
|
|
select: { skillCode: true, reachedAt: true },
|
|
}),
|
|
prisma.curriculumProgress.findMany({
|
|
where: { childId },
|
|
orderBy: { stage: "asc" },
|
|
}),
|
|
prisma.skillAttempt.findMany({
|
|
where: {
|
|
errorType: { not: null },
|
|
sessionLog: { childId },
|
|
},
|
|
select: { errorType: true },
|
|
}),
|
|
prisma.skillAttempt.findMany({
|
|
where: {
|
|
correct: false,
|
|
exerciseId: { not: null },
|
|
sessionLog: { childId },
|
|
},
|
|
select: { exerciseId: true, errorType: true },
|
|
}),
|
|
])
|
|
|
|
const todayMinutes = todaySessions > 0
|
|
? await prisma.sessionLog.aggregate({
|
|
where: { childId, startedAt: { gte: today } },
|
|
_sum: { durationSec: true },
|
|
}).then((r) => Math.round((r._sum.durationSec ?? 0) / 60))
|
|
: 0
|
|
|
|
const totalSkills = fsrsCards.length
|
|
const masteredSkills = fsrsCards.filter((c) => c.reps >= 5 && c.stability > 30).length
|
|
const masteryPercent = totalSkills > 0 ? Math.round((masteredSkills / totalSkills) * 100) : 0
|
|
|
|
const weeklyData = weeklySessions.map((s) => ({
|
|
date: s.startedAt.toISOString().slice(0, 10),
|
|
minutes: Math.round((s.durationSec ?? 0) / 60),
|
|
}))
|
|
|
|
const skillBreakdown = fsrsCards.map((c) => ({
|
|
skillCode: c.skillCode,
|
|
stability: c.stability,
|
|
difficulty: c.difficulty,
|
|
reps: c.reps,
|
|
due: c.dueAt.toISOString(),
|
|
}))
|
|
|
|
const streak = await calculateStreak(childId)
|
|
|
|
const totalErrors = allAttempts.length
|
|
const errorTypeCount = errorPatterns.reduce<Record<string, number>>((acc, e) => {
|
|
const key = e.errorType || "sin-clasificar"
|
|
acc[key] = (acc[key] || 0) + 1
|
|
return acc
|
|
}, {})
|
|
|
|
const stageProgress = curriculumProgress.map((cp) => ({
|
|
topic: cp.topic,
|
|
stage: cp.stage,
|
|
completedAt: cp.completedAt?.toISOString() || null,
|
|
}))
|
|
|
|
return NextResponse.json({
|
|
totalSessions,
|
|
todaySessions,
|
|
todayMinutes,
|
|
masteryPercent,
|
|
masteredSkills,
|
|
totalSkills,
|
|
milestones,
|
|
streak,
|
|
weeklyData,
|
|
skillBreakdown,
|
|
milestoneData: milestoneRecords,
|
|
stageProgress,
|
|
errorTypeCount,
|
|
totalErrors,
|
|
lastSession: lastSession
|
|
? {
|
|
startedAt: lastSession.startedAt.toISOString(),
|
|
durationSec: lastSession.durationSec,
|
|
skillsPracticed: lastSession.skillsPracticed,
|
|
correctCount: lastSession.skillAttempts.filter((a) => a.correct).length,
|
|
totalAttempts: lastSession.skillAttempts.length,
|
|
}
|
|
: null,
|
|
})
|
|
} catch (error) {
|
|
console.error("[dashboard/summary] error:", error)
|
|
return NextResponse.json({ error: "Internal error" }, { status: 500 })
|
|
}
|
|
}
|
|
|
|
async function calculateStreak(childId: string): Promise<number> {
|
|
const logs = await prisma.sessionLog.findMany({
|
|
where: { childId },
|
|
orderBy: { startedAt: "desc" },
|
|
select: { startedAt: true },
|
|
})
|
|
|
|
if (logs.length === 0) return 0
|
|
|
|
const days = [
|
|
...new Set(logs.map((l) => l.startedAt.toISOString().slice(0, 10))),
|
|
].sort()
|
|
.reverse()
|
|
|
|
let streak = 1
|
|
const today = new Date().toISOString().slice(0, 10)
|
|
|
|
if (days[0] !== today && days[0] !== getYesterday()) return 0
|
|
|
|
for (let i = 1; i < days.length; i++) {
|
|
const prev = new Date(days[i - 1])
|
|
const curr = new Date(days[i])
|
|
const diff = (prev.getTime() - curr.getTime()) / 86400000
|
|
if (Math.round(diff) === 1) {
|
|
streak++
|
|
} else {
|
|
break
|
|
}
|
|
}
|
|
return streak
|
|
}
|
|
|
|
function getYesterday(): string {
|
|
const d = new Date()
|
|
d.setDate(d.getDate() - 1)
|
|
return d.toISOString().slice(0, 10)
|
|
}
|