From 25614704dc8b67b2f6be81fedad2916d8e1f8a78 Mon Sep 17 00:00:00 2001 From: renato97 Date: Wed, 22 Jul 2026 21:48:19 -0300 Subject: [PATCH] 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 --- app/api/curriculum/next/route.ts | 11 +- app/francesca/lecturas/page.tsx | 12 ++ app/francesca/matematicas/page.tsx | 12 ++ app/francesca/page.tsx | 65 ++++++++ app/page.tsx | 34 ++++- app/sebastian/banderas/page.tsx | 12 ++ app/sebastian/historia/page.tsx | 12 ++ app/sebastian/page.tsx | 66 +++++++++ app/sebastian/sumas/page.tsx | 12 ++ components/child/exercise-session.tsx | 179 +++++++++++++++++++++++ curriculum/francesca-lectura/etapa-1.ts | 86 +++++++++++ curriculum/francesca-lectura/etapa-2.ts | 68 +++++++++ curriculum/francesca/etapa-1.ts | 51 +++++++ curriculum/francesca/etapa-2.ts | 42 ++++++ curriculum/francesca/etapa-3.ts | 44 ++++++ curriculum/francesca/etapa-4.ts | 112 ++++++++++++++ curriculum/sebastian-banderas/etapa-1.ts | 73 +++++++++ curriculum/sebastian-historia/etapa-1.ts | 116 +++++++++++++++ curriculum/sebastian-historia/etapa-2.ts | 86 +++++++++++ curriculum/sebastian/etapa-1.ts | 92 ++++++++++++ curriculum/sebastian/etapa-2.ts | 98 +++++++++++++ curriculum/sebastian/etapa-3.ts | 39 +++++ e2e/exercise-content.spec.ts | 75 +++++++++- lib/curriculum/engine.ts | 58 +++++++- prisma/schema.prisma | 7 + 25 files changed, 1444 insertions(+), 18 deletions(-) create mode 100644 app/francesca/lecturas/page.tsx create mode 100644 app/francesca/matematicas/page.tsx create mode 100644 app/francesca/page.tsx create mode 100644 app/sebastian/banderas/page.tsx create mode 100644 app/sebastian/historia/page.tsx create mode 100644 app/sebastian/page.tsx create mode 100644 app/sebastian/sumas/page.tsx create mode 100644 components/child/exercise-session.tsx create mode 100644 curriculum/francesca-lectura/etapa-1.ts create mode 100644 curriculum/francesca-lectura/etapa-2.ts create mode 100644 curriculum/francesca/etapa-1.ts create mode 100644 curriculum/francesca/etapa-2.ts create mode 100644 curriculum/francesca/etapa-3.ts create mode 100644 curriculum/francesca/etapa-4.ts create mode 100644 curriculum/sebastian-banderas/etapa-1.ts create mode 100644 curriculum/sebastian-historia/etapa-1.ts create mode 100644 curriculum/sebastian-historia/etapa-2.ts create mode 100644 curriculum/sebastian/etapa-1.ts create mode 100644 curriculum/sebastian/etapa-2.ts create mode 100644 curriculum/sebastian/etapa-3.ts diff --git a/app/api/curriculum/next/route.ts b/app/api/curriculum/next/route.ts index 9672526..e8720cb 100644 --- a/app/api/curriculum/next/route.ts +++ b/app/api/curriculum/next/route.ts @@ -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 }) } diff --git a/app/francesca/lecturas/page.tsx b/app/francesca/lecturas/page.tsx new file mode 100644 index 0000000..0163bb6 --- /dev/null +++ b/app/francesca/lecturas/page.tsx @@ -0,0 +1,12 @@ +import ExerciseSession from "@/components/child/exercise-session" + +export default function FrancescaLecturas() { + return ( + + ) +} diff --git a/app/francesca/matematicas/page.tsx b/app/francesca/matematicas/page.tsx new file mode 100644 index 0000000..640b7e1 --- /dev/null +++ b/app/francesca/matematicas/page.tsx @@ -0,0 +1,12 @@ +import ExerciseSession from "@/components/child/exercise-session" + +export default function FrancescaMate() { + return ( + + ) +} diff --git a/app/francesca/page.tsx b/app/francesca/page.tsx new file mode 100644 index 0000000..280ae00 --- /dev/null +++ b/app/francesca/page.tsx @@ -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 setAudioReady(true)} /> + } + + return ( +
+ + + {!paired ? ( +
+

Todavía no estás conectada. Pedile a mamá que te empareje.

+ +
+ ) : ( +
+ {activities.map((a) => ( + + ))} +
+ )} +
+ ) +} diff --git a/app/page.tsx b/app/page.tsx index 8d35acc..9803178 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -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 ( +
+

¿Quién sos?

+ +
+ {PROFILES.map((p) => ( + + ))} +
+
+ ) } diff --git a/app/sebastian/banderas/page.tsx b/app/sebastian/banderas/page.tsx new file mode 100644 index 0000000..229d1b3 --- /dev/null +++ b/app/sebastian/banderas/page.tsx @@ -0,0 +1,12 @@ +import ExerciseSession from "@/components/child/exercise-session" + +export default function SebastianBanderas() { + return ( + + ) +} diff --git a/app/sebastian/historia/page.tsx b/app/sebastian/historia/page.tsx new file mode 100644 index 0000000..ee17ef3 --- /dev/null +++ b/app/sebastian/historia/page.tsx @@ -0,0 +1,12 @@ +import ExerciseSession from "@/components/child/exercise-session" + +export default function SebastianHistoria() { + return ( + + ) +} diff --git a/app/sebastian/page.tsx b/app/sebastian/page.tsx new file mode 100644 index 0000000..54bc73a --- /dev/null +++ b/app/sebastian/page.tsx @@ -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 setAudioReady(true)} /> + } + + return ( +
+ + + {!paired ? ( +
+

Todavía no estás conectado. Pedile a mamá que te empareje.

+ +
+ ) : ( +
+ {activities.map((a) => ( + + ))} +
+ )} +
+ ) +} diff --git a/app/sebastian/sumas/page.tsx b/app/sebastian/sumas/page.tsx new file mode 100644 index 0000000..22d99d2 --- /dev/null +++ b/app/sebastian/sumas/page.tsx @@ -0,0 +1,12 @@ +import ExerciseSession from "@/components/child/exercise-session" + +export default function SebastianSumas() { + return ( + + ) +} diff --git a/components/child/exercise-session.tsx b/components/child/exercise-session.tsx new file mode 100644 index 0000000..2d502bc --- /dev/null +++ b/components/child/exercise-session.tsx @@ -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("loading") + const [exercise, setExercise] = useState(null) + const [running, setRunning] = useState(true) + const [score, setScore] = useState(0) + const [totalAttempts, setTotalAttempts] = useState(0) + const childIdRef = useRef("") + 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 ( +
+ +
+ ) + } + + if (pageState === "error") { + return + } + + if (pageState === "complete") { + return ( +
+ +

+ Aciertos: {score}/{totalAttempts} +

+ +
+ ) + } + + return ( +
+ { + setRunning(false) + setPageState("complete") + }} + running={running} + /> + + + + {exercise && ( + + )} + + +
+ ) +} diff --git a/curriculum/francesca-lectura/etapa-1.ts b/curriculum/francesca-lectura/etapa-1.ts new file mode 100644 index 0000000..5ca1347 --- /dev/null +++ b/curriculum/francesca-lectura/etapa-1.ts @@ -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 diff --git a/curriculum/francesca-lectura/etapa-2.ts b/curriculum/francesca-lectura/etapa-2.ts new file mode 100644 index 0000000..8cbe675 --- /dev/null +++ b/curriculum/francesca-lectura/etapa-2.ts @@ -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 diff --git a/curriculum/francesca/etapa-1.ts b/curriculum/francesca/etapa-1.ts new file mode 100644 index 0000000..f413849 --- /dev/null +++ b/curriculum/francesca/etapa-1.ts @@ -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 diff --git a/curriculum/francesca/etapa-2.ts b/curriculum/francesca/etapa-2.ts new file mode 100644 index 0000000..04ee96c --- /dev/null +++ b/curriculum/francesca/etapa-2.ts @@ -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 diff --git a/curriculum/francesca/etapa-3.ts b/curriculum/francesca/etapa-3.ts new file mode 100644 index 0000000..be134dd --- /dev/null +++ b/curriculum/francesca/etapa-3.ts @@ -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 diff --git a/curriculum/francesca/etapa-4.ts b/curriculum/francesca/etapa-4.ts new file mode 100644 index 0000000..0b08533 --- /dev/null +++ b/curriculum/francesca/etapa-4.ts @@ -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 diff --git a/curriculum/sebastian-banderas/etapa-1.ts b/curriculum/sebastian-banderas/etapa-1.ts new file mode 100644 index 0000000..4e493d0 --- /dev/null +++ b/curriculum/sebastian-banderas/etapa-1.ts @@ -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 diff --git a/curriculum/sebastian-historia/etapa-1.ts b/curriculum/sebastian-historia/etapa-1.ts new file mode 100644 index 0000000..10038f1 --- /dev/null +++ b/curriculum/sebastian-historia/etapa-1.ts @@ -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 diff --git a/curriculum/sebastian-historia/etapa-2.ts b/curriculum/sebastian-historia/etapa-2.ts new file mode 100644 index 0000000..65377d3 --- /dev/null +++ b/curriculum/sebastian-historia/etapa-2.ts @@ -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 diff --git a/curriculum/sebastian/etapa-1.ts b/curriculum/sebastian/etapa-1.ts new file mode 100644 index 0000000..65d52f3 --- /dev/null +++ b/curriculum/sebastian/etapa-1.ts @@ -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 diff --git a/curriculum/sebastian/etapa-2.ts b/curriculum/sebastian/etapa-2.ts new file mode 100644 index 0000000..ea1507f --- /dev/null +++ b/curriculum/sebastian/etapa-2.ts @@ -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 diff --git a/curriculum/sebastian/etapa-3.ts b/curriculum/sebastian/etapa-3.ts new file mode 100644 index 0000000..d0875e3 --- /dev/null +++ b/curriculum/sebastian/etapa-3.ts @@ -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 diff --git a/e2e/exercise-content.spec.ts b/e2e/exercise-content.spec.ts index 6ab6efb..12b88a2 100644 --- a/e2e/exercise-content.spec.ts +++ b/e2e/exercise-content.spec.ts @@ -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") }) }) diff --git a/lib/curriculum/engine.ts b/lib/curriculum/engine.ts index 032e613..9e4eff9 100644 --- a/lib/curriculum/engine.ts +++ b/lib/curriculum/engine.ts @@ -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> = { + 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> = {} + +async function getStagesForProfile( + profile: string, + topic: string, +): Promise> { + 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 = {} + 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 { - 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 diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 8438a42..958d68e 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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?