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:
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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!"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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!"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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!"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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!"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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!"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,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?
|
||||
|
||||
Reference in New Issue
Block a user