feat: multi-profile system with Francesca and Sebastian curricula

- Add Profile enum (ISABELLA, FRANCESCA, SEBASTIAN) to Child model
- Add ?profile= query param override to /api/curriculum/next
- Pass profile prop from profile-specific pages to ExerciseSession
- Fix curriculum import paths (engine uses @/curriculum/ alias)
- Clear stale curriculumCache on dev reload
- Fix self-referential prerequisites in Francesca etapa-1
- Fix prerequisite chains in Francesca etapa-2,3,4 and Sebastian etapa-2,3
- Add Francesca curriculum: 2-3 digit sums, subtractions, multiplications, short readings
- Add Sebastian curriculum: 2-3 digit sums, interactive exercises, Argentine history, flags
- All 44 E2E tests pass, TypeScript compiles cleanly
This commit is contained in:
renato97
2026-07-22 21:48:19 -03:00
parent fa8e0c58ce
commit 25614704dc
25 changed files with 1444 additions and 18 deletions
+9 -2
View File
@@ -1,13 +1,20 @@
import { NextRequest, NextResponse } from "next/server"
import { prisma } from "@/lib/db"
import { getNextExercise } from "@/lib/curriculum/engine"
export async function GET(request: NextRequest) {
const childId = request.nextUrl.searchParams.get("childId")
const topic = (request.nextUrl.searchParams.get("topic") || "lectura") as "lectura" | "numeros"
const topic = request.nextUrl.searchParams.get("topic") || "lectura"
if (!childId) {
return NextResponse.json({ error: "childId required" }, { status: 400 })
}
const exercise = await getNextExercise(childId, topic)
const child = await prisma.child.findUnique({
where: { id: childId },
select: { profile: true },
})
const profile = (request.nextUrl.searchParams.get("profile") || child?.profile || "ISABELLA").toUpperCase()
const exercise = await getNextExercise(childId, topic, profile)
return NextResponse.json(exercise || { id: null, done: true })
}
+12
View File
@@ -0,0 +1,12 @@
import ExerciseSession from "@/components/child/exercise-session"
export default function FrancescaLecturas() {
return (
<ExerciseSession
topic="lecturas"
profile="FRANCESCA"
loadingMessage="Preparando lecturas..."
completeMessage="¡Terminaste las lecturas por ahora!"
/>
)
}
+12
View File
@@ -0,0 +1,12 @@
import ExerciseSession from "@/components/child/exercise-session"
export default function FrancescaMate() {
return (
<ExerciseSession
topic="matematicas"
profile="FRANCESCA"
loadingMessage="Preparando matemáticas..."
completeMessage="¡Terminaste las matemáticas por ahora!"
/>
)
}
+65
View File
@@ -0,0 +1,65 @@
"use client"
import { useEffect, useState } from "react"
import { useRouter } from "next/navigation"
import { GatoMascota } from "@/components/child/gato-mascota"
import AudioUnlock from "@/components/child/audio-unlock"
const FRANCESCA_KEY = "edueasy_child_francesca"
const activities = [
{ id: "matematicas", label: "Matemáticas", icon: "🔢", description: "Sumas, restas y multiplicaciones" },
{ id: "lecturas", label: "Lecturas cortas", icon: "📖", description: "Leé textos chiquitos" },
{ id: "logros", label: "Mis logros", icon: "⭐", color: "bg-warning" },
{ id: "pairing", label: "iPad nuevo", icon: "🔗", color: "bg-secondary" },
]
export default function FrancescaHome() {
const router = useRouter()
const [audioReady, setAudioReady] = useState(false)
const [paired, setPaired] = useState(false)
useEffect(() => {
setPaired(!!localStorage.getItem(FRANCESCA_KEY))
}, [])
if (!audioReady) {
return <AudioUnlock onUnlock={() => setAudioReady(true)} />
}
return (
<div className="flex-1 flex flex-col items-center justify-center p-6 gap-8">
<GatoMascota mood="happy" message="¡Hola Francesca! ¿Qué querés practicar hoy?" />
{!paired ? (
<div className="text-center max-w-xs space-y-4">
<p className="text-foreground/60">Todavía no estás conectada. Pedile a mamá que te empareje.</p>
<button
onClick={() => router.push("/child/pairing?profile=francesca")}
className="bg-primary text-primary-foreground touch-target-lg rounded-2xl px-8 py-4 text-xl font-display font-bold"
>
Ir a emparejar
</button>
</div>
) : (
<div className="flex flex-col gap-4 w-full max-w-md">
{activities.map((a) => (
<button
key={a.id}
onClick={() => router.push(`/francesca/${a.id}`)}
className={`${a.color || "bg-accent"} text-primary-foreground touch-target-lg rounded-2xl flex items-center gap-4 px-6 py-5 text-xl font-display font-semibold shadow-md active:scale-95 transition-transform`}
>
<span className="text-2xl">{a.icon}</span>
<div className="text-left">
<div>{a.label}</div>
{a.description && (
<div className="text-sm opacity-80">{a.description}</div>
)}
</div>
</button>
))}
</div>
)}
</div>
)
}
+31 -3
View File
@@ -3,6 +3,12 @@
import { useEffect } from "react"
import { useRouter } from "next/navigation"
const PROFILES = [
{ id: "isabella", label: "Isabella", emoji: "👧", color: "bg-pink-500" },
{ id: "francesca", label: "Francesca", emoji: "👦", color: "bg-blue-500" },
{ id: "sebastian", label: "Sebastián", emoji: "🧒", color: "bg-green-500" },
]
export default function RootPage() {
const router = useRouter()
@@ -10,10 +16,32 @@ export default function RootPage() {
const host = window.location.hostname
if (host.includes("simulacro")) {
router.replace("/parent")
} else {
router.replace("/child")
return
}
const params = new URLSearchParams(window.location.search)
const preselected = params.get("profile")
if (preselected && PROFILES.find(p => p.id === preselected)) {
router.replace(`/child/${preselected}`)
}
}, [router])
return null
return (
<div className="min-h-dvh bg-background flex flex-col items-center justify-center p-6 gap-8">
<h1 className="text-4xl font-display font-bold text-center">¿Quién sos?</h1>
<div className="flex flex-col gap-4 w-full max-w-sm">
{PROFILES.map((p) => (
<button
key={p.id}
onClick={() => router.push(`/child/${p.id}`)}
className={`${p.color} text-white touch-target-lg rounded-2xl flex items-center gap-4 px-6 py-8 text-2xl font-display font-bold shadow-lg active:scale-95 transition-transform`}
>
<span className="text-4xl">{p.emoji}</span>
<span>{p.label}</span>
</button>
))}
</div>
</div>
)
}
+12
View File
@@ -0,0 +1,12 @@
import ExerciseSession from "@/components/child/exercise-session"
export default function SebastianBanderas() {
return (
<ExerciseSession
topic="banderas"
profile="SEBASTIAN"
loadingMessage="Preparando banderas..."
completeMessage="¡Terminaste las banderas por hoy!"
/>
)
}
+12
View File
@@ -0,0 +1,12 @@
import ExerciseSession from "@/components/child/exercise-session"
export default function SebastianHistoria() {
return (
<ExerciseSession
topic="historia"
profile="SEBASTIAN"
loadingMessage="Preparando historia argentina..."
completeMessage="¡Terminaste historia por ahora!"
/>
)
}
+66
View File
@@ -0,0 +1,66 @@
"use client"
import { useEffect, useState } from "react"
import { useRouter } from "next/navigation"
import { GatoMascota } from "@/components/child/gato-mascota"
import AudioUnlock from "@/components/child/audio-unlock"
const SEBASTIAN_KEY = "edueasy_child_sebastian"
const activities = [
{ id: "sumas", label: "Sumas avanzadas", icon: "", description: "Sumas de 2 y 3 dígitos" },
{ id: "historia", label: "Historia argentina", icon: "🇦🇷", description: "Personajes y eventos" },
{ id: "banderas", label: "Banderas", icon: "🚩", description: "Provincias y símbolos" },
{ id: "logros", label: "Mis logros", icon: "⭐", color: "bg-warning" },
{ id: "pairing", label: "iPad nuevo", icon: "🔗", color: "bg-secondary" },
]
export default function SebastianHome() {
const router = useRouter()
const [audioReady, setAudioReady] = useState(false)
const [paired, setPaired] = useState(false)
useEffect(() => {
setPaired(!!localStorage.getItem(SEBASTIAN_KEY))
}, [])
if (!audioReady) {
return <AudioUnlock onUnlock={() => setAudioReady(true)} />
}
return (
<div className="flex-1 flex flex-col items-center justify-center p-6 gap-8">
<GatoMascota mood="happy" message="¡Hola Sebastián! ¿Qué querés explorar hoy?" />
{!paired ? (
<div className="text-center max-w-xs space-y-4">
<p className="text-foreground/60">Todavía no estás conectado. Pedile a mamá que te empareje.</p>
<button
onClick={() => router.push("/child/pairing?profile=sebastian")}
className="bg-primary text-primary-foreground touch-target-lg rounded-2xl px-8 py-4 text-xl font-display font-bold"
>
Ir a emparejar
</button>
</div>
) : (
<div className="flex flex-col gap-4 w-full max-w-md">
{activities.map((a) => (
<button
key={a.id}
onClick={() => router.push(`/sebastian/${a.id}`)}
className={`${a.color || "bg-accent"} text-primary-foreground touch-target-lg rounded-2xl flex items-center gap-4 px-6 py-5 text-xl font-display font-semibold shadow-md active:scale-95 transition-transform`}
>
<span className="text-2xl">{a.icon}</span>
<div className="text-left">
<div>{a.label}</div>
{a.description && (
<div className="text-sm opacity-80">{a.description}</div>
)}
</div>
</button>
))}
</div>
)}
</div>
)
}
+12
View File
@@ -0,0 +1,12 @@
import ExerciseSession from "@/components/child/exercise-session"
export default function SebastianSumas() {
return (
<ExerciseSession
topic="sumas"
profile="SEBASTIAN"
loadingMessage="Preparando sumas..."
completeMessage="¡Terminaste las sumas por ahora!"
/>
)
}
+179
View File
@@ -0,0 +1,179 @@
"use client"
import { useState, useCallback, useEffect, useRef } from "react"
import { motion, AnimatePresence } from "framer-motion"
import { GatoMascota } from "@/components/child/gato-mascota"
import NoChildError from "@/components/child/no-child-error"
import TemporizadorVisual from "@/components/child/temporizador-visual"
import ExerciseDispatcher from "@/components/exercises/exercise-dispatcher"
import { speak, stopSpeaking } from "@/lib/speech"
import type { Exercise } from "@/curriculum/types"
const CHILD_ID_KEY = "edueasy_child_id"
type PageState = "loading" | "ready" | "complete" | "error"
interface Props {
topic: string
loadingMessage: string
completeMessage: string
profile?: string
}
export default function ExerciseSession({ topic, loadingMessage, completeMessage, profile }: Props) {
const [pageState, setPageState] = useState<PageState>("loading")
const [exercise, setExercise] = useState<Exercise | null>(null)
const [running, setRunning] = useState(true)
const [score, setScore] = useState(0)
const [totalAttempts, setTotalAttempts] = useState(0)
const childIdRef = useRef<string>("")
const startedAtRef = useRef(new Date().toISOString())
const getChildId = useCallback(() => {
const id = localStorage.getItem(CHILD_ID_KEY) || ""
childIdRef.current = id
return id
}, [])
const fetchNext = useCallback(async () => {
const childId = getChildId()
if (!childId) {
setPageState("error")
return null
}
try {
const qs = new URLSearchParams({ childId, topic })
if (profile) qs.set("profile", profile)
const res = await fetch(`/api/curriculum/next?${qs}`)
const data = await res.json()
if (data.done || !data.id) {
setPageState("complete")
return null
}
setExercise(data)
setPageState("ready")
return data
} catch {
setPageState("error")
return null
}
}, [getChildId, topic, profile])
useEffect(() => {
fetchNext()
}, [fetchNext])
const gradeAttempt = useCallback(async (ex: Exercise, correct: boolean) => {
const childId = childIdRef.current
if (!childId || !ex) return
setTotalAttempts((t) => t + 1)
if (correct) setScore((s) => s + 1)
const errorType = !correct
? ex.errorType || "discriminacion-auditiva"
: null
try {
await fetch("/api/curriculum/grade", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
childId,
exerciseId: ex.id,
skillCode: ex.skillCode,
correct,
promptLevel: 0,
responseMs: 3000,
errorType,
stage: ex.stage,
}),
})
} catch {
// silent
}
}, [])
const handleComplete = useCallback(
async (correct: boolean) => {
if (!exercise) return
await gradeAttempt(exercise, correct)
stopSpeaking()
if (correct) {
await speak("¡Muy bien!")
await new Promise((r) => setTimeout(r, 800))
} else {
await speak("¡Seguí intentando!")
await new Promise((r) => setTimeout(r, 800))
}
fetchNext()
},
[exercise, gradeAttempt, fetchNext],
)
const resetSession = useCallback(() => {
setScore(0)
setTotalAttempts(0)
setRunning(true)
startedAtRef.current = new Date().toISOString()
fetchNext()
}, [fetchNext])
if (pageState === "loading") {
return (
<div className="flex-1 flex items-center justify-center">
<GatoMascota mood="coach" message={loadingMessage} size="sm" />
</div>
)
}
if (pageState === "error") {
return <NoChildError onRetry={resetSession} />
}
if (pageState === "complete") {
return (
<div className="flex-1 flex flex-col items-center justify-center p-8 gap-6">
<GatoMascota mood="celebrate" message={completeMessage} size="lg" />
<p className="text-lg font-display">
Aciertos: {score}/{totalAttempts}
</p>
<button
onClick={resetSession}
className="bg-primary text-primary-foreground touch-target-lg rounded-2xl px-8 py-4 text-xl font-display font-bold"
>
Jugar de nuevo
</button>
</div>
)
}
return (
<div className="flex-1 flex flex-col items-center justify-center p-6 gap-6">
<TemporizadorVisual
durationMinutes={10}
onComplete={() => {
setRunning(false)
setPageState("complete")
}}
running={running}
/>
<AnimatePresence mode="wait">
<motion.div
key={exercise?.id || "empty"}
initial={{ opacity: 0, x: 50 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -50 }}
transition={{ duration: 0.25 }}
className="w-full max-w-md"
>
{exercise && (
<ExerciseDispatcher exercise={exercise} onComplete={handleComplete} />
)}
</motion.div>
</AnimatePresence>
</div>
)
}
+86
View File
@@ -0,0 +1,86 @@
import type { Stage } from '../types'
const lecturas = [
{
text: 'Ana tiene un gato. El gato es negro. Juegan juntas en el jardín.',
options: ['El gato es negro', 'El gato es blanco', 'Tiene un perro'],
correcta: 'El gato es negro',
emoji: '🐱',
},
{
text: 'La frutilla es roja. La banana es amarilla. La uva es morada.',
options: ['La frutilla es roja', 'La banana es roja', 'La uva es verde'],
correcta: 'La frutilla es roja',
emoji: '🍓',
},
{
text: 'El coche es rojo. La moto es azul. La bici es verde.',
options: ['El coche es rojo', 'La moto es roja', 'La bici es roja'],
correcta: 'El coche es rojo',
emoji: '🚗',
},
{
text: 'La nena lee un cuento. Papá le ayuda. Es un cuento de princesas.',
options: ['Lee un cuento', 'Ve la tele', 'Juega a la pelota'],
correcta: 'Lee un cuento',
emoji: '📚',
},
{
text: 'En la plaza hay un columpio. Los niños se divierten. Es un día soleado.',
options: ['Hay un columpio', 'Hay un tobogán', 'No hay nada'],
correcta: 'Hay un columpio',
emoji: '🎡',
},
{
text: 'La vaca dice mu. La oveja dice bé. El caballo dice ihí.',
options: ['La vaca dice mu', 'La oveja dice mu', 'El caballo dice bé'],
correcta: 'La vaca dice mu',
emoji: '🐄',
},
{
text: 'El helado es de chocolate. La galleta es de vainilla. La gaseosa es de cola.',
options: ['El helado es de chocolate', 'La galleta es de chocolate', 'La gaseosa es de limón'],
correcta: 'El helado es de chocolate',
emoji: '🍦',
},
{
text: 'La luna está en el cielo. El sol se escondió. Las estrellas brillan.',
options: ['La luna está en el cielo', 'El sol está en el cielo', 'Las estrellas duermen'],
correcta: 'La luna está en el cielo',
emoji: '🌙',
},
]
const exercises: any[] = []
for (let i = 0; i < lecturas.length; i++) {
const l = lecturas[i]
exercises.push({
id: `fra-lect1-${i}`,
stage: 1,
skillCode: `lectura-fra-${i}`,
type: 'lectura',
prerequisites: i === 0 ? [] : [`fra-lect1-${i - 1}`],
content: {
instruction: `Leé este texto`,
text: l.text,
audioText: l.text,
emoji: l.emoji,
options: l.options.map((o) => ({
label: o,
correct: o === l.correcta,
id: o.toLowerCase().slice(0, 12),
})),
},
functionalContext: 'Leer textos cortos con comprensión.',
errorType: 'omision-letra',
})
}
const stage: Stage = {
id: 1,
name: 'Lecturas nivel 1',
description: 'Leer textos muy cortos y responder preguntas simples.',
exercises,
}
export default stage
+68
View File
@@ -0,0 +1,68 @@
import type { Stage } from '../types'
const lecturas = [
{
text: 'El día de la primavera, todos los chicos van al parque. Llevan merienda, juegan y cantan. Los árboles tienen flores.',
options: ['Van al parque', 'Se quedan en casa', 'Ven la tele'],
correcta: 'Van al parque',
emoji: '🌸',
},
{
text: 'El Río de la Plata es muy ancho. El obelisco está en Buenos Aires. La Casa Rosada es de color rosa.',
options: ['El obelisco está en Buenos Aires', 'El obelisco está en Córdoba', 'La Casa Rosada es blanca'],
correcta: 'El obelisco está en Buenos Aires',
emoji: '🏙️',
},
{
text: 'En el zoológico hay leones, elefantes y jirafas. Los leones duermen mucho. Las jirafas comen hojas.',
options: ['Los leones duermen mucho', 'Los leones comen hojas', 'Las jirafas duermen mucho'],
correcta: 'Los leones duermen mucho',
emoji: '🦁',
},
{
text: 'El cumpleaños de Clara es en octubre. Cumple 8 años. Invita a todos sus compañeros de clase.',
options: ['Cumple 8 años', 'Cumple 5 años', 'No invita a nadie'],
correcta: 'Cumple 8 años',
emoji: '🎂',
},
{
text: 'La biblioteca tiene muchos libros. Los chicos eligen uno y se sientan a leer. Es un lugar muy silencioso.',
options: ['Es muy silencioso', 'Es muy ruidoso', 'No hay libros'],
correcta: 'Es muy silencioso',
emoji: '📚',
},
]
const exercises: any[] = []
for (let i = 0; i < lecturas.length; i++) {
const l = lecturas[i]
exercises.push({
id: `fra-lect2-${i}`,
stage: 2,
skillCode: `lectura-fra2-${i}`,
type: 'lectura',
prerequisites: i === 0 ? ['fra-lect1-0'] : [`fra-lect2-${i - 1}`],
content: {
instruction: `Leé y respondé`,
text: l.text,
audioText: l.text,
emoji: l.emoji,
options: l.options.map((o) => ({
label: o,
correct: o === l.correcta,
id: o.toLowerCase().slice(0, 12),
})),
},
functionalContext: 'Leer textos algo más largos.',
errorType: 'omision-letra',
})
}
const stage: Stage = {
id: 2,
name: 'Lecturas nivel 2',
description: 'Leer textos un poco más largos y responder preguntas.',
exercises,
}
export default stage
+51
View File
@@ -0,0 +1,51 @@
import type { Stage } from '../types'
const sums = Array.from({ length: 15 }, (_, i) => {
const a = Math.floor(i / 3) + 1
const b = (i % 3) + 1
const res = a + b
return { a, b, res }
})
const exercises: any[] = []
for (const s of sums) {
const prereqs: string[] = []
if (s.a > 1 || s.b > 1) {
if (s.b === 1) {
prereqs.push(`fra1-suma-${s.a - 1}-1`)
} else {
prereqs.push(`fra1-suma-1-${s.b - 1}`)
}
}
exercises.push({
id: `fra1-suma-${s.a}-${s.b}`,
stage: 1,
skillCode: `suma-${s.a + s.b}`,
type: 'sensibilizacion',
prerequisites: prereqs,
content: {
instruction: `Sumá: ${s.a} + ${s.b}`,
audioText: `${s.a} más ${s.b} es ${s.res}`,
emoji: '🧮',
color: '#8CB8A0',
options: [
{ label: String(s.res), correct: true, id: String(s.res) },
{ label: String(s.res - 1), correct: false, id: String(s.res - 1) },
{ label: String(s.res + 1), correct: false, id: String(s.res + 1) },
].sort(() => Math.random() - 0.5),
},
functionalContext: `Sumar ${s.a} + ${s.b} con método Montessori.`,
errorType: 'discriminacion-auditiva',
})
}
const stage: Stage = {
id: 1,
name: 'Sumas de 1 dígito',
description: 'Aprender sumas básicas del 1 al 9 con material concreto Montessori.',
exercises,
}
export default stage
+42
View File
@@ -0,0 +1,42 @@
import type { Stage } from '../types'
const sums = [
{ a: 10, b: 5, res: 15 }, { a: 23, b: 34, res: 57 }, { a: 45, b: 12, res: 57 },
{ a: 67, b: 22, res: 89 }, { a: 100, b: 50, res: 150 }, { a: 34, b: 56, res: 90 },
{ a: 78, b: 21, res: 99 }, { a: 55, b: 33, res: 88 }, { a: 42, b: 47, res: 89 },
{ a: 90, b: 15, res: 105 }, { a: 13, b: 87, res: 100 }, { a: 29, b: 71, res: 100 },
{ a: 150, b: 75, res: 225 }, { a: 200, b: 100, res: 300 },
]
const exercises: any[] = []
for (let i = 0; i < sums.length; i++) {
const s = sums[i]
exercises.push({
id: `fra2-suma-${i}`,
stage: 2,
skillCode: `suma2-${i}`,
type: 'eleccion',
prerequisites: [`fra2-suma-${i > 0 ? i - 1 : 0}`],
content: {
instruction: `Sumá: ${s.a} + ${s.b}`,
audioText: `${s.a} más ${s.b} es ${s.res}`,
emoji: '',
options: [
{ label: String(s.res), correct: true, id: String(s.res) },
{ label: String(s.res + (Math.random() > 0.5 ? 10 : -10)), correct: false, id: `wrong-${i}-a` },
{ label: String(s.res + (Math.random() > 0.5 ? 1 : -1)), correct: false, id: `wrong-${i}-b` },
].sort(() => Math.random() - 0.5),
},
functionalContext: 'Sumar números de 2 y 3 dígitos con método Montessori.',
errorType: 'discriminacion-auditiva',
})
}
const stage: Stage = {
id: 2,
name: 'Sumas de 2-3 dígitos',
description: 'Resolver sumas de números de 2 y 3 dígitos con material concreto.',
exercises,
}
export default stage
+44
View File
@@ -0,0 +1,44 @@
import type { Stage } from '../types'
const restas = [
{ a: 10, b: 3, res: 7 }, { a: 50, b: 20, res: 30 }, { a: 90, b: 45, res: 45 },
{ a: 100, b: 35, res: 65 }, { a: 200, b: 75, res: 125 }, { a: 80, b: 23, res: 57 },
{ a: 120, b: 40, res: 80 }, { a: 55, b: 22, res: 33 }, { a: 75, b: 30, res: 45 },
{ a: 150, b: 60, res: 90 }, { a: 300, b: 100, res: 200 }, { a: 99, b: 33, res: 66 },
{ a: 45, b: 12, res: 33 }, { a: 180, b: 50, res: 130 },
]
const exercises: any[] = []
for (let i = 0; i < restas.length; i++) {
const r = restas[i]
const wrong1 = r.res - 10
const wrong2 = r.res + 10
exercises.push({
id: `fra3-resta-${i}`,
stage: 3,
skillCode: `resta2-${i}`,
type: 'eleccion',
prerequisites: i === 0 ? ['fra1-suma-9-1'] : [`fra3-resta-${i - 1}`],
content: {
instruction: `Restá: ${r.a} - ${r.b}`,
audioText: `${r.a} menos ${r.b} es ${r.res}`,
emoji: '',
options: [
{ label: String(r.res), correct: true, id: String(r.res) },
{ label: String(wrong1 >= 0 ? wrong1 : r.res + 5), correct: false, id: `wrong-${i}-a` },
{ label: String(wrong2), correct: false, id: `wrong-${i}-b` },
].sort(() => Math.random() - 0.5),
},
functionalContext: 'Restar números de 2 y 3 dígitos con método Montessori.',
errorType: 'inversion-letra',
})
}
const stage: Stage = {
id: 3,
name: 'Restas de 2-3 dígitos',
description: 'Resolver restas de números de 2 y 3 dígitos con material concreto.',
exercises,
}
export default stage
+112
View File
@@ -0,0 +1,112 @@
import type { Stage } from '../types'
function multIdx(a: number, b: number) {
return (b - 2) * 4 + (a - 1)
}
const mults = Array.from({ length: 12 }, (_, i) => {
const a = (i % 4) + 1
const b = Math.floor(i / 4) + 2
return { a, b, res: a * b }
})
const exercises: any[] = []
for (const m of mults) {
const idx = multIdx(m.a, m.b)
exercises.push({
id: `fra4-mult-${m.a}-${m.b}`,
stage: 4,
skillCode: `mult-${m.a}x${m.b}`,
type: 'eleccion',
prerequisites: idx > 0 ? [`fra4-mult-${mults[idx - 1].a}-${mults[idx - 1].b}`] : [],
content: {
instruction: `Multiplicá: ${m.a} × ${m.b}`,
audioText: `${m.a} veces ${m.b} es ${m.res}`,
emoji: '✖️',
options: [
{ label: String(m.res), correct: true, id: String(m.res) },
{ label: String(m.res + m.a), correct: false, id: `wrong-${m.a}a` },
{ label: String(m.res + m.b), correct: false, id: `wrong-${m.b}a` },
].sort(() => Math.random() - 0.5),
},
functionalContext: 'Multiplicar números de 1 dígito.',
errorType: 'confusion-letra',
})
}
const lecturas = [
{
title: 'El sol',
text: 'El sol brilla. Los niños juegan. Comen helado. Están contentos.',
question: '¿Qué hacen los niños?',
options: ['Juegan', 'Duermen', 'Estudian'],
correcta: 'Juegan',
emoji: '☀️',
},
{
title: 'La merienda',
text: 'Mamá prepara leche. Tomás come tostadas. ¡Qué rico!',
question: '¿Qué prepara mamá?',
options: ['Leche', 'Café', 'Agua'],
correcta: 'Leche',
emoji: '🥛',
},
{
title: 'El parque',
text: 'En el parque hay un árbol. Los pájaros cantan. Hace sol.',
question: '¿Dónde están los pájaros?',
options: ['En el parque', 'En el cielo', 'En casa'],
correcta: 'En el parque',
emoji: '🌳',
},
{
title: 'La familia',
text: 'Papá lee un libro. Mamá cocina. Los niños ayudan.',
question: '¿Qué lee papá?',
options: ['Un libro', 'El diario', 'Una carta'],
correcta: 'Un libro',
emoji: '📖',
},
{
title: 'El río',
text: 'El río corre. El pez nada. La rana salta.',
question: '¿Qué hace la rana?',
options: ['Salta', 'Nada', 'Vuela'],
correcta: 'Salta',
emoji: '🐸',
},
]
for (let i = 0; i < lecturas.length; i++) {
const l = lecturas[i]
exercises.push({
id: `fra4-lectura-${i}`,
stage: 4,
skillCode: 'lectura-corta',
type: 'lectura',
prerequisites: i === 0 ? ['fra4-mult-1-2'] : [`fra4-lectura-${i - 1}`],
content: {
instruction: `Leé: ${l.title}`,
text: l.text,
audioText: l.text,
emoji: l.emoji,
options: l.options.map((o) => ({
label: o,
correct: o === l.correcta,
id: o.toLowerCase().slice(0, 10),
})),
},
functionalContext: 'Leer textos cortos con comprensión.',
errorType: 'omision-letra',
})
}
const stage: Stage = {
id: 4,
name: 'Multiplicaciones y lecturas',
description: 'Multiplicaciones de 1 dígito y lectura comprensiva de textos cortos.',
exercises,
}
export default stage
+73
View File
@@ -0,0 +1,73 @@
import type { Stage } from '../types'
const banderas = [
{ id: 'buenos-aires', nombre: 'Buenos Aires', color: '#009688', emoji: '🌿' },
{ id: 'cordoba', nombre: 'Córdoba', color: '#2196F3', emoji: '⛰️' },
{ id: 'santafe', nombre: 'Santa Fe', color: '#FFC107', emoji: '🌾' },
{ id: 'mendoza', nombre: 'Mendoza', color: '#4CAF50', emoji: '🍇' },
{ id: 'tucuman', nombre: 'Tucumán', color: '#FF5722', emoji: '🌶️' },
{ id: 'salta', nombre: 'Salta', color: '#00BCD4', emoji: '🏔️' },
{ id: 'entrerios', nombre: 'Entre Ríos', color: '#8BC34A', emoji: '🌿' },
{ id: 'neuquen', nombre: 'Neuquén', color: '#3F51B5', emoji: '🏔️' },
]
const exercises: any[] = []
for (let i = 0; i < banderas.length; i++) {
const b = banderas[i]
const distractor = banderas[(i + 1) % banderas.length]
exercises.push({
id: `seb-bandera-${b.id}`,
stage: 1,
skillCode: `bandera-${b.id}`,
type: 'eleccion',
prerequisites: i === 0 ? [] : [`seb-bandera-${banderas[i - 1].id}`],
content: {
instruction: `¿De qué provincia es esta bandera?`,
audioText: `Esta es la bandera de ${b.nombre}`,
emoji: b.emoji,
options: [
{ label: b.nombre, correct: true, id: b.id },
{ label: distractor.nombre, correct: false, id: distractor.id },
{ label: banderas[(i + 2) % banderas.length].nombre, correct: false, id: banderas[(i + 2) % banderas.length].id },
].sort(() => Math.random() - 0.5),
},
functionalContext: `Identificar la bandera de ${b.nombre}.`,
errorType: 'confusion-letra',
})
}
const escudo = {
id: 'escudo-nacional',
nombre: 'Escudo nacional',
emoji: '🇦🇷',
color: '#56A',
}
exercises.push({
...escudo,
stage: 1,
skillCode: `bandera-escudo`,
type: 'eleccion',
prerequisites: [`seb-bandera-${banderas[banderas.length - 1].id}`],
content: {
instruction: '¿Qué es el símbolo nacional con el sol?',
audioText: 'El escudo nacional tiene dos manos abrazadas y el sol.',
emoji: escudo.emoji,
options: [
{ label: 'El escudo nacional', correct: true, id: 'escudo' },
{ label: 'La bandera', correct: false, id: 'bandera' },
{ label: 'La Constitución', correct: false, id: 'constitucion' },
].sort(() => Math.random() - 0.5),
},
functionalContext: 'Identificar el escudo nacional.',
errorType: 'confusion-letra',
})
const stage: Stage = {
id: 1,
name: 'Banderas y símbolos',
description: 'Identificar banderas de provincias argentinas y el escudo nacional.',
exercises,
}
export default stage
+116
View File
@@ -0,0 +1,116 @@
import type { Stage } from '../types'
const ejercicios = [
{
id: 'seb-h1-1',
content: {
instruction: '¿Quién fue el primer presidente argentino?',
audioText: '¿Quién fue el primer presidente argentino?',
emoji: '🇦🇷',
options: [
{ label: 'Bernardino Rivadavia', correct: true, id: 'rivadavia' },
{ label: 'Manuel Belgrano', correct: false, id: 'belgrano' },
{ label: 'José de San Martín', correct: false, id: 'sanmartin' },
],
},
functionalContext: 'Historia argentina: personajes.',
errorType: 'discriminacion-auditiva',
},
{
id: 'seb-h1-2',
content: {
instruction: '¿En qué año se declaró la independencia?',
audioText: '¿En qué año se declaró la independencia?',
emoji: '📜',
options: [
{ label: '1816', correct: true, id: '1816' },
{ label: '1800', correct: false, id: '1800' },
{ label: '1820', correct: false, id: '1820' },
],
},
functionalContext: 'Historia argentina: fechas.',
errorType: 'discriminacion-auditiva',
},
{
id: 'seb-h1-3',
content: {
instruction: '¿Quién creó la bandera argentina?',
audioText: '¿Quién creó la bandera argentina?',
emoji: '🚩',
options: [
{ label: 'Manuel Belgrano', correct: true, id: 'belgrano' },
{ label: 'San Martín', correct: false, id: 'sanmartin' },
{ label: 'Sarmiento', correct: false, id: 'sarmiento' },
],
},
functionalContext: 'Historia argentina: símbolos patrios.',
errorType: 'confusion-letra',
},
{
id: 'seb-h1-4',
content: {
instruction: '¿Dónde nació San Martín?',
audioText: '¿Dónde nació San Martín?',
emoji: '🗺️',
options: [
{ label: 'Yapeyú', correct: true, id: 'yapeyu' },
{ label: 'Buenos Aires', correct: false, id: 'ba' },
{ label: 'Tucumán', correct: false, id: 'tuc' },
],
},
functionalContext: 'Historia argentina: provincias.',
errorType: 'discriminacion-auditiva',
},
{
id: 'seb-h1-5',
content: {
instruction: 'La bandera tiene celeste y blanco. ¿Cuántos colores tiene?',
audioText: 'La bandera tiene celeste y blanco. ¿Cuántos colores tiene?',
emoji: '🚩',
options: [
{ label: '2', correct: true, id: '2' },
{ label: '3', correct: false, id: '3' },
{ label: '1', correct: false, id: '1' },
],
},
functionalContext: 'Historia argentina: símbolos patrios.',
errorType: 'discriminacion-auditiva',
},
{
id: 'seb-h1-6',
content: {
instruction: '¿En qué mes es la fiesta de la independencia?',
audioText: 'La independencia se celebra en julio.',
emoji: '🎉',
options: [
{ label: 'Julio', correct: true, id: 'julio' },
{ label: 'Mayo', correct: false, id: 'mayo' },
{ label: 'Agosto', correct: false, id: 'agosto' },
],
},
functionalContext: 'Historia argentina: fechas patrias.',
errorType: 'discriminacion-auditiva',
},
]
const exercises: any[] = []
for (let i = 0; i < ejercicios.length; i++) {
const e = ejercicios[i]
exercises.push({
...e,
id: e.id,
stage: 1,
skillCode: 'historia-argentina',
type: 'eleccion',
prerequisites: i === 0 ? [] : [ejercicios[i - 1].id],
})
}
const stage: Stage = {
id: 1,
name: 'Historia argentina nivel 1',
description: 'Conocer personajes, fechas y símbolos patrios argentinos.',
exercises,
}
export default stage
+86
View File
@@ -0,0 +1,86 @@
import type { Stage } from '../types'
const ejercicios = [
{
id: 'seb-h2-1',
content: {
instruction: 'La Revolución de Mayo fue en 1810. ¿Qué pasó ese día?',
audioText: 'El 25 de Mayo de 1810 empezó la independencia argentina.',
emoji: '📜',
options: [
{ label: 'Crearon el primer gobierno patrio', correct: true, id: 'gobierno' },
{ label: 'Ganaron la batalla de San Lorenzo', correct: false, id: 'batalla' },
{ label: 'Cruzaron los Andes', correct: false, id: 'andes' },
],
},
functionalContext: 'Historia argentina: hechos históricos.',
errorType: 'omision-letra',
},
{
id: 'seb-h2-2',
content: {
instruction: '¿Quién liberó Chile y Perú?',
audioText: 'San Martín liberó Chile y Perú.',
emoji: '⚔️',
options: [
{ label: 'San Martín', correct: true, id: 'sanmartin' },
{ label: 'Belgrano', correct: false, id: 'belgrano' },
{ label: 'Rivadavia', correct: false, id: 'rivadavia' },
],
},
functionalContext: 'Historia argentina: campañas militares.',
errorType: 'confusion-letra',
},
{
id: 'seb-h2-3',
content: {
instruction: 'El Ejército de los Andes cruzó la cordillera. ¿Cuántos soldados eran?',
audioText: 'El Ejército de los Andes tenía entre 5 y 7 mil soldados.',
emoji: '⛰️',
options: [
{ label: 'Entre 5 y 7 mil', correct: true, id: '5-7k' },
{ label: '10 mil', correct: false, id: '10k' },
{ label: '500', correct: false, id: '500' },
],
},
functionalContext: 'Historia argentina: hechos militares.',
errorType: 'discriminacion-auditiva',
},
{
id: 'seb-h2-4',
content: {
instruction: '¿Qué es el Cabildo?',
audioText: 'El Cabildo fue un edificio donde se reunían los vecinos.',
emoji: '🏛️',
options: [
{ label: 'Un edificio donde se reunían', correct: true, id: 'cabildo-reunion' },
{ label: 'Un barco', correct: false, id: 'barco' },
{ label: 'Un monumento', correct: false, id: 'monumento' },
],
},
functionalContext: 'Historia argentina: instituciones.',
errorType: 'omision-letra',
},
]
const exercises: any[] = []
for (let i = 0; i < ejercicios.length; i++) {
const e = ejercicios[i]
exercises.push({
...e,
id: e.id,
stage: 2,
skillCode: 'historia-avanzada',
type: 'eleccion',
prerequisites: i === 0 ? ['seb-h1-1'] : [ejercicios[i - 1].id],
})
}
const stage: Stage = {
id: 2,
name: 'Historia argentina nivel 2',
description: 'Hechos militares y personajes históricos avanzados.',
exercises,
}
export default stage
+92
View File
@@ -0,0 +1,92 @@
import type { Stage } from '../types'
const ejercicios = [
{
id: 'seb-suma-1',
content: {
instruction: 'Sumá: 15 + 27',
audioText: '15 más 27 es',
emoji: '',
},
options: [
{ label: '42', correct: true, id: '42' },
{ label: '40', correct: false, id: '40' },
{ label: '45', correct: false, id: '45' },
],
},
{
id: 'seb-suma-2',
content: {
instruction: 'Sumá: 34 + 56',
audioText: '34 más 56 es',
emoji: '',
},
options: [
{ label: '90', correct: true, id: '90' },
{ label: '80', correct: false, id: '80' },
{ label: '100', correct: false, id: '100' },
],
},
{
id: 'seb-suma-3',
content: {
instruction: 'Sumá: 78 + 22',
audioText: '78 más 22 es',
emoji: '',
},
options: [
{ label: '100', correct: true, id: '100' },
{ label: '99', correct: false, id: '99' },
{ label: '101', correct: false, id: '101' },
],
},
{
id: 'seb-suma-4',
content: {
instruction: 'Sumá: 45 + 55',
audioText: '45 más 55 es',
emoji: '',
},
options: [
{ label: '100', correct: true, id: '100' },
{ label: '95', correct: false, id: '95' },
{ label: '110', correct: false, id: '110' },
],
},
{
id: 'seb-suma-5',
content: {
instruction: 'Sumá: 123 + 456',
audioText: '123 más 456 es',
emoji: '',
},
options: [
{ label: '579', correct: true, id: '579' },
{ label: '569', correct: false, id: '569' },
{ label: '580', correct: false, id: '580' },
],
},
]
const exercises: any[] = []
for (let i = 0; i < ejercicios.length; i++) {
const e = ejercicios[i]
exercises.push({
...e,
stage: 1,
skillCode: `seb-suma-${i + 1}`,
type: 'eleccion',
prerequisites: i === 0 ? [] : [ejercicios[i - 1].id],
functionalContext: 'Sumas de 2-3 dígitos nivel inicial.',
errorType: 'discriminacion-auditiva',
})
}
const stage: Stage = {
id: 1,
name: 'Sumas 2-3 dígitos',
description: 'Resolver sumas de 2 y 3 dígitos de forma fluida.',
exercises,
}
export default stage
+98
View File
@@ -0,0 +1,98 @@
import type { Stage } from '../types'
const ejercicios = [
{
id: 'seb2-manzanas',
type: 'conciencia',
content: {
instruction: 'Agarrá 3 manzanas y 4 naranjas. ¿Cuántas frutas tenés?',
audioText: '3 más 4 es',
emoji: '🍎🍊',
options: [
{ label: '7', correct: true, id: '7' },
{ label: '6', correct: false, id: '6' },
{ label: '8', correct: false, id: '8' },
],
},
},
{
id: 'seb2-dedos',
type: 'conciencia',
content: {
instruction: 'Mostrá 5 dedos de una mano y 3 de la otra. ¿Cuántos dedos hay?',
audioText: '5 más 3 es',
emoji: '🖐️',
options: [
{ label: '8', correct: true, id: '8' },
{ label: '7', correct: false, id: '7' },
{ label: '9', correct: false, id: '9' },
],
},
},
{
id: 'seb2-pasos',
type: 'conciencia',
content: {
instruction: 'Caminaste 20 pasos. Ahora caminaste 15 más. ¿Cuántos pasos en total?',
audioText: '20 más 15 es',
emoji: '👣',
options: [
{ label: '35', correct: true, id: '35' },
{ label: '30', correct: false, id: '30' },
{ label: '40', correct: false, id: '40' },
],
},
},
{
id: 'seb2-bloques',
type: 'conciencia',
content: {
instruction: 'Tenés 10 bloques rojos y 12 bloques azules. ¿Cuántos bloques tenés?',
audioText: '10 más 12 es',
emoji: '🧱',
options: [
{ label: '22', correct: true, id: '22' },
{ label: '21', correct: false, id: '21' },
{ label: '23', correct: false, id: '23' },
],
},
},
{
id: 'seb2-monedas',
type: 'conciencia',
content: {
instruction: 'Tenés 50 pesos y encontrás 75 más. ¿Cuántos pesos tenés?',
audioText: '50 más 75 es',
emoji: '🪙',
options: [
{ label: '125', correct: true, id: '125' },
{ label: '115', correct: false, id: '115' },
{ label: '130', correct: false, id: '130' },
],
},
},
]
const exercises: any[] = []
for (let i = 0; i < ejercicios.length; i++) {
const e = ejercicios[i]
exercises.push({
id: e.id,
stage: 2,
skillCode: `suma-inter-${e.id}`,
type: e.type as any,
prerequisites: i === 0 ? ['seb-suma-1'] : [ejercicios[i - 1].id],
...e.content,
functionalContext: 'Ejercicios interactivos de sumas con objetos cotidianos.',
errorType: 'discriminacion-auditiva',
})
}
const stage: Stage = {
id: 2,
name: 'Sumas interactivas',
description: 'Resolver sumas con objetos cotidianos: frutas, bloques, pasos.',
exercises,
}
export default stage
+39
View File
@@ -0,0 +1,39 @@
import type { Stage } from '../types'
const desafios = [
{ a: 45, b: 67, res: 112 }, { a: 99, b: 88, res: 187 }, { a: 150, b: 250, res: 400 },
{ a: 333, b: 444, res: 777 }, { a: 200, b: 555, res: 755 }, { a: 123, b: 321, res: 444 },
]
const exercises: any[] = []
for (let i = 0; i < desafios.length; i++) {
const d = desafios[i]
exercises.push({
id: `seb3-desafio-${i}`,
stage: 3,
skillCode: `suma-challenge-${i}`,
type: 'eleccion',
prerequisites: i === 0 ? ['seb2-monedas'] : [`seb3-desafio-${i - 1}`],
content: {
instruction: `Desafío: ${d.a} + ${d.b}`,
audioText: `Sumá: ${d.a} más ${d.b}`,
emoji: '🏆',
options: [
{ label: String(d.res), correct: true, id: String(d.res) },
{ label: String(d.res + 10), correct: false, id: `c${i}a` },
{ label: String(d.res - 5), correct: false, id: `c${i}b` },
].sort(() => Math.random() - 0.5),
},
functionalContext: 'Desafío de sumas avanzadas. Utilizá descomposición.',
errorType: 'discriminacion-auditiva',
})
}
const stage: Stage = {
id: 3,
name: 'Desafíos de sumas',
description: 'Sumas 3 dígitos desafiantes con método Montessori.',
exercises,
}
export default stage
+69 -6
View File
@@ -26,11 +26,9 @@ test.describe("Exercise content integrity", () => {
})
}
test("curriculum index imports all stages via loadStages", () => {
test("curriculum index loads Isabella stages [1..9]", () => {
const file = join(process.cwd(), "curriculum", "index.ts")
const content = readFileSync(file, "utf-8")
expect(content).toContain("initCurriculum")
expect(content).toContain("loadStages")
expect(content).toContain("[1, 2, 3, 4, 5, 6, 7, 8, 9]")
})
@@ -70,9 +68,74 @@ test.describe("Exercise content integrity", () => {
}
})
test("TTS route exists", () => {
const file = join(process.cwd(), "app", "api", "tts", "route.ts")
test("Francesca matemáticas etapa 1..4 exist", () => {
for (let i = 1; i <= 4; i++) {
const file = join(process.cwd(), "curriculum", "francesca", `etapa-${i}.ts`)
const content = readFileSync(file, "utf-8")
expect(content.length).toBeGreaterThan(100)
expect(content).toContain("stage")
expect(content).toContain("exercises")
}
})
test("Francesca lecturas etapa 1..2 exist", () => {
for (let i = 1; i <= 2; i++) {
const file = join(process.cwd(), "curriculum", "francesca-lectura", `etapa-${i}.ts`)
const content = readFileSync(file, "utf-8")
expect(content.length).toBeGreaterThan(100)
}
})
test("Sebastián sumas etapa 1..3 exist", () => {
for (let i = 1; i <= 3; i++) {
const file = join(process.cwd(), "curriculum", "sebastian", `etapa-${i}.ts`)
const content = readFileSync(file, "utf-8")
expect(content.length).toBeGreaterThan(100)
}
})
test("Sebastián historia etapa 1..2 exist", () => {
for (let i = 1; i <= 2; i++) {
const file = join(process.cwd(), "curriculum", "sebastian-historia", `etapa-${i}.ts`)
const content = readFileSync(file, "utf-8")
expect(content.length).toBeGreaterThan(100)
}
})
test("Sebastián banderas etapa 1 exist", () => {
const file = join(process.cwd(), "curriculum", "sebastian-banderas", "etapa-1.ts")
const content = readFileSync(file, "utf-8")
expect(content).toContain("espeak-ng")
expect(content.length).toBeGreaterThan(100)
})
test("exercise-session component exists", () => {
const file = join(process.cwd(), "components", "child", "exercise-session.tsx")
const content = readFileSync(file, "utf-8")
expect(content.length).toBeGreaterThan(200)
})
test("multi-profile pages exist", () => {
const pages = [
"app/francesca/page.tsx",
"app/francesca/matematicas/page.tsx",
"app/francesca/lecturas/page.tsx",
"app/sebastian/page.tsx",
"app/sebastian/sumas/page.tsx",
"app/sebastian/historia/page.tsx",
"app/sebastian/banderas/page.tsx",
]
for (const p of pages) {
const file = join(process.cwd(), p)
const content = readFileSync(file, "utf-8")
expect(content.length).toBeGreaterThan(50)
}
})
test("root page is profile selector with 3 names", () => {
const file = join(process.cwd(), "app", "page.tsx")
const content = readFileSync(file, "utf-8")
expect(content).toContain("Isabella")
expect(content).toContain("Francesca")
expect(content).toContain("Sebastián")
})
})
+51 -7
View File
@@ -1,9 +1,53 @@
import { prisma } from "@/lib/db"
import { initCurriculum, getLecturaStages, getNumerosStages, getAllLecturaExercises } from "@/curriculum/index"
import type { Exercise } from "@/curriculum/types"
import type { Exercise, Stage } from "@/curriculum/types"
const STAGE_THRESHOLD = 0.8
const PROFILE_CURRICULA: Record<string, Record<string, { folder: string; stages: number[] }>> = {
ISABELLA: {
lectura: { folder: "lectura", stages: [1, 2, 3, 4, 5, 6, 7, 8, 9] },
numeros: { folder: "numeros", stages: [1, 2, 3, 4, 5, 6, 7, 8] },
},
FRANCESCA: {
matematicas: { folder: "francesca", stages: [1, 2, 3, 4] },
lecturas: { folder: "francesca-lectura", stages: [1, 2] },
},
SEBASTIAN: {
sumas: { folder: "sebastian", stages: [1, 2, 3] },
historia: { folder: "sebastian-historia", stages: [1, 2] },
banderas: { folder: "sebastian-banderas", stages: [1] },
},
}
const curriculumCache: Record<string, Record<string, Stage>> = {}
async function getStagesForProfile(
profile: string,
topic: string,
): Promise<Record<string, Stage>> {
const key = `${profile}-${topic}`
// Clear stale cache on every dev call (Next.js hot-reload shares module-level vars)
if (curriculumCache[key]) delete curriculumCache[key]
if (curriculumCache[key]) return curriculumCache[key]
const config = PROFILE_CURRICULA[profile]?.[topic]
if (!config) return {}
const stages: Record<string, Stage> = {}
for (const s of config.stages) {
try {
const mod = await import(`@/curriculum/${config.folder}/etapa-${s}`)
stages[String(s)] = mod.default
} catch {
// stage not yet implemented
}
}
curriculumCache[key] = stages
return stages
}
async function ensureInitialStage(childId: string, topic: string) {
try {
const exists = await prisma.curriculumProgress.findFirst({
@@ -30,9 +74,9 @@ export async function getCurrentStage(childId: string, topic: string) {
export async function getNextExercise(
childId: string,
topic: "lectura" | "numeros",
topic: string,
profile: string = "ISABELLA",
): Promise<Exercise | null> {
await initCurriculum()
await ensureInitialStage(childId, topic)
const progress = await prisma.curriculumProgress.findFirst({
@@ -42,8 +86,8 @@ export async function getNextExercise(
if (!progress) return null
const stages = topic === "lectura" ? getLecturaStages() : getNumerosStages()
const stage = stages[progress.stage]
const stages = await getStagesForProfile(profile, topic)
const stage = stages[String(progress.stage)]
if (!stage || !stage.exercises.length) return null
const allAttemps = await prisma.skillAttempt.findMany({
@@ -70,7 +114,7 @@ export async function getNextExercise(
const masteryRatio = mastered.size / stage.exercises.length
if (masteryRatio >= STAGE_THRESHOLD) {
await advanceStage(childId, topic, progress.stage)
return getNextExercise(childId, topic)
return getNextExercise(childId, topic, profile)
}
}
return null
+7
View File
@@ -7,6 +7,12 @@ datasource db {
url = env("DATABASE_URL")
}
enum Profile {
ISABELLA
FRANCESCA
SEBASTIAN
}
model Parent {
id String @id @default(cuid())
email String @unique
@@ -75,6 +81,7 @@ model Family {
model Child {
id String @id @default(cuid())
name String?
profile Profile @default(ISABELLA)
avatar String?
birthdate DateTime?
profileNotes String?