feat: Puente Mayúscula-Minúscula + TTS SpeechSynthesis + E2E fixes
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"fingerprint": "ee41cd16aee142978eedd00dcf1854a07a534d9e"
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
# Skill Registry — edueasy
|
||||
|
||||
<!-- Auto-generated by gentle-ai skill-registry refresh. Run `gentle-ai skill-registry refresh --force` to regenerate. -->
|
||||
|
||||
Last updated: 2026-07-20
|
||||
|
||||
## Sources scanned
|
||||
|
||||
- /home/ren/.config/opencode/skills
|
||||
- /home/ren/.codex/skills
|
||||
|
||||
## Contract
|
||||
|
||||
**Delegator use only.** This registry is an index, not a summary. Any agent that launches subagents reads it to select relevant skills, then passes exact `SKILL.md` paths for the subagent to read before work.
|
||||
|
||||
`SKILL.md` remains the source of truth. Do not inject generated summaries or compact rules by default; pass paths so subagents load the full runtime contract and preserve author intent.
|
||||
|
||||
## Skills
|
||||
|
||||
| Skill | Trigger / description | Scope | Path |
|
||||
| --- | --- | --- | --- |
|
||||
| `branch-pr` | Create Gentle AI pull requests with issue-first checks. Trigger: creating, opening, or preparing PRs for review. | user | `/home/ren/.config/opencode/skills/branch-pr/SKILL.md` |
|
||||
| `chained-pr` | Trigger: PRs over 400 lines, stacked PRs, review slices. Split oversized changes into chained PRs that protect review focus. | user | `/home/ren/.config/opencode/skills/chained-pr/SKILL.md` |
|
||||
| `cognitive-doc-design` | Design docs that reduce cognitive load. Trigger: writing guides, READMEs, RFCs, onboarding, architecture, or review-facing docs. | user | `/home/ren/.config/opencode/skills/cognitive-doc-design/SKILL.md` |
|
||||
| `comment-writer` | Write warm, direct collaboration comments. Trigger: PR feedback, issue replies, reviews, Slack messages, or GitHub comments. | user | `/home/ren/.config/opencode/skills/comment-writer/SKILL.md` |
|
||||
| `go-testing` | Trigger: Go tests, go test coverage, Bubbletea teatest, golden files. Apply focused Go testing patterns. | user | `/home/ren/.config/opencode/skills/go-testing/SKILL.md` |
|
||||
| `issue-creation` | Create Gentle AI issues with issue-first checks. Trigger: creating GitHub issues, bug reports, or feature requests. | user | `/home/ren/.config/opencode/skills/issue-creation/SKILL.md` |
|
||||
| `judgment-day` | Trigger: judgment day, dual review, adversarial review, juzgar. Run blind dual review, fix confirmed issues, then re-judge. | user | `/home/ren/.config/opencode/skills/judgment-day/SKILL.md` |
|
||||
| `skill-creator` | Trigger: new skills, agent instructions, documenting AI usage patterns. Create LLM-first skills with valid frontmatter. | user | `/home/ren/.config/opencode/skills/skill-creator/SKILL.md` |
|
||||
| `skill-improver` | Trigger: improve skills, audit skills, refactor skills, skill quality. Audit and upgrade existing LLM-first skills. | user | `/home/ren/.config/opencode/skills/skill-improver/SKILL.md` |
|
||||
| `work-unit-commits` | Plan commits as reviewable work units. Trigger: implementation, commit splitting, chained PRs, or keeping tests and docs with code. | user | `/home/ren/.config/opencode/skills/work-unit-commits/SKILL.md` |
|
||||
|
||||
## Loading protocol
|
||||
|
||||
1. Match task context and target files against the `Trigger / description` column.
|
||||
2. Pass only the matching `Path` values to the subagent under `## Skills to load before work`.
|
||||
3. Instruct the subagent to read those exact `SKILL.md` files before reading, writing, reviewing, testing, or creating artifacts.
|
||||
4. If no matching skill exists, proceed without project skill injection and report `skill_resolution: none`.
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
node_modules/
|
||||
.next/
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
.env
|
||||
.env.local
|
||||
.env*.local
|
||||
.env.local
|
||||
.env*.local
|
||||
.cache/
|
||||
dist/
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
# Stage 1: Install dependencies
|
||||
FROM node:22-alpine AS deps
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
# Stage 2: Build
|
||||
FROM node:22-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
RUN npx prisma generate
|
||||
RUN npm run build
|
||||
|
||||
# Stage 3: Production
|
||||
FROM node:22-alpine AS runner
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/prisma ./prisma
|
||||
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
@@ -0,0 +1,219 @@
|
||||
# EduEasy — Plan de Plataforma
|
||||
|
||||
## Arquitectura General
|
||||
|
||||
Plataforma educativa para una nena de 8 años con déficit de atención, baja abstractividad, que no sabe leer ni contar (bases malas en 1er grado). Dos caras en un mismo server:
|
||||
|
||||
| App | Dominio | Dispositivo | Usuario |
|
||||
|-----|---------|-------------|---------|
|
||||
| **Aprendizaje** | `mate.cbcren.online` | iPad Air 4 (solo dedo) | La nena |
|
||||
| **Dashboard** | `simulacro.cbcren.online` | Poco F6 Pro | La mamá |
|
||||
|
||||
**Stack**: Next.js 15 + React 19 + TypeScript estricto + Tailwind CSS + shadcn/ui + Better Auth + Prisma + PostgreSQL + ts-fsrs
|
||||
**Deploy**: All-in-one Docker (un proceso Next.js + Postgres) en red `caddy` del server existente (194.163.191.200)
|
||||
|
||||
## Decisiones de Diseño Cerradas
|
||||
|
||||
| Decisión | Respuesta |
|
||||
|----------|-----------|
|
||||
| **Mascota** | 🐱 **Gato** — guía las sesiones con instrucciones en audio, celebra logros, da feedback en errorless learning. Sin nombre fijo aún. |
|
||||
| **Paleta** | **Calmada** — fondo `#FFF8F0` crema/beige, primario `#8CB8A0` verde salvia, secundario `#6B8FA3` azul medio, acento `#D4A574` terracota, texto `#2D3436` (no negro puro). Bajas saturaciones en todos. |
|
||||
| **Audio** | **TTS por ahora** (Web Speech API, voz es-ES femenina). Se reemplaza por grabaciones reales es-AR en Fase 4+ cuando el contenido esté validado. |
|
||||
|
||||
---
|
||||
|
||||
## Pedagogía (Métodos Europeos)
|
||||
|
||||
| Área | Método | Aplicación |
|
||||
|------|--------|------------|
|
||||
| **Lectura** | Borel-Maisonny (FR) | Cada fonema tiene un gesto físico + sonido. La nena hace el gesto en el aire + traza con el dedo en la pantalla + escucha el audio. |
|
||||
| **Lectura** | Jolly Phonics (UK) adaptado | Orden de aprendizaje: no A-B-C, sino por frecuencia de uso. Vocales primero (A, E, I, O, U) + consonantes sonoras continuas (S, M, L, N). |
|
||||
| **Lectura** | Método silábico | Español = 5 vocales puras, sílabas CV, CVC, CCV. Silabeo sistemático desde el día 1. |
|
||||
| **Numeración** | CPA (Concrete-Pictórico-Abstracto) | **Concreto**: objetos (tapitas virtuales para contar). **Pictórico**: dibujos de objetos. **Abstracto**: el número símbolo. |
|
||||
| **Numeración** | Regletas Cuisenaire (Bélgica) | Cantidad como barra de color. 1=blanco, 2=rojo, 3=verde claro... La nena ve y toca la cantidad sin "contar" abstractamente. |
|
||||
| **Internalización** | Galperin (URSS) | **1. Mano**: manipular objetos. **2. Voz externa**: decir en voz alta. **3. Voz interna**: susurrar. **4. Mental**: hacerlo en la cabeza. |
|
||||
| **Errores** | Errorless learning | **Nunca** se muestra "incorrecto". Si titubea, el sistema da la pista más obvia (modelado). Si persiste el error, fadea la ayuda. |
|
||||
| **Refuerzo** | Fijo y predecible | Sin slots, sin FOMO, sin rachas ansiosas. Estrellita predecible después de cada ronda completa. |
|
||||
| **Sesiones** | Chunking estructurado | 10-15 min máximo. Temporizador visual tipo Time Timer. Micro-pausa motora cada 5 min (saltar, aplaudir). |
|
||||
|
||||
---
|
||||
|
||||
## Stack Técnico
|
||||
|
||||
```
|
||||
Frontend: Next.js 15 (App Router) + React 19 + TypeScript + Tailwind CSS
|
||||
UI Lib: shadcn/ui (componentes base)
|
||||
Auth: Better Auth (login mamá + QR pairing → JWT device-bound para nena)
|
||||
DB: PostgreSQL + Prisma ORM
|
||||
Spaced Rep: ts-fsrs (Free Spaced Repetition Scheduler)
|
||||
Canvas: perfect-freehand (trazo con dedo) + @use-gesture/react
|
||||
Audio: Web Speech API (TTS es-ES/es-MX, confirmado: TTS por ahora → reemplazar con grabaciones reales es-AR en Fase 4+)
|
||||
Animación: framer-motion (transiciones lentas 300-500ms, sin sparkles)
|
||||
Charts: Recharts (dashboard mamá)
|
||||
Deploy: Docker + red caddy existente
|
||||
```
|
||||
|
||||
### Repos reutilizados
|
||||
- `ts-fsrs` (npm) — algoritmo FSRS de repetición espaciada
|
||||
- `perfect-freehand` (npm) — trazado fluido con dedo (Excalidraw)
|
||||
- Better Auth (npm) — auth + device pairing
|
||||
- shadcn/ui (npm) — componentes base de UI
|
||||
- Math Learning Center apps — referencia UX de manipulativos virtuales (number-rack, ten-frames)
|
||||
|
||||
---
|
||||
|
||||
## Modelo de Datos
|
||||
|
||||
```prisma
|
||||
model Family { id, name, created_at }
|
||||
model Parent { id, family_id, email, name, auth_provider }
|
||||
model Child { id, family_id, name, avatar, birthdate, profile_notes }
|
||||
model Device { id, child_id, device_fingerprint, paired_at, last_seen, role }
|
||||
model Session { id, child_id, started_at, ended_at, duration_sec, skills_practiced }
|
||||
model SkillAttempt { id, session_id, skill_code, prompt_level, correct, response_ms }
|
||||
model FsrsCard { id, child_id, skill_code, due_at, stability, difficulty, reps, lapses }
|
||||
model Milestone { id, child_id, skill_code, reached_at }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Estructura del Proyecto
|
||||
|
||||
```
|
||||
/home/ren/edueasy/
|
||||
├── app/
|
||||
│ ├── globals.css
|
||||
│ ├── layout.tsx # Raíz (fonts, metadata)
|
||||
│ ├── page.tsx # Redirige según host
|
||||
│ ├── child/ # mate.cbcren.online
|
||||
│ │ ├── layout.tsx # Layout infantil (gato, audio siempre presente)
|
||||
│ │ ├── page.tsx # Menú principal de la nena
|
||||
│ │ ├── sesion/
|
||||
│ │ └── logros/
|
||||
│ ├── parent/ # simulacro.cbcren.online
|
||||
│ │ ├── layout.tsx # Layout dashboard + sidebar
|
||||
│ │ ├── page.tsx # Dashboard principal
|
||||
│ │ ├── auth/login/
|
||||
│ │ ├── auth/pairing/
|
||||
│ │ └── configuracion/
|
||||
│ └── api/
|
||||
│ ├── auth/[...all]/ # Better Auth handler
|
||||
│ ├── pairing/ # QR pairing endpoint
|
||||
│ └── sessions/sync/ # Sync offline → server
|
||||
├── components/
|
||||
│ ├── ui/ # shadcn/ui
|
||||
│ ├── child/ # Componentes de la nena
|
||||
│ │ ├── gato-mascota
|
||||
│ │ ├── trazo-letra (canvas tracing)
|
||||
│ │ ├── vocal-card
|
||||
│ │ ├── cuisenaire-rod
|
||||
│ │ ├── temporizador-visual
|
||||
│ │ └── boton-grande (60-80px)
|
||||
│ └── parent/ # Componentes del dashboard
|
||||
├── lib/
|
||||
│ ├── db.ts (Prisma client)
|
||||
│ ├── auth.ts (Better Auth server)
|
||||
│ ├── auth-client.ts
|
||||
│ ├── speech.ts (Web Speech API)
|
||||
│ ├── fsrs.ts (ts-fsrs wrapper)
|
||||
│ └── pedagogia/
|
||||
│ ├── borel-maisonny.ts (fonemas + gestos)
|
||||
│ ├── cuisenaire.ts (regletas)
|
||||
│ ├── errorless.ts (prompt fading)
|
||||
│ └── cpa-progression.ts (CPA estados)
|
||||
├── prisma/schema.prisma
|
||||
├── middleware.ts (host-based routing)
|
||||
├── Dockerfile
|
||||
├── docker-compose.yml
|
||||
├── package.json
|
||||
├── tsconfig.json
|
||||
├── next.config.ts
|
||||
├── tailwind.config.ts
|
||||
├── postcss.config.js
|
||||
├── components.json (shadcn/ui)
|
||||
└── .env.local
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## UX Constraints (iPad Air 4 + Dedo)
|
||||
|
||||
- **Touch targets**: 60–80px mínimo (baselines adultos 44px no alcanzan para niños)
|
||||
- **Drag > Tap** para interacciones principales
|
||||
- **Una sola acción focal** por pantalla
|
||||
- **100% iconografía + audio** (la nena NO LEE)
|
||||
- **Sin "X roja"** nunca — sonido neutro + mascota dice "probemos juntas"
|
||||
- **Fondo claro baja saturación** (crema/beige) + acentos verde salvia + azul medio
|
||||
- **Botón "Tocá para empezar"** en cada sesión (unlock AudioContext en iOS Safari)
|
||||
- **Persistencia al backend siempre** (Safari evicta IndexedDB a 7 días)
|
||||
|
||||
### iOS Safari Técnico
|
||||
- Audio: .m4a primario + .mp3 fallback. **NUNCA .ogg**.
|
||||
- AudioContext: requiere user gesture para desbloquear.
|
||||
- `100dvh` en vez de `100vh` (barra de Safari).
|
||||
- `touch-action: manipulation` para eliminar delay 300ms.
|
||||
- inputs con `font-size: 16px` mínimo (evita zoom automático).
|
||||
|
||||
---
|
||||
|
||||
## Roadmap
|
||||
|
||||
### Fase 0 — Fundaciones (1-2 sem)
|
||||
- [x] Repositorio + package.json + configs
|
||||
- [x] Docker + docker-compose
|
||||
- [x] Prisma schema + migración
|
||||
- [x] Better Auth + QR pairing
|
||||
- [x] Design system base (gato mascota, paleta, component primitives)
|
||||
- [x] Caddy config update
|
||||
|
||||
### Fase 1 — Lectura MVP
|
||||
- [ ] Módulo vocales: A, E, I, O, U
|
||||
- [ ] Audio TTS para cada fonema + palabra ejemplo
|
||||
- [ ] Gesto Borel-Maisonny (animación + instrucción)
|
||||
- [ ] Trazo con dedo (canvas + perfect-freehand)
|
||||
- [ ] Sesión guiada 10 min + temporizador visual
|
||||
- [ ] Errorless learning + fading automático
|
||||
- [ ] Logros por vocal completada
|
||||
|
||||
### Fase 2 — Numeración MVP
|
||||
- [ ] Módulo cantidades 1-5 (expandir a 10)
|
||||
- [ ] CPA: objetos → imagen → símbolo
|
||||
- [ ] Regletas Cuisenaire virtuales (arrastrar, comparar)
|
||||
- [ ] Conteo one-to-one con arrastre
|
||||
- [ ] Galperin: mano → voz → cabeza
|
||||
|
||||
### Fase 3 — Adaptativo + Dashboard
|
||||
- [ ] ts-fsrs: repaso espaciado por skill
|
||||
- [ ] Dashboard mamá: streak, tiempo, mastery %, último resumen
|
||||
- [ ] Push notifications al Poco F6 Pro
|
||||
- [ ] QR pairing funcional
|
||||
- [ ] Export de progreso
|
||||
|
||||
### Fase 4+ (futuro)
|
||||
- [ ] Consonantes y sílabas completas
|
||||
- [ ] Números 11-100, suma/resta
|
||||
- [ ] Grabaciones de audio real (es-AR)
|
||||
- [ ] IA adaptativa (Ollama/Open WebUI)
|
||||
|
||||
---
|
||||
|
||||
## Diseño Visual
|
||||
|
||||
**Mascota**: 🐱 Gato (sin nombre definido — que la nena lo nombre).
|
||||
- Guía las sesiones, da instrucciones en audio, celebra logros.
|
||||
- Animaciones suaves (no bruscas), tamaño grande en pantalla.
|
||||
|
||||
**Paleta**:
|
||||
- Fondo: `#FFF8F0` (crema suave)
|
||||
- Primario: `#8CB8A0` (verde salvia)
|
||||
- Secundario: `#6B8FA3` (azul medio)
|
||||
- Acento: `#D4A574` (terracota suave)
|
||||
- Texto: `#2D3436` (gris oscuro, no negro puro)
|
||||
- Correcto: `#7FB685` (verde suave, no brillante)
|
||||
|
||||
**Tipografía**:
|
||||
- Títulos: Fredoka One (redondeada, amigable, legible)
|
||||
- Interface: Inter (sistema, para dashboard de la mamá)
|
||||
- La nena no ve texto — solo escucha. Las letras aparecen grandes, claras, bien trazadas.
|
||||
|
||||
---
|
||||
@@ -0,0 +1,4 @@
|
||||
import { auth } from "@/lib/auth"
|
||||
import { toNextJsHandler } from "better-auth/next-js"
|
||||
|
||||
export const { POST, GET } = toNextJsHandler(auth)
|
||||
@@ -0,0 +1,57 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { prisma } from "@/lib/db"
|
||||
import { auth } from "@/lib/auth"
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const session = await auth.api.getSession({ headers: request.headers })
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "No autorizado" }, { status: 401 })
|
||||
}
|
||||
|
||||
const parent = await prisma.parent.findUnique({
|
||||
where: { id: session.user.id },
|
||||
include: {
|
||||
family: {
|
||||
include: { children: true },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (!parent || !parent.family) {
|
||||
return NextResponse.json({ error: "Parent or family not found" }, { status: 404 })
|
||||
}
|
||||
|
||||
return NextResponse.json(parent.family.children)
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const session = await auth.api.getSession({ headers: request.headers })
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "No autorizado" }, { status: 401 })
|
||||
}
|
||||
|
||||
const { name, birthdate, profileNotes } = await request.json()
|
||||
if (!name) {
|
||||
return NextResponse.json({ error: "name required" }, { status: 400 })
|
||||
}
|
||||
|
||||
const parent = await prisma.parent.findUnique({
|
||||
where: { id: session.user.id },
|
||||
select: { familyId: true },
|
||||
})
|
||||
|
||||
if (!parent || !parent.familyId) {
|
||||
return NextResponse.json({ error: "Parent has no family" }, { status: 400 })
|
||||
}
|
||||
|
||||
const child = await prisma.child.create({
|
||||
data: {
|
||||
name,
|
||||
birthdate: birthdate ? new Date(birthdate) : undefined,
|
||||
profileNotes,
|
||||
familyId: parent.familyId,
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json(child, { status: 201 })
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { prisma } from "@/lib/db"
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { childId, exerciseId, skillCode, correct, promptLevel, responseMs, errorType, stage } = await request.json()
|
||||
|
||||
if (!childId || !exerciseId || !skillCode) {
|
||||
return NextResponse.json({ error: "Missing required fields" }, { status: 400 })
|
||||
}
|
||||
|
||||
const sessionLog = await prisma.sessionLog.findFirst({
|
||||
where: { childId },
|
||||
orderBy: { startedAt: "desc" },
|
||||
})
|
||||
|
||||
if (!sessionLog) {
|
||||
return NextResponse.json({ error: "No active session" }, { status: 400 })
|
||||
}
|
||||
|
||||
await prisma.skillAttempt.create({
|
||||
data: {
|
||||
sessionLogId: sessionLog.id,
|
||||
skillCode,
|
||||
promptLevel: promptLevel ?? 0,
|
||||
correct,
|
||||
responseMs: responseMs ?? null,
|
||||
errorType: errorType ?? null,
|
||||
exerciseId,
|
||||
stage: stage ?? null,
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error("Curriculum grade error:", error)
|
||||
return NextResponse.json({ error: "Internal error" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
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"
|
||||
if (!childId) {
|
||||
return NextResponse.json({ error: "childId required" }, { status: 400 })
|
||||
}
|
||||
|
||||
const exercise = await getNextExercise(childId, topic)
|
||||
return NextResponse.json(exercise || { id: null, done: true })
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { prisma } from "@/lib/db"
|
||||
import { initCurriculum, getLecturaStages, getNumerosStages } from "@/curriculum/index"
|
||||
import { getCurrentStage, 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"
|
||||
if (!childId) {
|
||||
return NextResponse.json({ error: "childId required" }, { status: 400 })
|
||||
}
|
||||
|
||||
await initCurriculum()
|
||||
|
||||
const stages = topic === "lectura" ? getLecturaStages() : getNumerosStages()
|
||||
const stageKeys = Object.keys(stages).map(Number).sort((a, b) => a - b)
|
||||
|
||||
const progress = prisma.curriculumProgress.findMany({
|
||||
where: { childId, topic },
|
||||
orderBy: { stage: "asc" },
|
||||
})
|
||||
|
||||
const allAttempts = prisma.skillAttempt.findMany({
|
||||
where: {
|
||||
exerciseId: { not: null },
|
||||
stage: { not: null },
|
||||
sessionLog: { childId },
|
||||
},
|
||||
select: { exerciseId: true, correct: true },
|
||||
})
|
||||
|
||||
const [progressRows, attempts] = await Promise.all([progress, allAttempts])
|
||||
|
||||
const mastered = new Set(
|
||||
attempts.filter((a) => a.correct).map((a) => a.exerciseId),
|
||||
)
|
||||
|
||||
const stagesInfo = stageKeys.map((sk) => {
|
||||
const s = stages[sk]
|
||||
const total = s?.exercises?.length || 0
|
||||
const completed = s?.exercises?.filter((e) => mastered.has(e.id)).length || 0
|
||||
const prog = progressRows.find((p) => p.stage === sk)
|
||||
return {
|
||||
stage: sk,
|
||||
name: s?.name || `Etapa ${sk}`,
|
||||
total,
|
||||
completed,
|
||||
unlocked: !!prog,
|
||||
completedAt: prog?.completedAt || null,
|
||||
percent: total > 0 ? Math.round((completed / total) * 100) : 0,
|
||||
}
|
||||
})
|
||||
|
||||
const errorTypes = await prisma.skillAttempt.groupBy({
|
||||
by: ["errorType"],
|
||||
where: {
|
||||
errorType: { not: null },
|
||||
sessionLog: { childId },
|
||||
},
|
||||
_count: { errorType: true },
|
||||
})
|
||||
|
||||
const errorPatterns = errorTypes.map((e) => ({
|
||||
errorType: e.errorType,
|
||||
count: e._count.errorType,
|
||||
}))
|
||||
|
||||
const activeProgress = progressRows.find((p) => !p.completedAt)
|
||||
const currentStage = activeProgress?.stage || 1
|
||||
|
||||
return NextResponse.json({
|
||||
currentStage,
|
||||
stages: stagesInfo,
|
||||
errorPatterns,
|
||||
hasActiveSession: true,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { prisma } from "@/lib/db"
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const childId = request.nextUrl.searchParams.get("childId")
|
||||
if (!childId) {
|
||||
return NextResponse.json({ error: "childId required" }, { status: 400 })
|
||||
}
|
||||
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
|
||||
const [totalSessions, todaySessions, milestones, fsrsCards, lastSession, weeklySessions, milestoneRecords, curriculumProgress, errorPatterns, allAttempts] =
|
||||
await Promise.all([
|
||||
prisma.sessionLog.count({ where: { childId } }),
|
||||
prisma.sessionLog.count({
|
||||
where: { childId, startedAt: { gte: today } },
|
||||
}),
|
||||
prisma.milestone.count({ where: { childId } }),
|
||||
prisma.fsrsCard.findMany({ where: { childId } }),
|
||||
prisma.sessionLog.findFirst({
|
||||
where: { childId },
|
||||
orderBy: { startedAt: "desc" },
|
||||
include: { skillAttempts: true },
|
||||
}),
|
||||
prisma.sessionLog.findMany({
|
||||
where: { childId, startedAt: { gte: new Date(Date.now() - 7 * 86400000) } },
|
||||
orderBy: { startedAt: "asc" },
|
||||
select: { startedAt: true, durationSec: true },
|
||||
}),
|
||||
prisma.milestone.findMany({
|
||||
where: { childId },
|
||||
orderBy: { reachedAt: "desc" },
|
||||
select: { skillCode: true, reachedAt: true },
|
||||
}),
|
||||
prisma.curriculumProgress.findMany({
|
||||
where: { childId },
|
||||
orderBy: { stage: "asc" },
|
||||
}),
|
||||
prisma.skillAttempt.findMany({
|
||||
where: {
|
||||
errorType: { not: null },
|
||||
sessionLog: { childId },
|
||||
},
|
||||
select: { errorType: true },
|
||||
}),
|
||||
prisma.skillAttempt.findMany({
|
||||
where: {
|
||||
correct: false,
|
||||
exerciseId: { not: null },
|
||||
sessionLog: { childId },
|
||||
},
|
||||
select: { exerciseId: true, errorType: true },
|
||||
}),
|
||||
])
|
||||
|
||||
const todayMinutes = todaySessions > 0
|
||||
? await prisma.sessionLog.aggregate({
|
||||
where: { childId, startedAt: { gte: today } },
|
||||
_sum: { durationSec: true },
|
||||
}).then((r) => Math.round((r._sum.durationSec ?? 0) / 60))
|
||||
: 0
|
||||
|
||||
const totalSkills = fsrsCards.length
|
||||
const masteredSkills = fsrsCards.filter((c) => c.reps >= 5 && c.stability > 30).length
|
||||
const masteryPercent = totalSkills > 0 ? Math.round((masteredSkills / totalSkills) * 100) : 0
|
||||
|
||||
const weeklyData = weeklySessions.map((s) => ({
|
||||
date: s.startedAt.toISOString().slice(0, 10),
|
||||
minutes: Math.round((s.durationSec ?? 0) / 60),
|
||||
}))
|
||||
|
||||
const skillBreakdown = fsrsCards.map((c) => ({
|
||||
skillCode: c.skillCode,
|
||||
stability: c.stability,
|
||||
difficulty: c.difficulty,
|
||||
reps: c.reps,
|
||||
due: c.dueAt.toISOString(),
|
||||
}))
|
||||
|
||||
const streak = await calculateStreak(childId)
|
||||
|
||||
const totalErrors = allAttempts.length
|
||||
const errorTypeCount = errorPatterns.reduce<Record<string, number>>((acc, e) => {
|
||||
const key = e.errorType || "sin-clasificar"
|
||||
acc[key] = (acc[key] || 0) + 1
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
const stageProgress = curriculumProgress.map((cp) => ({
|
||||
topic: cp.topic,
|
||||
stage: cp.stage,
|
||||
completedAt: cp.completedAt?.toISOString() || null,
|
||||
}))
|
||||
|
||||
return NextResponse.json({
|
||||
totalSessions,
|
||||
todaySessions,
|
||||
todayMinutes,
|
||||
masteryPercent,
|
||||
masteredSkills,
|
||||
totalSkills,
|
||||
milestones,
|
||||
streak,
|
||||
weeklyData,
|
||||
skillBreakdown,
|
||||
milestoneData: milestoneRecords,
|
||||
stageProgress,
|
||||
errorTypeCount,
|
||||
totalErrors,
|
||||
lastSession: lastSession
|
||||
? {
|
||||
startedAt: lastSession.startedAt.toISOString(),
|
||||
durationSec: lastSession.durationSec,
|
||||
skillsPracticed: lastSession.skillsPracticed,
|
||||
correctCount: lastSession.skillAttempts.filter((a) => a.correct).length,
|
||||
totalAttempts: lastSession.skillAttempts.length,
|
||||
}
|
||||
: null,
|
||||
})
|
||||
}
|
||||
|
||||
async function calculateStreak(childId: string): Promise<number> {
|
||||
const logs = await prisma.sessionLog.findMany({
|
||||
where: { childId },
|
||||
orderBy: { startedAt: "desc" },
|
||||
select: { startedAt: true },
|
||||
})
|
||||
|
||||
if (logs.length === 0) return 0
|
||||
|
||||
const days = [
|
||||
...new Set(logs.map((l) => l.startedAt.toISOString().slice(0, 10))),
|
||||
].sort()
|
||||
.reverse()
|
||||
|
||||
let streak = 1
|
||||
const today = new Date().toISOString().slice(0, 10)
|
||||
|
||||
if (days[0] !== today && days[0] !== getYesterday()) return 0
|
||||
|
||||
for (let i = 1; i < days.length; i++) {
|
||||
const prev = new Date(days[i - 1])
|
||||
const curr = new Date(days[i])
|
||||
const diff = (prev.getTime() - curr.getTime()) / 86400000
|
||||
if (Math.round(diff) === 1) {
|
||||
streak++
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
return streak
|
||||
}
|
||||
|
||||
function getYesterday(): string {
|
||||
const d = new Date()
|
||||
d.setDate(d.getDate() - 1)
|
||||
return d.toISOString().slice(0, 10)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { prisma } from "@/lib/db"
|
||||
import { buildFsrsCard, gradeCard, skillCodeFromGrade } from "@/lib/fsrs"
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { childId, skillCode, correct, responseMs } = await request.json()
|
||||
if (!childId || !skillCode) {
|
||||
return NextResponse.json({ error: "childId and skillCode required" }, { status: 400 })
|
||||
}
|
||||
|
||||
const existingCard = await prisma.fsrsCard.findFirst({
|
||||
where: { childId, skillCode },
|
||||
})
|
||||
|
||||
const grade = skillCodeFromGrade(correct, responseMs ?? 5000)
|
||||
const baseCard = buildFsrsCard({
|
||||
stability: existingCard?.stability,
|
||||
difficulty: existingCard?.difficulty,
|
||||
reps: existingCard?.reps,
|
||||
lapses: existingCard?.lapses,
|
||||
due: existingCard?.dueAt,
|
||||
})
|
||||
|
||||
const { card: updatedCard } = gradeCard(baseCard, grade)
|
||||
|
||||
if (existingCard) {
|
||||
await prisma.fsrsCard.update({
|
||||
where: { id: existingCard.id },
|
||||
data: {
|
||||
dueAt: updatedCard.due,
|
||||
stability: updatedCard.stability,
|
||||
difficulty: updatedCard.difficulty,
|
||||
reps: updatedCard.reps,
|
||||
lapses: updatedCard.lapses,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
await prisma.fsrsCard.create({
|
||||
data: {
|
||||
childId,
|
||||
skillCode,
|
||||
dueAt: updatedCard.due,
|
||||
stability: updatedCard.stability,
|
||||
difficulty: updatedCard.difficulty,
|
||||
reps: updatedCard.reps,
|
||||
lapses: updatedCard.lapses,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error("FSRS grade error:", error)
|
||||
return NextResponse.json({ error: "Internal error" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { prisma } from "@/lib/db"
|
||||
import { buildFsrsCard, getRetrievability } from "@/lib/fsrs"
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const childId = request.nextUrl.searchParams.get("childId")
|
||||
if (!childId) {
|
||||
return NextResponse.json({ error: "childId required" }, { status: 400 })
|
||||
}
|
||||
|
||||
const dueCard = await prisma.fsrsCard.findFirst({
|
||||
where: {
|
||||
childId,
|
||||
dueAt: { lte: new Date() },
|
||||
},
|
||||
orderBy: { dueAt: "asc" },
|
||||
})
|
||||
|
||||
if (dueCard) {
|
||||
const card = buildFsrsCard({
|
||||
stability: dueCard.stability,
|
||||
difficulty: dueCard.difficulty,
|
||||
reps: dueCard.reps,
|
||||
lapses: dueCard.lapses,
|
||||
due: dueCard.dueAt,
|
||||
})
|
||||
return NextResponse.json({
|
||||
...dueCard,
|
||||
cardId: dueCard.id,
|
||||
retrievability: getRetrievability(card),
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({ skillCode: null })
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { prisma } from "@/lib/db"
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const childId = request.nextUrl.searchParams.get("childId")
|
||||
if (!childId) {
|
||||
return NextResponse.json({ error: "childId required" }, { status: 400 })
|
||||
}
|
||||
const milestones = await prisma.milestone.findMany({
|
||||
where: { childId },
|
||||
orderBy: { reachedAt: "desc" },
|
||||
})
|
||||
return NextResponse.json(milestones)
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const { childId, skillCode } = await request.json()
|
||||
if (!childId || !skillCode) {
|
||||
return NextResponse.json({ error: "childId and skillCode required" }, { status: 400 })
|
||||
}
|
||||
const existing = await prisma.milestone.findUnique({
|
||||
where: { childId_skillCode: { childId, skillCode } },
|
||||
})
|
||||
if (existing) {
|
||||
return NextResponse.json(existing)
|
||||
}
|
||||
const milestone = await prisma.milestone.create({
|
||||
data: { childId, skillCode },
|
||||
})
|
||||
return NextResponse.json(milestone, { status: 201 })
|
||||
} catch (error) {
|
||||
console.error("Milestone error:", error)
|
||||
return NextResponse.json({ error: "Internal error" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import { prisma } from "@/lib/db"
|
||||
import { randomUUID } from "crypto"
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const { deviceFingerprint, pairingCode, childId, action } = await request.json()
|
||||
|
||||
if (action === "generate") {
|
||||
if (!childId) {
|
||||
return NextResponse.json({ error: "childId required" }, { status: 400 })
|
||||
}
|
||||
const code = randomUUID().slice(0, 8).toUpperCase()
|
||||
await prisma.pendingPairing.create({
|
||||
data: {
|
||||
code,
|
||||
childId,
|
||||
expiresAt: new Date(Date.now() + 10 * 60 * 1000),
|
||||
},
|
||||
})
|
||||
return NextResponse.json({ pairingCode: code })
|
||||
}
|
||||
|
||||
if (pairingCode) {
|
||||
const pending = await prisma.pendingPairing.findUnique({
|
||||
where: { code: pairingCode },
|
||||
})
|
||||
if (!pending || pending.expiresAt < new Date()) {
|
||||
return NextResponse.json({ error: "Código inválido o expirado" }, { status: 404 })
|
||||
}
|
||||
|
||||
const targetChildId = childId || pending.childId
|
||||
if (!targetChildId) {
|
||||
return NextResponse.json({ error: "Sin niño asociado" }, { status: 400 })
|
||||
}
|
||||
|
||||
if (childId && !pending.childId) {
|
||||
await prisma.pendingPairing.update({
|
||||
where: { id: pending.id },
|
||||
data: { childId },
|
||||
})
|
||||
}
|
||||
|
||||
const fp = deviceFingerprint || pending.deviceFingerprint
|
||||
if (!fp) {
|
||||
return NextResponse.json({ error: "Se requiere deviceFingerprint" }, { status: 400 })
|
||||
}
|
||||
|
||||
const device = await prisma.device.create({
|
||||
data: {
|
||||
childId: targetChildId,
|
||||
deviceFingerprint: fp,
|
||||
role: "child",
|
||||
},
|
||||
include: { child: true },
|
||||
})
|
||||
|
||||
await prisma.pendingPairing.delete({ where: { id: pending.id } })
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
childId: device.childId,
|
||||
childName: device.child.name,
|
||||
})
|
||||
}
|
||||
|
||||
if (deviceFingerprint) {
|
||||
const existingDevice = await prisma.device.findFirst({
|
||||
where: { deviceFingerprint },
|
||||
})
|
||||
if (existingDevice) {
|
||||
return NextResponse.json({
|
||||
paired: true,
|
||||
childId: existingDevice.childId,
|
||||
role: existingDevice.role,
|
||||
})
|
||||
}
|
||||
|
||||
const code = randomUUID().slice(0, 8).toUpperCase()
|
||||
await prisma.pendingPairing.create({
|
||||
data: {
|
||||
code,
|
||||
deviceFingerprint,
|
||||
expiresAt: new Date(Date.now() + 10 * 60 * 1000),
|
||||
},
|
||||
})
|
||||
return NextResponse.json({
|
||||
paired: false,
|
||||
pairingCode: code,
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "Invalid request" }, { status: 400 })
|
||||
} catch (error) {
|
||||
console.error("Pairing error:", error)
|
||||
return NextResponse.json({ error: "Internal error" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { prisma } from "@/lib/db"
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { childId, session, attempts } = body
|
||||
|
||||
if (!childId || !session) {
|
||||
return NextResponse.json({ error: "Missing required fields" }, { status: 400 })
|
||||
}
|
||||
|
||||
const sessionLog = await prisma.sessionLog.create({
|
||||
data: {
|
||||
childId,
|
||||
startedAt: new Date(session.startedAt),
|
||||
endedAt: session.endedAt ? new Date(session.endedAt) : undefined,
|
||||
durationSec: session.durationSec,
|
||||
skillsPracticed: session.skillsPracticed,
|
||||
skillAttempts: {
|
||||
create: (attempts || []).map((a: any) => ({
|
||||
skillCode: a.skillCode,
|
||||
promptLevel: a.promptLevel ?? 0,
|
||||
correct: a.correct,
|
||||
responseMs: a.responseMs,
|
||||
})),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true, sessionId: sessionLog.id })
|
||||
} catch (error) {
|
||||
console.error("Sync error:", error)
|
||||
return NextResponse.json({ error: "Internal error" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { execSync } from "child_process"
|
||||
import { existsSync, mkdirSync } from "fs"
|
||||
import { createHash } from "crypto"
|
||||
import { readFile, mkdir } from "fs/promises"
|
||||
import { join } from "path"
|
||||
|
||||
const CACHE_DIR = join(process.cwd(), "public", "tts-cache")
|
||||
|
||||
async function ensureCacheDir() {
|
||||
if (!existsSync(CACHE_DIR)) {
|
||||
await mkdir(CACHE_DIR, { recursive: true })
|
||||
}
|
||||
}
|
||||
|
||||
function getCachePath(text: string): string {
|
||||
const hash = createHash("md5").update(text).digest("hex")
|
||||
return join(CACHE_DIR, `${hash}.wav`)
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const text = request.nextUrl.searchParams.get("text")
|
||||
if (!text || text.length > 200) {
|
||||
return NextResponse.json({ error: "text param required (max 200 chars)" }, { status: 400 })
|
||||
}
|
||||
|
||||
await ensureCacheDir()
|
||||
const cachePath = getCachePath(text)
|
||||
|
||||
if (!existsSync(cachePath)) {
|
||||
try {
|
||||
execSync(
|
||||
`espeak-ng -v es-mx -s 140 -p 60 "${text.replace(/"/g, '\\"')}" -w "${cachePath}"`,
|
||||
{ timeout: 10000 },
|
||||
)
|
||||
} catch {
|
||||
return NextResponse.json({ error: "TTS generation failed" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
const audioBuffer = await readFile(cachePath)
|
||||
return new NextResponse(audioBuffer, {
|
||||
headers: {
|
||||
"Content-Type": "audio/wav",
|
||||
"Content-Length": audioBuffer.length.toString(),
|
||||
"Cache-Control": "public, max-age=31536000, immutable",
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import AudioUnlock from "@/components/child/audio-unlock"
|
||||
|
||||
export default function ChildLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const [audioReady, setAudioReady] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
document.body.style.overscrollBehavior = "none"
|
||||
}, [])
|
||||
|
||||
if (!audioReady) {
|
||||
return <AudioUnlock onUnlock={() => setAudioReady(true)} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-dvh bg-background flex flex-col">
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
"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"
|
||||
|
||||
export default function LecturaSesion() {
|
||||
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 res = await fetch(`/api/curriculum/next?childId=${childId}&topic=lectura`)
|
||||
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])
|
||||
|
||||
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))
|
||||
}
|
||||
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="Preparando ejercicios..." 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="¡Terminaste todos los ejercicios por ahora!" 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,78 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { motion } from "framer-motion"
|
||||
import { GatoMascota } from "@/components/child/gato-mascota"
|
||||
|
||||
const ALL_SKILLS = [
|
||||
{ id: "vocal-a", label: "Letra A", emoji: "✈️" },
|
||||
{ id: "vocal-e", label: "Letra E", emoji: "🐘" },
|
||||
{ id: "vocal-i", label: "Letra I", emoji: "🏔️" },
|
||||
{ id: "vocal-o", label: "Letra O", emoji: "🐻" },
|
||||
{ id: "vocal-u", label: "Letra U", emoji: "🍇" },
|
||||
{ id: "numero-1", label: "Número 1", emoji: "1️⃣" },
|
||||
{ id: "numero-2", label: "Número 2", emoji: "2️⃣" },
|
||||
{ id: "numero-3", label: "Número 3", emoji: "3️⃣" },
|
||||
{ id: "numero-4", label: "Número 4", emoji: "4️⃣" },
|
||||
{ id: "numero-5", label: "Número 5", emoji: "5️⃣" },
|
||||
]
|
||||
|
||||
const CHILD_ID_KEY = "edueasy_child_id"
|
||||
|
||||
export default function LogrosPage() {
|
||||
const [milestones, setMilestones] = useState<string[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
const childId = localStorage.getItem(CHILD_ID_KEY)
|
||||
if (!childId) {
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
fetch(`/api/milestones?childId=${childId}`)
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
if (Array.isArray(data)) {
|
||||
setMilestones(data.map((m: any) => m.skillCode))
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
const completedCount = milestones.length
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center p-6 gap-6">
|
||||
<GatoMascota mood="happy" message="Tus logros" size="sm" />
|
||||
|
||||
<p className="text-lg font-display">
|
||||
{completedCount} de {ALL_SKILLS.length} completados
|
||||
</p>
|
||||
|
||||
{loading ? (
|
||||
<p className="text-muted-foreground">Cargando...</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-3 justify-center max-w-sm">
|
||||
{ALL_SKILLS.map((skill, i) => {
|
||||
const completed = milestones.includes(skill.id)
|
||||
return (
|
||||
<motion.div
|
||||
key={skill.id}
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ delay: i * 0.05 }}
|
||||
className={`rounded-2xl p-4 flex flex-col items-center gap-1 w-24 ${
|
||||
completed ? "bg-primary/20 border-2 border-primary" : "bg-muted opacity-50"
|
||||
}`}
|
||||
>
|
||||
<span className="text-3xl">{skill.emoji}</span>
|
||||
<span className="text-xs text-center font-display font-medium">{skill.label}</span>
|
||||
</motion.div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
"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"
|
||||
|
||||
const CHILD_ID_KEY = "edueasy_child_id"
|
||||
const META_KEY = "edueasy_child_meta"
|
||||
|
||||
type PageState = "loading" | "ready" | "complete" | "error"
|
||||
|
||||
export default function NumerosSesion() {
|
||||
const [pageState, setPageState] = useState<PageState>("loading")
|
||||
const [exercise, setExercise] = useState<any | 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 res = await fetch(`/api/curriculum/next?childId=${childId}&topic=numeros`)
|
||||
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])
|
||||
|
||||
useEffect(() => {
|
||||
fetchNext()
|
||||
}, [fetchNext])
|
||||
|
||||
const gradeAttempt = useCallback(async (ex: any, 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))
|
||||
}
|
||||
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="Preparando ejercicios de números..." 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="¡Terminaste todos los ejercicios de números por ahora!" 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,34 @@
|
||||
"use client"
|
||||
|
||||
import { useRouter } from "next/navigation"
|
||||
import { GatoMascota } from "@/components/child/gato-mascota"
|
||||
|
||||
const activities = [
|
||||
{ id: "lectura", label: "Letras", icon: "Aa", color: "bg-primary" },
|
||||
{ id: "numeros", label: "Números", icon: "123", color: "bg-accent" },
|
||||
{ id: "logros", label: "Mis logros", icon: "⭐", color: "bg-warning" },
|
||||
{ id: "pairing", label: "iPad nuevo", icon: "🔗", color: "bg-secondary" },
|
||||
]
|
||||
|
||||
export default function ChildHome() {
|
||||
const router = useRouter()
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center justify-center p-6 gap-8">
|
||||
<GatoMascota mood="happy" message="¡Hola! ¿Qué querés aprender hoy?" />
|
||||
|
||||
<div className="flex flex-col gap-4 w-full max-w-md">
|
||||
{activities.map((a) => (
|
||||
<button
|
||||
key={a.id}
|
||||
onClick={() => router.push(`/child/${a.id}`)}
|
||||
className={`${a.color} 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>
|
||||
<span>{a.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { GatoMascota } from "@/components/child/gato-mascota"
|
||||
import { speak } from "@/lib/speech"
|
||||
|
||||
const CHILD_ID_KEY = "edueasy_child_id"
|
||||
|
||||
export default function ChildPairingPage() {
|
||||
const [code, setCode] = useState("")
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState("")
|
||||
const [paired, setPaired] = useState(false)
|
||||
const router = useRouter()
|
||||
|
||||
const handlePair = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
setError("")
|
||||
|
||||
const deviceFingerprint = `child-${navigator.userAgent.slice(0, 64)}`
|
||||
const res = await fetch("/api/pairing", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
pairingCode: code.toUpperCase(),
|
||||
deviceFingerprint,
|
||||
}),
|
||||
})
|
||||
const data = await res.json()
|
||||
|
||||
if (data.error) {
|
||||
setError(data.error)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
localStorage.setItem(CHILD_ID_KEY, data.childId)
|
||||
setPaired(true)
|
||||
speak("¡Emparejado! Ahora podemos aprender juntos")
|
||||
}
|
||||
|
||||
if (paired) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center justify-center p-8 gap-6">
|
||||
<GatoMascota mood="celebrate" message="iPad emparejado" size="lg" />
|
||||
<button
|
||||
onClick={() => router.push("/child")}
|
||||
className="bg-primary text-primary-foreground touch-target-lg rounded-2xl px-8 py-4 text-xl font-display font-bold"
|
||||
>
|
||||
Ir a jugar
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center justify-center p-6 gap-6">
|
||||
<GatoMascota mood="coach" message="Poné el código que te dió mamá" size="md" />
|
||||
|
||||
<form onSubmit={handlePair} className="flex flex-col items-center gap-4 w-full max-w-sm">
|
||||
<input
|
||||
type="text"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value.toUpperCase())}
|
||||
placeholder="Código"
|
||||
maxLength={8}
|
||||
className="w-full rounded-2xl border-2 border-primary bg-white px-6 py-4 text-center text-3xl tracking-[0.5em] uppercase font-display font-bold focus:outline-none focus:ring-4 focus:ring-primary/30"
|
||||
autoFocus
|
||||
/>
|
||||
{error && <p className="text-destructive text-sm">{error}</p>}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || code.length < 8}
|
||||
className="bg-primary text-primary-foreground touch-target-lg rounded-2xl px-8 py-4 text-xl font-display font-bold disabled:opacity-50"
|
||||
>
|
||||
{loading ? "Emparejando..." : "¡Listo!"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push("/child")}
|
||||
className="text-muted-foreground underline text-sm"
|
||||
>
|
||||
Volver
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@import url('https://fonts.googleapis.com/css2?family=Fredoka:wght@400;500;600;700&family=Inter:wght@400;500;600;700&display=swap');
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--radius: 1rem;
|
||||
--background: 38 100% 97%;
|
||||
--foreground: 200 6% 19%;
|
||||
--primary: 150 22% 64%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
--secondary: 200 24% 53%;
|
||||
--secondary-foreground: 0 0% 100%;
|
||||
--accent: 30 48% 64%;
|
||||
--accent-foreground: 0 0% 100%;
|
||||
--muted: 30 20% 88%;
|
||||
--muted-foreground: 200 4% 40%;
|
||||
--card: 0 0% 100%;
|
||||
--border: 30 20% 88%;
|
||||
--ring: 150 22% 64%;
|
||||
--success: 130 25% 61%;
|
||||
--destructive: 0 62% 68%;
|
||||
--warning: 35 85% 62%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground font-sans;
|
||||
overscroll-behavior: none;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.touch-target {
|
||||
@apply min-h-[60px] min-w-[60px];
|
||||
}
|
||||
.touch-target-lg {
|
||||
@apply min-h-[80px] min-w-[80px];
|
||||
}
|
||||
}
|
||||
|
||||
/* iOS Safari quirks fixes */
|
||||
input, textarea {
|
||||
font-size: 16px !important;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { Metadata, Viewport } from "next"
|
||||
import { Inter } from "next/font/google"
|
||||
import "./globals.css"
|
||||
|
||||
const inter = Inter({ subsets: ["latin"], variable: "--font-inter" })
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "EduEasy",
|
||||
description: "Plataforma educativa infantil",
|
||||
appleWebApp: {
|
||||
capable: true,
|
||||
statusBarStyle: "default",
|
||||
title: "EduEasy",
|
||||
},
|
||||
}
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
maximumScale: 1,
|
||||
viewportFit: "cover",
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<html lang="es" className={inter.variable}>
|
||||
<head>
|
||||
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
|
||||
<link rel="apple-touch-icon" href="/icon.svg" />
|
||||
</head>
|
||||
<body className="dvh-screen overflow-x-hidden">{children}</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
|
||||
export default function RootPage() {
|
||||
const router = useRouter()
|
||||
|
||||
useEffect(() => {
|
||||
const host = window.location.hostname
|
||||
if (host.includes("simulacro")) {
|
||||
router.replace("/parent")
|
||||
} else {
|
||||
router.replace("/child")
|
||||
}
|
||||
}, [router])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { authClient } from "@/lib/auth-client"
|
||||
|
||||
export default function LoginPage() {
|
||||
const [email, setEmail] = useState("")
|
||||
const [password, setPassword] = useState("")
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState("")
|
||||
const router = useRouter()
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
setError("")
|
||||
|
||||
const { error: err } = await authClient.signIn.email({
|
||||
email,
|
||||
password,
|
||||
})
|
||||
|
||||
if (err) {
|
||||
setError(err.message || "Error al iniciar sesión")
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
router.push("/parent")
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-dvh flex items-center justify-center p-6">
|
||||
<form onSubmit={handleLogin} className="w-full max-w-sm flex flex-col gap-6">
|
||||
<h1 className="text-2xl font-display font-bold text-foreground text-center">
|
||||
EduEasy · Mamá
|
||||
</h1>
|
||||
|
||||
{error && (
|
||||
<p className="text-destructive text-sm text-center">{error}</p>
|
||||
)}
|
||||
|
||||
<input
|
||||
type="email"
|
||||
placeholder="Email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full rounded-xl border border-border bg-white px-4 py-3 text-base focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
required
|
||||
/>
|
||||
|
||||
<input
|
||||
type="password"
|
||||
placeholder="Contraseña"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full rounded-xl border border-border bg-white px-4 py-3 text-base focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
required
|
||||
/>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full bg-primary text-primary-foreground touch-target rounded-xl text-lg font-display font-semibold disabled:opacity-50"
|
||||
>
|
||||
{loading ? "Entrando..." : "Iniciar sesión"}
|
||||
</button>
|
||||
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
¿No tenés cuenta?{" "}
|
||||
<a href="/parent/auth/register" className="text-primary underline">
|
||||
Crear cuenta
|
||||
</a>
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { authClient } from "@/lib/auth-client"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { GatoMascota } from "@/components/child/gato-mascota"
|
||||
|
||||
interface ChildSummary {
|
||||
id: string
|
||||
name: string | null
|
||||
}
|
||||
|
||||
export default function PairingPage() {
|
||||
const [code, setCode] = useState("")
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState("")
|
||||
const [paired, setPaired] = useState(false)
|
||||
const [children, setChildren] = useState<ChildSummary[]>([])
|
||||
const [selectedChildId, setSelectedChildId] = useState("")
|
||||
const { data: session } = authClient.useSession()
|
||||
const router = useRouter()
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/children")
|
||||
.then((r) => r.json())
|
||||
.then((list) => {
|
||||
if (Array.isArray(list)) {
|
||||
setChildren(list)
|
||||
if (list.length > 0) setSelectedChildId(list[0].id)
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
const handlePairing = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
setError("")
|
||||
|
||||
if (!selectedChildId) {
|
||||
setError("Seleccioná un niño")
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/pairing", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
pairingCode: code.toUpperCase(),
|
||||
childId: selectedChildId,
|
||||
}),
|
||||
})
|
||||
const data = await res.json()
|
||||
|
||||
if (data.error) {
|
||||
setError(data.error)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
setPaired(true)
|
||||
} catch {
|
||||
setError("Error de conexión")
|
||||
}
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
return (
|
||||
<div className="min-h-dvh flex flex-col items-center justify-center p-6 gap-4">
|
||||
<p className="text-muted-foreground">Iniciá sesión primero para emparejar</p>
|
||||
<button
|
||||
onClick={() => router.push("/parent/auth/login")}
|
||||
className="bg-primary text-primary-foreground touch-target rounded-xl px-6 py-3 font-display"
|
||||
>
|
||||
Ir a login
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-dvh flex flex-col items-center justify-center p-6 gap-6">
|
||||
<h1 className="text-2xl font-display font-bold">Emparejar iPad</h1>
|
||||
|
||||
{paired ? (
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<GatoMascota mood="celebrate" message="iPad emparejado con éxito" size="lg" />
|
||||
<button
|
||||
onClick={() => router.push("/parent")}
|
||||
className="bg-primary text-primary-foreground touch-target rounded-xl px-6 py-3 font-display"
|
||||
>
|
||||
Volver al dashboard
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handlePairing} className="flex flex-col gap-4 w-full max-w-sm">
|
||||
<p className="text-muted-foreground text-center">
|
||||
Ingresá el código de 8 caracteres que aparece en el iPad
|
||||
</p>
|
||||
|
||||
{children.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 justify-center">
|
||||
{children.map((c) => (
|
||||
<button
|
||||
key={c.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedChildId(c.id)}
|
||||
className={`rounded-xl px-4 py-2 font-display text-sm transition-colors ${
|
||||
c.id === selectedChildId
|
||||
? "bg-secondary text-secondary-foreground"
|
||||
: "bg-muted text-foreground"
|
||||
}`}
|
||||
>
|
||||
{c.name || "Sin nombre"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<input
|
||||
type="text"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value.toUpperCase())}
|
||||
placeholder="Código de 8 letras"
|
||||
maxLength={8}
|
||||
className="w-full rounded-xl border border-border bg-white px-4 py-3 text-center text-2xl tracking-widest uppercase focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
autoFocus
|
||||
/>
|
||||
{error && <p className="text-destructive text-sm text-center">{error}</p>}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || code.length < 8}
|
||||
className="bg-secondary text-secondary-foreground touch-target rounded-xl text-lg font-display font-semibold disabled:opacity-50"
|
||||
>
|
||||
{loading ? "Emparejando..." : "Emparejar"}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { authClient } from "@/lib/auth-client"
|
||||
|
||||
export default function RegisterPage() {
|
||||
const [email, setEmail] = useState("")
|
||||
const [password, setPassword] = useState("")
|
||||
const [name, setName] = useState("")
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState("")
|
||||
const router = useRouter()
|
||||
|
||||
const handleRegister = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
setError("")
|
||||
|
||||
const { error: err } = await authClient.signUp.email({
|
||||
email,
|
||||
password,
|
||||
name: name || "",
|
||||
})
|
||||
|
||||
if (err) {
|
||||
setError(err.message || "Error al registrarse")
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
router.push("/parent")
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-dvh flex items-center justify-center p-6">
|
||||
<form onSubmit={handleRegister} className="w-full max-w-sm flex flex-col gap-6">
|
||||
<h1 className="text-2xl font-display font-bold text-foreground text-center">
|
||||
Crear cuenta · Mamá
|
||||
</h1>
|
||||
|
||||
{error && <p className="text-destructive text-sm text-center">{error}</p>}
|
||||
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Tu nombre"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full rounded-xl border border-border bg-white px-4 py-3 text-base focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
|
||||
<input
|
||||
type="email"
|
||||
placeholder="Email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full rounded-xl border border-border bg-white px-4 py-3 text-base focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
required
|
||||
/>
|
||||
|
||||
<input
|
||||
type="password"
|
||||
placeholder="Contraseña (mín. 8 caracteres)"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
minLength={8}
|
||||
className="w-full rounded-xl border border-border bg-white px-4 py-3 text-base focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
required
|
||||
/>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full bg-primary text-primary-foreground touch-target rounded-xl text-lg font-display font-semibold disabled:opacity-50"
|
||||
>
|
||||
{loading ? "Creando cuenta..." : "Crear cuenta"}
|
||||
</button>
|
||||
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
¿Ya tenés cuenta?{" "}
|
||||
<a href="/parent/auth/login" className="text-primary underline">
|
||||
Iniciá sesión
|
||||
</a>
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
"use client"
|
||||
|
||||
import { authClient } from "@/lib/auth-client"
|
||||
import { useRouter } from "next/navigation"
|
||||
|
||||
export default function ConfigPage() {
|
||||
const { data: session } = authClient.useSession()
|
||||
const router = useRouter()
|
||||
|
||||
return (
|
||||
<div className="min-h-dvh p-6">
|
||||
<h1 className="text-2xl font-display font-bold mb-6">Configuración</h1>
|
||||
|
||||
<div className="flex flex-col gap-4 max-w-md">
|
||||
<div className="bg-white rounded-2xl p-4 shadow-sm">
|
||||
<p className="text-sm text-muted-foreground">Email</p>
|
||||
<p className="font-medium">{session?.user?.email || "—"}</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => router.push("/parent/auth/pairing")}
|
||||
className="bg-secondary text-secondary-foreground touch-target rounded-xl text-lg font-display font-semibold"
|
||||
>
|
||||
Emparejar nuevo iPad
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => router.push("/parent")}
|
||||
className="bg-muted text-foreground touch-target rounded-xl font-display"
|
||||
>
|
||||
Volver al dashboard
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { authClient } from "@/lib/auth-client"
|
||||
import { MetricCard } from "@/components/parent/metric-card"
|
||||
import { WeeklyActivityChart } from "@/components/parent/weekly-activity-chart"
|
||||
import { SkillMasteryChart } from "@/components/parent/skill-mastery-chart"
|
||||
import { MilestonesList } from "@/components/parent/milestones-list"
|
||||
import ErrorPatternsChart from "@/components/parent/error-patterns-chart"
|
||||
|
||||
interface ChildSummary {
|
||||
id: string
|
||||
name: string | null
|
||||
}
|
||||
|
||||
interface DashboardData {
|
||||
totalSessions: number
|
||||
todaySessions: number
|
||||
todayMinutes: number
|
||||
masteryPercent: number
|
||||
masteredSkills: number
|
||||
totalSkills: number
|
||||
milestones: number
|
||||
milestoneData: { skillCode: string; reachedAt: string }[]
|
||||
streak: number
|
||||
weeklyData: { date: string; minutes: number }[]
|
||||
skillBreakdown: { skillCode: string; stability: number; reps: number }[]
|
||||
stageProgress: { topic: string; stage: number; completedAt: string | null }[]
|
||||
errorTypeCount: Record<string, number>
|
||||
totalErrors: number
|
||||
lastSession: {
|
||||
startedAt: string
|
||||
durationSec: number
|
||||
skillsPracticed: string
|
||||
correctCount: number
|
||||
totalAttempts: number
|
||||
} | null
|
||||
}
|
||||
|
||||
export default function ParentDashboard() {
|
||||
const { data: session, isPending } = authClient.useSession()
|
||||
const router = useRouter()
|
||||
const [children, setChildren] = useState<ChildSummary[]>([])
|
||||
const [selectedChildId, setSelectedChildId] = useState<string>("")
|
||||
const [data, setData] = useState<DashboardData | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [newChildName, setNewChildName] = useState("")
|
||||
const [pairingCode, setPairingCode] = useState("")
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/children")
|
||||
.then((r) => r.json())
|
||||
.then((list) => {
|
||||
if (Array.isArray(list)) {
|
||||
setChildren(list)
|
||||
if (list.length > 0) setSelectedChildId(list[0].id)
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedChildId) {
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
fetch(`/api/dashboard/summary?childId=${selectedChildId}`)
|
||||
.then((r) => r.json())
|
||||
.then(setData)
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false))
|
||||
}, [selectedChildId])
|
||||
|
||||
const handleCreateChild = async () => {
|
||||
if (!newChildName.trim()) return
|
||||
setCreating(true)
|
||||
const res = await fetch("/api/children", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: newChildName.trim() }),
|
||||
})
|
||||
if (res.ok) {
|
||||
const child = await res.json()
|
||||
setChildren((prev) => [...prev, child])
|
||||
setSelectedChildId(child.id)
|
||||
setNewChildName("")
|
||||
}
|
||||
setCreating(false)
|
||||
}
|
||||
|
||||
const handleGeneratePairingCode = async () => {
|
||||
if (!selectedChildId) return
|
||||
const res = await fetch("/api/pairing", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "generate", childId: selectedChildId }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (data.pairingCode) {
|
||||
setPairingCode(data.pairingCode)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPending && !session) {
|
||||
router.push("/parent/auth/login")
|
||||
}
|
||||
}, [session, isPending, router])
|
||||
|
||||
if (isPending || !session) {
|
||||
return (
|
||||
<div className="p-4 text-center text-muted-foreground">Cargando...</div>
|
||||
)
|
||||
}
|
||||
|
||||
const selectedChild = children.find((c) => c.id === selectedChildId)
|
||||
|
||||
return (
|
||||
<div className="p-4 sm:p-6 max-w-2xl mx-auto">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-6">
|
||||
<div>
|
||||
<h2 className="text-2xl font-display font-bold">Dashboard</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Bienvenida, {session.user?.name || "Mamá"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{children.length === 0 ? (
|
||||
<div className="bg-white rounded-2xl p-6 shadow-sm border border-border">
|
||||
<h3 className="text-lg font-display font-semibold mb-4">Agregar un niño</h3>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newChildName}
|
||||
onChange={(e) => setNewChildName(e.target.value)}
|
||||
placeholder="Nombre de la nena"
|
||||
className="flex-1 rounded-xl border border-border bg-white px-4 py-3 text-base focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
<button
|
||||
onClick={handleCreateChild}
|
||||
disabled={creating || !newChildName.trim()}
|
||||
className="bg-primary text-primary-foreground touch-target rounded-xl px-6 font-display font-semibold disabled:opacity-50"
|
||||
>
|
||||
{creating ? "..." : "Agregar"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-wrap gap-2 mb-6">
|
||||
{children.map((c) => (
|
||||
<button
|
||||
key={c.id}
|
||||
onClick={() => setSelectedChildId(c.id)}
|
||||
className={`rounded-xl px-4 py-2 font-display font-medium text-sm transition-colors ${
|
||||
c.id === selectedChildId
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-foreground"
|
||||
}`}
|
||||
>
|
||||
{c.name || "Sin nombre"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-12 text-muted-foreground">Cargando...</div>
|
||||
) : data ? (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
<MetricCard title="Racha" value={`${data.streak} días`} variant="primary" />
|
||||
<MetricCard
|
||||
title="Hoy"
|
||||
value={`${data.todayMinutes} min`}
|
||||
subtitle={data.todaySessions > 0 ? `${data.todaySessions} sesiones` : undefined}
|
||||
variant="success"
|
||||
/>
|
||||
<MetricCard
|
||||
title="Maestría"
|
||||
value={`${data.masteryPercent}%`}
|
||||
subtitle={`${data.masteredSkills}/${data.totalSkills} skills`}
|
||||
variant="accent"
|
||||
/>
|
||||
<MetricCard title="Logros" value={data.milestones} variant="default" />
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-2xl p-4 shadow-sm border border-border">
|
||||
<h3 className="text-sm font-display font-semibold mb-3">
|
||||
Actividad semanal
|
||||
</h3>
|
||||
<WeeklyActivityChart data={data.weeklyData} />
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-2xl p-4 shadow-sm border border-border">
|
||||
<h3 className="text-sm font-display font-semibold mb-3">
|
||||
Maestría por skill
|
||||
</h3>
|
||||
<SkillMasteryChart data={data.skillBreakdown} />
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-2xl p-4 shadow-sm border border-border">
|
||||
<h3 className="text-sm font-display font-semibold mb-3">Logros</h3>
|
||||
<MilestonesList data={data.milestoneData} />
|
||||
</div>
|
||||
|
||||
{data.stageProgress.length > 0 && (
|
||||
<div className="bg-white rounded-2xl p-4 shadow-sm border border-border">
|
||||
<h3 className="text-sm font-display font-semibold mb-3">Progreso curricular</h3>
|
||||
<div className="space-y-2 text-sm">
|
||||
{data.stageProgress.map((sp) => (
|
||||
<div key={`${sp.topic}-${sp.stage}`} className="flex justify-between items-center">
|
||||
<span className="font-medium">
|
||||
{sp.topic === "lectura" ? "Lectura" : "Números"} — Etapa {sp.stage}
|
||||
</span>
|
||||
<span className={sp.completedAt ? "text-green-600" : "text-yellow-600"}>
|
||||
{sp.completedAt ? "✅ Completada" : "🔵 En curso"}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data.totalErrors > 0 && (
|
||||
<div className="bg-white rounded-2xl p-4 shadow-sm border border-border">
|
||||
<h3 className="text-sm font-display font-semibold mb-3">Patrones de error</h3>
|
||||
<ErrorPatternsChart data={data.errorTypeCount} total={data.totalErrors} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data.lastSession && (
|
||||
<div className="bg-white rounded-2xl p-4 shadow-sm border border-border">
|
||||
<h3 className="text-sm font-display font-semibold mb-2">Última sesión</h3>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<p>{new Date(data.lastSession.startedAt).toLocaleDateString("es-AR")}</p>
|
||||
<p>
|
||||
{Math.round((data.lastSession.durationSec ?? 0) / 60)} min ·{" "}
|
||||
{data.lastSession.correctCount}/{data.lastSession.totalAttempts} correctas
|
||||
</p>
|
||||
<p className="text-xs mt-1">Skills: {data.lastSession.skillsPracticed || "—"}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-3 mt-2">
|
||||
<button
|
||||
onClick={handleGeneratePairingCode}
|
||||
className="bg-secondary text-secondary-foreground touch-target rounded-xl px-6 font-display font-semibold"
|
||||
>
|
||||
Emparejar nuevo iPad
|
||||
</button>
|
||||
<button
|
||||
onClick={() => router.push("/parent/auth/pairing")}
|
||||
className="bg-muted text-foreground touch-target rounded-xl font-display"
|
||||
>
|
||||
Ingresar código
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
Seleccioná un niño para ver su progreso
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{pairingCode && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
|
||||
<div className="bg-white rounded-2xl p-6 shadow-xl max-w-sm w-full text-center space-y-4">
|
||||
<h3 className="text-lg font-display font-semibold">Código de emparejamiento</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Ingresá este código en el iPad de la nena
|
||||
</p>
|
||||
<p className="text-3xl tracking-[0.3em] font-bold font-display text-primary select-all">
|
||||
{pairingCode}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setPairingCode("")}
|
||||
className="bg-primary text-primary-foreground touch-target rounded-xl px-6 py-3 font-display font-semibold"
|
||||
>
|
||||
Listo
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { usePathname } from "next/navigation"
|
||||
import { authClient } from "@/lib/auth-client"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const navItems = [
|
||||
{ href: "/parent/dashboard", label: "Dashboard", emoji: "📊" },
|
||||
{ href: "/parent/configuracion", label: "Configuración", emoji: "⚙️" },
|
||||
]
|
||||
|
||||
export default function ParentLayout({ children }: { children: React.ReactNode }) {
|
||||
const pathname = usePathname()
|
||||
const { data: session } = authClient.useSession()
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false)
|
||||
|
||||
if (
|
||||
pathname.startsWith("/parent/auth")
|
||||
) {
|
||||
return <div className="min-h-dvh bg-background">{children}</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-dvh bg-background flex flex-col">
|
||||
<header className="sticky top-0 z-10 bg-background/95 backdrop-blur border-b border-border px-4 py-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => setSidebarOpen(!sidebarOpen)}
|
||||
className="text-foreground touch-target rounded-xl flex items-center justify-center"
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M3 12h18M3 6h18M3 18h18" />
|
||||
</svg>
|
||||
</button>
|
||||
<h1 className="text-lg font-display font-bold">EduEasy</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-muted-foreground hidden sm:block">
|
||||
{session?.user?.name || "Mamá"}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => authClient.signOut()}
|
||||
className="text-sm text-muted-foreground underline"
|
||||
>
|
||||
Salir
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{sidebarOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/20 z-20"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
>
|
||||
<nav
|
||||
className="fixed left-0 top-0 bottom-0 w-64 bg-white shadow-lg p-4 pt-16"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
{navItems.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-xl px-4 py-3 font-display font-medium transition-colors",
|
||||
pathname.startsWith(item.href)
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-foreground hover:bg-muted",
|
||||
)}
|
||||
>
|
||||
<span>{item.emoji}</span>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<main className="flex-1">{children}</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { authClient } from "@/lib/auth-client"
|
||||
|
||||
export default function ParentPage() {
|
||||
const { data: session, isPending } = authClient.useSession()
|
||||
const router = useRouter()
|
||||
|
||||
useEffect(() => {
|
||||
if (isPending) return
|
||||
if (session) {
|
||||
router.replace("/parent/dashboard")
|
||||
} else {
|
||||
router.replace("/parent/auth/login")
|
||||
}
|
||||
}, [session, isPending, router])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "default",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.ts",
|
||||
"css": "app/globals.css",
|
||||
"baseColor": "slate",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { motion } from "framer-motion"
|
||||
import { GatoMascota } from "./gato-mascota"
|
||||
|
||||
interface AudioUnlockProps {
|
||||
onUnlock: () => void
|
||||
}
|
||||
|
||||
export default function AudioUnlock({ onUnlock }: AudioUnlockProps) {
|
||||
const [pressed, setPressed] = useState(false)
|
||||
|
||||
const handleUnlock = () => {
|
||||
if (typeof window !== "undefined") {
|
||||
const AudioCtx = window.AudioContext || (window as any).webkitAudioContext
|
||||
if (AudioCtx) {
|
||||
const ctx = new AudioCtx()
|
||||
ctx.resume().then(() => {
|
||||
ctx.close()
|
||||
})
|
||||
}
|
||||
}
|
||||
setPressed(true)
|
||||
setTimeout(onUnlock, 600)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-dvh bg-background flex flex-col items-center justify-center p-8 gap-8">
|
||||
<GatoMascota mood="happy" message="¡Hola! Tocá la pantalla para empezar" size="lg" />
|
||||
|
||||
<motion.button
|
||||
onClick={handleUnlock}
|
||||
disabled={pressed}
|
||||
className="bg-primary text-primary-foreground touch-target-lg rounded-full text-2xl px-12 py-6 font-display font-bold shadow-lg"
|
||||
whileTap={{ scale: 0.95 }}
|
||||
animate={pressed ? { scale: 1.1, opacity: 0 } : {}}
|
||||
>
|
||||
{pressed ? "¡Empezamos!" : "TOCÁ ACÁ"}
|
||||
</motion.button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
"use client"
|
||||
|
||||
import { speak } from "@/lib/speech"
|
||||
|
||||
export interface CPAExerciseProps {
|
||||
value: number
|
||||
stage: "concrete" | "pictoric" | "abstract"
|
||||
onCorrect: () => void
|
||||
onIncorrect: () => void
|
||||
}
|
||||
|
||||
export function generateCPAItems(value: number, stage: string): Array<{ emoji: string; id: number }> {
|
||||
const pool = ["🍎", "🐱", "⭐", "🌸", "🎈", "🐶", "🍪", "🌈", "🦋", "🧸"]
|
||||
return Array.from({ length: value }, (_, i) => ({
|
||||
emoji: pool[i % pool.length],
|
||||
id: i,
|
||||
}))
|
||||
}
|
||||
|
||||
export function speakCPAInstruction(value: number, stage: string) {
|
||||
if (stage === "concrete") {
|
||||
speak(`Tocá ${value} ${getItemName(value)}`)
|
||||
} else if (stage === "pictoric") {
|
||||
speak(`¿Cuántos hay? Señalá el número`)
|
||||
} else {
|
||||
speak(`¿Este número es el ${value}?`)
|
||||
}
|
||||
}
|
||||
|
||||
function getItemName(n: number): string {
|
||||
if (n === 1) return "elemento"
|
||||
return "elementos"
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { motion } from "framer-motion"
|
||||
|
||||
export interface CuisenaireRodProps {
|
||||
value: number
|
||||
color: string
|
||||
length: number
|
||||
draggable?: boolean
|
||||
onDrop?: (value: number) => void
|
||||
}
|
||||
|
||||
const rodColors: Record<number, string> = {
|
||||
1: "#FFFFFF",
|
||||
2: "#E74C3C",
|
||||
3: "#2ECC71",
|
||||
4: "#E91E63",
|
||||
5: "#F1C40F",
|
||||
6: "#1ABC9C",
|
||||
7: "#2C3E50",
|
||||
8: "#3498DB",
|
||||
9: "#9B59B6",
|
||||
10: "#E67E22",
|
||||
}
|
||||
|
||||
const rodLengths: Record<number, number> = {
|
||||
1: 20, 2: 40, 3: 60, 4: 80, 5: 100,
|
||||
6: 120, 7: 140, 8: 160, 9: 180, 10: 200,
|
||||
}
|
||||
|
||||
export { rodColors, rodLengths }
|
||||
|
||||
export function CuisenaireRod({ value, color, length, draggable = false }: CuisenaireRodProps) {
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className={`rounded-lg border border-border/50 shadow-sm touch-target select-none ${
|
||||
draggable ? "cursor-grab" : ""
|
||||
} ${isDragging ? "cursor-grabbing z-10" : ""}`}
|
||||
style={{
|
||||
width: `${length}px`,
|
||||
minWidth: `${length}px`,
|
||||
height: "40px",
|
||||
backgroundColor: color,
|
||||
borderColor: value === 1 ? "#ddd" : undefined,
|
||||
}}
|
||||
whileTap={draggable ? { scale: 1.08 } : undefined}
|
||||
drag={draggable}
|
||||
dragElastic={0.1}
|
||||
dragMomentum={false}
|
||||
onDragStart={() => setIsDragging(true)}
|
||||
onDragEnd={() => setIsDragging(false)}
|
||||
dragConstraints={{ left: -400, right: 400, top: -300, bottom: 300 }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
"use client"
|
||||
|
||||
import { motion } from "framer-motion"
|
||||
|
||||
interface GatoMascotaProps {
|
||||
mood?: "happy" | "thinking" | "celebrate" | "coach"
|
||||
message?: string
|
||||
size?: "sm" | "md" | "lg"
|
||||
}
|
||||
|
||||
const moods = {
|
||||
happy: {
|
||||
emoji: "😺",
|
||||
animation: { y: [0, -5, 0] },
|
||||
transition: { repeat: Infinity, duration: 2 },
|
||||
},
|
||||
thinking: {
|
||||
emoji: "🤔",
|
||||
animation: { rotate: [0, -10, 10, 0] },
|
||||
transition: { repeat: Infinity, duration: 1.5 },
|
||||
},
|
||||
celebrate: {
|
||||
emoji: "🎉",
|
||||
animation: { scale: [1, 1.2, 1] },
|
||||
transition: { repeat: Infinity, duration: 0.8 },
|
||||
},
|
||||
coach: {
|
||||
emoji: "🐱",
|
||||
animation: { y: 0 },
|
||||
transition: { duration: 0 },
|
||||
},
|
||||
}
|
||||
|
||||
export function GatoMascota({ mood = "happy", message, size = "md" }: GatoMascotaProps) {
|
||||
const config = moods[mood]
|
||||
const sizeClass = size === "sm" ? "text-3xl" : size === "lg" ? "text-7xl" : "text-5xl"
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<motion.span
|
||||
className={`${sizeClass} block select-none`}
|
||||
animate={config.animation}
|
||||
transition={config.transition}
|
||||
>
|
||||
{config.emoji}
|
||||
</motion.span>
|
||||
{message && (
|
||||
<p className="text-foreground/80 text-lg font-display font-medium text-center max-w-xs">
|
||||
{message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default GatoMascota
|
||||
@@ -0,0 +1,53 @@
|
||||
"use client"
|
||||
|
||||
import { useRouter } from "next/navigation"
|
||||
import { GatoMascota } from "./gato-mascota"
|
||||
|
||||
const CHILD_ID_KEY = "edueasy_child_id"
|
||||
|
||||
interface Props {
|
||||
onRetry?: () => void
|
||||
}
|
||||
|
||||
export default function NoChildError({ onRetry }: Props) {
|
||||
const router = useRouter()
|
||||
const hasChildId = typeof window !== "undefined" && localStorage.getItem(CHILD_ID_KEY)
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center justify-center p-8 gap-6">
|
||||
<GatoMascota mood="coach" message={hasChildId ? "Ups, hubo un problema" : "Todavía no estoy conectado"} size="lg" />
|
||||
<div className="text-center max-w-xs">
|
||||
{hasChildId ? (
|
||||
<p className="text-foreground/60">
|
||||
No pude conectar con el servidor. Revisá la conexión.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-foreground/60">Pedile a mamá que te empareje desde su celular.</p>
|
||||
<p className="text-foreground/40 text-sm mt-1">Mamá va a Dashboard → Emparejar nuevo iPad</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
{hasChildId && onRetry && (
|
||||
<button
|
||||
onClick={onRetry}
|
||||
className="bg-primary text-primary-foreground touch-target-lg rounded-2xl px-8 py-4 text-xl font-display font-bold"
|
||||
>
|
||||
Reintentar
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => router.push("/child/pairing")}
|
||||
className={`touch-target-lg rounded-2xl px-8 py-4 text-xl font-display font-bold ${
|
||||
hasChildId
|
||||
? "bg-secondary text-secondary-foreground"
|
||||
: "bg-primary text-primary-foreground"
|
||||
}`}
|
||||
>
|
||||
{hasChildId ? "Emparejar otro iPad" : "Ir a emparejar"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { motion } from "framer-motion"
|
||||
|
||||
interface TemporizadorVisualProps {
|
||||
durationMinutes: number
|
||||
onComplete: () => void
|
||||
running: boolean
|
||||
}
|
||||
|
||||
export default function TemporizadorVisual({
|
||||
durationMinutes,
|
||||
onComplete,
|
||||
running,
|
||||
}: TemporizadorVisualProps) {
|
||||
const [remaining, setRemaining] = useState(durationMinutes * 60)
|
||||
const totalSec = durationMinutes * 60
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const onCompleteRef = useRef(onComplete)
|
||||
onCompleteRef.current = onComplete
|
||||
|
||||
useEffect(() => {
|
||||
if (running) {
|
||||
intervalRef.current = setInterval(() => {
|
||||
setRemaining((prev) => {
|
||||
if (prev <= 1) {
|
||||
clearInterval(intervalRef.current!)
|
||||
onCompleteRef.current()
|
||||
return 0
|
||||
}
|
||||
return prev - 1
|
||||
})
|
||||
}, 1000)
|
||||
} else {
|
||||
if (intervalRef.current) clearInterval(intervalRef.current)
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (intervalRef.current) clearInterval(intervalRef.current)
|
||||
}
|
||||
}, [running])
|
||||
|
||||
const fraction = remaining / totalSec
|
||||
const degrees = fraction * 360
|
||||
const minutes = Math.floor(remaining / 60)
|
||||
const seconds = remaining % 60
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="relative w-20 h-20">
|
||||
<svg className="w-full h-full -rotate-90" viewBox="0 0 100 100">
|
||||
<circle
|
||||
cx="50" cy="50" r="42"
|
||||
fill="none"
|
||||
stroke="#E8E0D8"
|
||||
strokeWidth="8"
|
||||
/>
|
||||
<circle
|
||||
cx="50" cy="50" r="42"
|
||||
fill="none"
|
||||
stroke={fraction > 0.3 ? "#8CB8A0" : "#D4A574"}
|
||||
strokeWidth="8"
|
||||
strokeDasharray="264"
|
||||
strokeDashoffset={264 * (1 - fraction)}
|
||||
strokeLinecap="round"
|
||||
className="transition-all duration-1000 ease-linear"
|
||||
/>
|
||||
</svg>
|
||||
<span className="absolute inset-0 flex items-center justify-center text-lg font-display font-bold text-foreground">
|
||||
{minutes}:{seconds.toString().padStart(2, "0")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useRef, useCallback, useEffect } from "react"
|
||||
import { getStroke } from "perfect-freehand"
|
||||
|
||||
interface TrazoLetraProps {
|
||||
letra: string
|
||||
color?: string
|
||||
onComplete?: () => void
|
||||
promptLevel?: number
|
||||
}
|
||||
|
||||
export default function TrazoLetra({
|
||||
letra,
|
||||
color = "#8CB8A0",
|
||||
onComplete,
|
||||
promptLevel = 0,
|
||||
}: TrazoLetraProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const [isDrawing, setIsDrawing] = useState(false)
|
||||
const points = useRef<number[][]>([])
|
||||
const [showLetter, setShowLetter] = useState(promptLevel < 2)
|
||||
|
||||
useEffect(() => {
|
||||
if (promptLevel < 2) {
|
||||
drawLetterGuide(letra)
|
||||
}
|
||||
}, [letra, promptLevel])
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return
|
||||
canvas.style.touchAction = "none"
|
||||
}, [])
|
||||
|
||||
const drawLetterGuide = (l: string) => {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return
|
||||
const ctx = canvas.getContext("2d")
|
||||
if (!ctx) return
|
||||
const w = canvas.width
|
||||
const h = canvas.height
|
||||
ctx.clearRect(0, 0, w, h)
|
||||
|
||||
ctx.font = `${h * 0.6}px "Fredoka", sans-serif`
|
||||
ctx.textAlign = "center"
|
||||
ctx.textBaseline = "middle"
|
||||
ctx.globalAlpha = 0.15
|
||||
ctx.fillStyle = "#2D3436"
|
||||
ctx.fillText(l.toUpperCase(), w / 2, h / 2)
|
||||
ctx.globalAlpha = 1
|
||||
}
|
||||
|
||||
const startDrawing = useCallback((e: React.PointerEvent) => {
|
||||
setIsDrawing(true)
|
||||
points.current = [[e.clientX, e.clientY, e.pressure || 0.5]]
|
||||
const canvas = canvasRef.current
|
||||
if (canvas) canvas.setPointerCapture(e.pointerId)
|
||||
}, [])
|
||||
|
||||
const draw = useCallback((e: React.PointerEvent) => {
|
||||
if (!isDrawing) return
|
||||
points.current.push([e.clientX, e.clientY, e.pressure || 0.5])
|
||||
renderStroke()
|
||||
}, [isDrawing])
|
||||
|
||||
const endDrawing = useCallback(() => {
|
||||
setIsDrawing(false)
|
||||
if (points.current.length > 5 && onComplete) {
|
||||
onComplete()
|
||||
}
|
||||
points.current = []
|
||||
}, [onComplete])
|
||||
|
||||
const renderStroke = () => {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return
|
||||
const ctx = canvas.getContext("2d")
|
||||
if (!ctx) return
|
||||
|
||||
const stroke = getStroke(points.current, {
|
||||
size: 12,
|
||||
thinning: 0.5,
|
||||
smoothing: 0.5,
|
||||
streamline: 0.5,
|
||||
})
|
||||
|
||||
if (stroke.length < 2) return
|
||||
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||
if (showLetter) drawLetterGuide(letra)
|
||||
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(stroke[0][0], stroke[0][1])
|
||||
for (let i = 1; i < stroke.length; i++) {
|
||||
ctx.lineTo(stroke[i][0], stroke[i][1])
|
||||
}
|
||||
ctx.strokeStyle = color
|
||||
ctx.lineWidth = 3
|
||||
ctx.lineCap = "round"
|
||||
ctx.lineJoin = "round"
|
||||
ctx.fillStyle = color
|
||||
ctx.fill()
|
||||
|
||||
if (promptLevel < 3) {
|
||||
drawDirectionArrow(ctx, letra)
|
||||
}
|
||||
}
|
||||
|
||||
const drawDirectionArrow = (ctx: CanvasRenderingContext2D, l: string) => {
|
||||
const w = ctx.canvas.width
|
||||
const h = ctx.canvas.height
|
||||
ctx.save()
|
||||
ctx.globalAlpha = 0.3
|
||||
ctx.strokeStyle = "#8CB8A0"
|
||||
ctx.lineWidth = 2
|
||||
ctx.fillStyle = "#8CB8A0"
|
||||
|
||||
const arrowPaths: Record<string, { sx: number; sy: number; ex: number; ey: number }> = {
|
||||
a: { sx: w * 0.3, sy: h * 0.7, ex: w * 0.7, ey: h * 0.3 },
|
||||
e: { sx: w * 0.3, sy: h * 0.3, ex: w * 0.7, ey: h * 0.7 },
|
||||
i: { sx: w * 0.5, sy: h * 0.2, ex: w * 0.5, ey: h * 0.8 },
|
||||
o: { sx: w * 0.3, sy: h * 0.3, ex: w * 0.6, ey: h * 0.6 },
|
||||
u: { sx: w * 0.3, sy: h * 0.3, ex: w * 0.7, ey: h * 0.5 },
|
||||
}
|
||||
|
||||
const path = arrowPaths[l.toLowerCase()]
|
||||
if (path) {
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(path.sx, path.sy)
|
||||
ctx.lineTo(path.ex, path.ey)
|
||||
ctx.stroke()
|
||||
|
||||
const angle = Math.atan2(path.ey - path.sy, path.ex - path.sx)
|
||||
const sz = 6
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(path.ex, path.ey)
|
||||
ctx.lineTo(path.ex - sz * Math.cos(angle - Math.PI / 6), path.ey - sz * Math.sin(angle - Math.PI / 6))
|
||||
ctx.lineTo(path.ex - sz * Math.cos(angle + Math.PI / 6), path.ey - sz * Math.sin(angle + Math.PI / 6))
|
||||
ctx.closePath()
|
||||
ctx.fill()
|
||||
}
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={300}
|
||||
height={300}
|
||||
className="w-[250px] h-[250px] touch-none rounded-2xl bg-white shadow-md"
|
||||
onPointerDown={startDrawing}
|
||||
onPointerMove={draw}
|
||||
onPointerUp={endDrawing}
|
||||
onPointerCancel={endDrawing}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
"use client"
|
||||
|
||||
import { motion } from "framer-motion"
|
||||
|
||||
export interface VocalCardProps {
|
||||
letra: string
|
||||
fonema: string
|
||||
palabra: string
|
||||
imageEmoji: string
|
||||
color?: string
|
||||
onClick: () => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export default function VocalCard({
|
||||
letra,
|
||||
fonema,
|
||||
palabra,
|
||||
imageEmoji,
|
||||
color = "#8CB8A0",
|
||||
onClick,
|
||||
disabled = false,
|
||||
}: VocalCardProps) {
|
||||
return (
|
||||
<motion.button
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={`touch-target-lg rounded-3xl flex flex-col items-center justify-center gap-3 p-6 shadow-md ${disabled ? "opacity-40" : ""}`}
|
||||
style={{ backgroundColor: color }}
|
||||
whileTap={{ scale: 0.92 }}
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
>
|
||||
<span className="text-5xl font-display font-bold text-white drop-shadow-sm">
|
||||
{letra.toUpperCase()}
|
||||
</span>
|
||||
<span className="text-3xl">{imageEmoji}</span>
|
||||
<span className="text-white/90 text-lg font-display font-medium">
|
||||
{palabra}
|
||||
</span>
|
||||
</motion.button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { motion, AnimatePresence } from "framer-motion"
|
||||
import { speak } from "@/lib/speech"
|
||||
import { GatoMascota } from "@/components/child/gato-mascota"
|
||||
import type { Exercise } from "@/curriculum/types"
|
||||
|
||||
interface Props {
|
||||
exercise: Exercise
|
||||
onComplete: (correct: boolean) => void
|
||||
}
|
||||
|
||||
export default function EjercicioConciencia({ exercise, onComplete }: Props) {
|
||||
const { content } = exercise
|
||||
const options = content.options || []
|
||||
|
||||
const handleChoose = async (opt: (typeof options)[0]) => {
|
||||
if (content.audioText) await speak(content.audioText)
|
||||
onComplete(opt.correct)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-6">
|
||||
<GatoMascota mood="coach" message={content.instruction} size="sm" />
|
||||
|
||||
{content.text && (
|
||||
<p className="text-2xl font-display font-bold text-center">{content.text}</p>
|
||||
)}
|
||||
|
||||
{content.emoji && (
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
className="text-6xl"
|
||||
>
|
||||
{content.emoji}
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-4 justify-center max-w-md">
|
||||
{options.map((opt, i) => (
|
||||
<motion.button
|
||||
key={`${opt.label}-${i}`}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: i * 0.08 }}
|
||||
onClick={() => handleChoose(opt)}
|
||||
className="touch-target-lg rounded-2xl px-8 py-6 text-xl font-display font-bold shadow-md active:scale-95 transition-transform bg-white"
|
||||
>
|
||||
{opt.label}
|
||||
</motion.button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
"use client"
|
||||
|
||||
import { motion } from "framer-motion"
|
||||
import { speak } from "@/lib/speech"
|
||||
import { GatoMascota } from "@/components/child/gato-mascota"
|
||||
import type { Exercise } from "@/curriculum/types"
|
||||
|
||||
interface Props {
|
||||
exercise: Exercise
|
||||
onComplete: (correct: boolean) => void
|
||||
}
|
||||
|
||||
export default function EjercicioEleccion({ exercise, onComplete }: Props) {
|
||||
const { content } = exercise
|
||||
const options = content.options || []
|
||||
|
||||
const handleChoose = async (opt: (typeof options)[0]) => {
|
||||
if (content.audioText) await speak(content.audioText)
|
||||
onComplete(opt.correct)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-6">
|
||||
<GatoMascota mood="coach" message={content.instruction} size="sm" />
|
||||
|
||||
{content.text && (
|
||||
<p className="text-2xl font-display font-bold text-center">{content.text}</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-4 justify-center max-w-sm">
|
||||
{options.map((opt, i) => (
|
||||
<motion.button
|
||||
key={`${opt.label}-${i}`}
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ delay: i * 0.12 }}
|
||||
onClick={() => handleChoose(opt)}
|
||||
className="touch-target-lg rounded-2xl px-8 py-6 text-3xl font-display font-bold shadow-md active:scale-95 transition-transform bg-white"
|
||||
>
|
||||
{opt.label}
|
||||
</motion.button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
"use client"
|
||||
|
||||
import { motion } from "framer-motion"
|
||||
import { speak } from "@/lib/speech"
|
||||
import { GatoMascota } from "@/components/child/gato-mascota"
|
||||
import type { Exercise } from "@/curriculum/types"
|
||||
|
||||
interface Props {
|
||||
exercise: Exercise
|
||||
onComplete: (correct: boolean) => void
|
||||
}
|
||||
|
||||
export default function EjercicioLectura({ exercise, onComplete }: Props) {
|
||||
const { content } = exercise
|
||||
const options = content.options || []
|
||||
|
||||
const handleChoose = async (opt: (typeof options)[0]) => {
|
||||
if (content.audioText) await speak(content.audioText)
|
||||
onComplete(opt.correct)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-6">
|
||||
<GatoMascota mood="coach" message={content.instruction} size="sm" />
|
||||
|
||||
{content.text && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="bg-card border rounded-2xl p-6 max-w-md text-center"
|
||||
>
|
||||
<p className="text-xl font-display leading-relaxed">{content.text}</p>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{content.imageEmoji && (
|
||||
<div className="text-6xl">{content.imageEmoji}</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-4 justify-center max-w-sm">
|
||||
{options.map((opt, i) => (
|
||||
<motion.button
|
||||
key={`${opt.label}-${i}`}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: i * 0.12 }}
|
||||
onClick={() => handleChoose(opt)}
|
||||
className="touch-target-lg rounded-2xl px-8 py-5 text-lg font-display font-bold shadow-md active:scale-95 transition-transform bg-white"
|
||||
>
|
||||
{opt.label}
|
||||
</motion.button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { motion, AnimatePresence } from "framer-motion"
|
||||
import { speak } from "@/lib/speech"
|
||||
import { GatoMascota } from "@/components/child/gato-mascota"
|
||||
import type { Exercise } from "@/curriculum/types"
|
||||
|
||||
interface Props {
|
||||
exercise: Exercise
|
||||
onComplete: (correct: boolean) => void
|
||||
}
|
||||
|
||||
export default function EjercicioPalabra({ exercise, onComplete }: Props) {
|
||||
const { content } = exercise
|
||||
const options = content.options || []
|
||||
|
||||
const handleChoose = async (opt: (typeof options)[0]) => {
|
||||
if (opt.id === "escuchar") {
|
||||
if (content.audioText) await speak(content.audioText)
|
||||
return
|
||||
}
|
||||
onComplete(opt.correct)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-6">
|
||||
<GatoMascota mood="coach" message={content.instruction} size="sm" />
|
||||
|
||||
{content.imageEmoji && (
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
className="text-6xl"
|
||||
>
|
||||
{content.imageEmoji}
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-4 justify-center max-w-md">
|
||||
{options.map((opt, i) => (
|
||||
<motion.button
|
||||
key={`${opt.label}-${i}`}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: i * 0.08 }}
|
||||
onClick={() => handleChoose(opt)}
|
||||
className={`touch-target-lg rounded-2xl px-8 py-6 text-xl font-display font-bold shadow-md active:scale-95 transition-transform ${
|
||||
opt.id === "escuchar"
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "bg-white"
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</motion.button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { motion, Reorder } from "framer-motion"
|
||||
import { speak } from "@/lib/speech"
|
||||
import { GatoMascota } from "@/components/child/gato-mascota"
|
||||
import type { Exercise } from "@/curriculum/types"
|
||||
|
||||
interface Props {
|
||||
exercise: Exercise
|
||||
onComplete: (correct: boolean) => void
|
||||
}
|
||||
|
||||
export default function EjercicioSecuencia({ exercise, onComplete }: Props) {
|
||||
const { content } = exercise
|
||||
const sequence = content.sequence || []
|
||||
|
||||
const [items, setItems] = useState(() =>
|
||||
[...sequence].sort(() => Math.random() - 0.5),
|
||||
)
|
||||
|
||||
const handleReorder = (newOrder: string[]) => {
|
||||
setItems(newOrder)
|
||||
if (newOrder.every((item, i) => item === sequence[i])) {
|
||||
if (content.audioText) speak(content.audioText)
|
||||
onComplete(true)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-6">
|
||||
<GatoMascota mood="coach" message={content.instruction} size="sm" />
|
||||
|
||||
<Reorder.Group
|
||||
axis="y"
|
||||
values={items}
|
||||
onReorder={handleReorder}
|
||||
className="flex flex-col gap-3 max-w-xs w-full"
|
||||
>
|
||||
{items.map((item) => (
|
||||
<Reorder.Item
|
||||
key={item}
|
||||
value={item}
|
||||
className="touch-target-lg rounded-2xl px-6 py-4 text-xl font-display font-bold bg-white shadow-md cursor-grab active:cursor-grabbing text-center"
|
||||
>
|
||||
{item}
|
||||
</Reorder.Item>
|
||||
))}
|
||||
</Reorder.Group>
|
||||
|
||||
<p className="text-sm text-foreground/40">Arrastrá para ordenar</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
"use client"
|
||||
|
||||
import { motion } from "framer-motion"
|
||||
import { speak } from "@/lib/speech"
|
||||
import { GatoMascota } from "@/components/child/gato-mascota"
|
||||
import type { Exercise } from "@/curriculum/types"
|
||||
|
||||
interface Props {
|
||||
exercise: Exercise
|
||||
onComplete: (correct: boolean) => void
|
||||
}
|
||||
|
||||
export default function EjercicioSensibilizacion({ exercise, onComplete }: Props) {
|
||||
const { content } = exercise
|
||||
const hasOptions = content.options && content.options.length > 0
|
||||
|
||||
const handleClick = async (option: { label: string; correct: boolean }) => {
|
||||
if (content.audioText) await speak(content.audioText)
|
||||
onComplete(option.correct)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-6">
|
||||
<GatoMascota mood="coach" message={content.instruction} size="sm" />
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="text-6xl text-center"
|
||||
>
|
||||
{content.emoji && <span>{content.emoji}</span>}
|
||||
</motion.div>
|
||||
|
||||
{hasOptions && (
|
||||
<div className="flex flex-wrap gap-4 justify-center max-w-sm">
|
||||
{content.options!.map((opt, i) => (
|
||||
<motion.button
|
||||
key={opt.label}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: i * 0.1 }}
|
||||
onClick={() => handleClick(opt)}
|
||||
className="touch-target-lg rounded-2xl px-8 py-6 text-xl font-display font-bold shadow-md active:scale-95 transition-transform bg-white"
|
||||
>
|
||||
{opt.label}
|
||||
</motion.button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!hasOptions && (
|
||||
<button
|
||||
onClick={() => onComplete(true)}
|
||||
className="bg-primary text-primary-foreground touch-target-lg rounded-2xl px-8 py-4 text-xl font-display font-bold"
|
||||
>
|
||||
¡Lo hice!
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
"use client"
|
||||
|
||||
import { motion } from "framer-motion"
|
||||
import { speak } from "@/lib/speech"
|
||||
import { GatoMascota } from "@/components/child/gato-mascota"
|
||||
import TrazoLetra from "@/components/child/trazo-letra"
|
||||
import type { Exercise } from "@/curriculum/types"
|
||||
|
||||
interface Props {
|
||||
exercise: Exercise
|
||||
onComplete: (correct: boolean) => void
|
||||
}
|
||||
|
||||
export default function EjercicioTrazo({ exercise, onComplete }: Props) {
|
||||
const { content } = exercise
|
||||
const charToTrace = content.text && content.text.length === 1
|
||||
? content.text.toUpperCase()
|
||||
: null
|
||||
|
||||
const handleAudio = async () => {
|
||||
if (content.audioText) await speak(content.audioText)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-6">
|
||||
<GatoMascota mood="coach" message={content.instruction} size="sm" />
|
||||
|
||||
{charToTrace ? (
|
||||
<>
|
||||
<TrazoLetra
|
||||
letra={charToTrace}
|
||||
color={content.color || '#8CB8A0'}
|
||||
onComplete={() => onComplete(true)}
|
||||
promptLevel={0}
|
||||
/>
|
||||
{content.audioText && (
|
||||
<button
|
||||
onClick={handleAudio}
|
||||
className="text-sm text-primary underline"
|
||||
>
|
||||
Escuchar instrucción
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{content.emoji && (
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
className="text-6xl"
|
||||
>
|
||||
{content.emoji}
|
||||
</motion.div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => onComplete(true)}
|
||||
className="bg-primary text-primary-foreground touch-target-lg rounded-2xl px-8 py-4 text-xl font-display font-bold"
|
||||
>
|
||||
¡Lo hice!
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
"use client"
|
||||
|
||||
import dynamic from "next/dynamic"
|
||||
import type { Exercise, ExerciseType } from "@/curriculum/types"
|
||||
|
||||
const EjercicioSensibilizacion = dynamic(() => import("./ejercicio-sensibilizacion"), { ssr: false })
|
||||
const EjercicioConciencia = dynamic(() => import("./ejercicio-conciencia"), { ssr: false })
|
||||
const EjercicioEleccion = dynamic(() => import("./ejercicio-eleccion"), { ssr: false })
|
||||
const EjercicioSecuencia = dynamic(() => import("./ejercicio-secuencia"), { ssr: false })
|
||||
const EjercicioPalabra = dynamic(() => import("./ejercicio-palabra"), { ssr: false })
|
||||
const EjercicioLectura = dynamic(() => import("./ejercicio-lectura"), { ssr: false })
|
||||
const EjercicioTrazo = dynamic(() => import("./ejercicio-trazo"), { ssr: false })
|
||||
|
||||
const componentMap: Record<ExerciseType, React.ComponentType<{ exercise: Exercise; onComplete: (correct: boolean) => void }>> = {
|
||||
sensibilizacion: EjercicioSensibilizacion,
|
||||
conciencia: EjercicioConciencia,
|
||||
trazo: EjercicioTrazo,
|
||||
eleccion: EjercicioEleccion,
|
||||
emparejar: EjercicioConciencia,
|
||||
secuencia: EjercicioSecuencia,
|
||||
palabra: EjercicioPalabra,
|
||||
problema: EjercicioLectura,
|
||||
lectura: EjercicioLectura,
|
||||
}
|
||||
|
||||
interface Props {
|
||||
exercise: Exercise
|
||||
onComplete: (correct: boolean) => void
|
||||
}
|
||||
|
||||
export default function ExerciseDispatcher({ exercise, onComplete }: Props) {
|
||||
const Component = componentMap[exercise.type] || EjercicioSensibilizacion
|
||||
return <Component exercise={exercise} onComplete={onComplete} />
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
"use client"
|
||||
|
||||
const errorLabels: Record<string, string> = {
|
||||
"discriminacion-auditiva": "Discriminación auditiva",
|
||||
"confusion-letra": "Confusión de letra",
|
||||
"silaba-dificil": "Sílaba difícil",
|
||||
"omision-letra": "Omisión de letra",
|
||||
"inversion-letra": "Inversión de letra",
|
||||
}
|
||||
|
||||
const errorColors: Record<string, string> = {
|
||||
"discriminacion-auditiva": "bg-blue-200 text-blue-800",
|
||||
"confusion-letra": "bg-red-200 text-red-800",
|
||||
"silaba-dificil": "bg-yellow-200 text-yellow-800",
|
||||
"omision-letra": "bg-purple-200 text-purple-800",
|
||||
"inversion-letra": "bg-orange-200 text-orange-800",
|
||||
}
|
||||
|
||||
interface Props {
|
||||
data: Record<string, number>
|
||||
total: number
|
||||
}
|
||||
|
||||
export default function ErrorPatternsChart({ data, total }: Props) {
|
||||
const entries = Object.entries(data)
|
||||
|
||||
if (total === 0) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">
|
||||
Sin errores registrados. ¡Bien ahí!
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{entries.map(([key, count]) => {
|
||||
const pct = Math.round((count / total) * 100)
|
||||
return (
|
||||
<div key={key}>
|
||||
<div className="flex justify-between text-sm mb-1">
|
||||
<span className="font-medium">{errorLabels[key] || key}</span>
|
||||
<span className="text-muted-foreground">
|
||||
{count} ({pct}%)
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all ${
|
||||
key === "discriminacion-auditiva"
|
||||
? "bg-blue-400"
|
||||
: key === "confusion-letra"
|
||||
? "bg-red-400"
|
||||
: key === "silaba-dificil"
|
||||
? "bg-yellow-400"
|
||||
: key === "omision-letra"
|
||||
? "bg-purple-400"
|
||||
: "bg-orange-400"
|
||||
}`}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface MetricCardProps {
|
||||
title: string
|
||||
value: string | number
|
||||
subtitle?: string
|
||||
variant?: "default" | "primary" | "success" | "accent"
|
||||
className?: string
|
||||
}
|
||||
|
||||
const variants = {
|
||||
default: "bg-white",
|
||||
primary: "bg-primary/10 border-primary/20",
|
||||
success: "bg-success/10 border-success/20",
|
||||
accent: "bg-accent/10 border-accent/20",
|
||||
}
|
||||
|
||||
export function MetricCard({ title, value, subtitle, variant = "default", className }: MetricCardProps) {
|
||||
return (
|
||||
<div className={cn("rounded-2xl p-4 shadow-sm border border-border", variants[variant], className)}>
|
||||
<p className="text-sm text-muted-foreground">{title}</p>
|
||||
<p className="text-3xl font-display font-bold text-foreground mt-1">{value}</p>
|
||||
{subtitle && <p className="text-xs text-muted-foreground mt-1">{subtitle}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
interface MilestoneData {
|
||||
skillCode: string
|
||||
reachedAt: string
|
||||
}
|
||||
|
||||
const skillLabels: Record<string, string> = {
|
||||
"vocal-a": "Letra A",
|
||||
"vocal-e": "Letra E",
|
||||
"vocal-i": "Letra I",
|
||||
"vocal-o": "Letra O",
|
||||
"vocal-u": "Letra U",
|
||||
"numero-1": "Número 1",
|
||||
"numero-2": "Número 2",
|
||||
"numero-3": "Número 3",
|
||||
"numero-4": "Número 4",
|
||||
"numero-5": "Número 5",
|
||||
}
|
||||
|
||||
export function MilestonesList({ data }: { data: MilestoneData[] }) {
|
||||
if (!data || data.length === 0) {
|
||||
return (
|
||||
<div className="text-center text-muted-foreground italic py-4">
|
||||
Todavía no hay logros
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{data.map((m) => (
|
||||
<div
|
||||
key={m.skillCode}
|
||||
className="bg-primary/10 border border-primary/20 rounded-xl px-3 py-2 text-sm font-display font-medium"
|
||||
>
|
||||
{skillLabels[m.skillCode] || m.skillCode}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
RadarChart,
|
||||
PolarGrid,
|
||||
PolarAngleAxis,
|
||||
PolarRadiusAxis,
|
||||
Radar,
|
||||
ResponsiveContainer,
|
||||
} from "recharts"
|
||||
|
||||
interface SkillData {
|
||||
skillCode: string
|
||||
stability: number
|
||||
reps: number
|
||||
}
|
||||
|
||||
export function SkillMasteryChart({ data }: { data: SkillData[] }) {
|
||||
if (!data || data.length === 0) {
|
||||
return (
|
||||
<div className="text-center text-muted-foreground italic py-8">
|
||||
Sin skills todavía
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const chartData = data
|
||||
.filter((s) => s.reps > 0)
|
||||
.slice(0, 8)
|
||||
.map((s) => ({
|
||||
skill: s.skillCode.replace("vocal-", "").replace("numero-", "num "),
|
||||
mastery: Math.min(Math.round((s.stability / 100) * 100), 100),
|
||||
}))
|
||||
|
||||
if (chartData.length === 0) {
|
||||
return (
|
||||
<div className="text-center text-muted-foreground italic py-8">
|
||||
Sin skills todavía
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full h-48">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<RadarChart data={chartData} margin={{ top: 8, right: 8, bottom: 8, left: 8 }}>
|
||||
<PolarGrid stroke="#E8E0D8" />
|
||||
<PolarAngleAxis dataKey="skill" tick={{ fontSize: 11, fill: "#6B7280" }} />
|
||||
<PolarRadiusAxis angle={90} domain={[0, 100]} tick={false} />
|
||||
<Radar
|
||||
name="Maestría"
|
||||
dataKey="mastery"
|
||||
stroke="#8CB8A0"
|
||||
fill="#8CB8A0"
|
||||
fillOpacity={0.3}
|
||||
/>
|
||||
</RadarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
CartesianGrid,
|
||||
} from "recharts"
|
||||
|
||||
interface WeeklyData {
|
||||
date: string
|
||||
minutes: number
|
||||
}
|
||||
|
||||
export function WeeklyActivityChart({ data }: { data: WeeklyData[] }) {
|
||||
if (!data || data.length === 0) {
|
||||
return (
|
||||
<div className="text-center text-muted-foreground italic py-8">
|
||||
No hay actividad esta semana
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const daysOfWeek = ["dom", "lun", "mar", "mié", "jue", "vie", "sáb"]
|
||||
const chartData = data.map((d) => {
|
||||
const day = new Date(d.date).getDay()
|
||||
return {
|
||||
name: daysOfWeek[day],
|
||||
minutos: d.minutes,
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="w-full h-48">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={chartData} margin={{ top: 8, right: 8, bottom: 0, left: -16 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#E8E0D8" />
|
||||
<XAxis dataKey="name" tick={{ fontSize: 12, fill: "#6B7280" }} />
|
||||
<YAxis tick={{ fontSize: 12, fill: "#6B7280" }} />
|
||||
<Tooltip
|
||||
contentStyle={{ borderRadius: 12, border: "1px solid #E8E0D8" }}
|
||||
formatter={(value: number) => [`${value} min`, "Actividad"]}
|
||||
/>
|
||||
<Bar dataKey="minutos" fill="#8CB8A0" radius={[6, 6, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as AvatarPrimitive from "@radix-ui/react-avatar"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Avatar = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Avatar.displayName = AvatarPrimitive.Root.displayName
|
||||
|
||||
const AvatarImage = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Image>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Image
|
||||
ref={ref}
|
||||
className={cn("aspect-square h-full w-full", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AvatarImage.displayName = AvatarPrimitive.Image.displayName
|
||||
|
||||
const AvatarFallback = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Fallback>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Fallback
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-full w-full items-center justify-center rounded-full bg-muted",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
|
||||
|
||||
export { Avatar, AvatarImage, AvatarFallback }
|
||||
@@ -0,0 +1,36 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
||||
outline: "text-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
@@ -0,0 +1,56 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Button.displayName = "Button"
|
||||
|
||||
export { Button, buttonVariants }
|
||||
@@ -0,0 +1,79 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Card.displayName = "Card"
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardHeader.displayName = "CardHeader"
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-2xl font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardTitle.displayName = "CardTitle"
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardDescription.displayName = "CardDescription"
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
))
|
||||
CardContent.displayName = "CardContent"
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex items-center p-6 pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardFooter.displayName = "CardFooter"
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||
@@ -0,0 +1,22 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Input.displayName = "Input"
|
||||
|
||||
export { Input }
|
||||
@@ -0,0 +1,26 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const labelVariants = cva(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
)
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||
VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(labelVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Label.displayName = LabelPrimitive.Root.displayName
|
||||
|
||||
export { Label }
|
||||
@@ -0,0 +1,28 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as ProgressPrimitive from "@radix-ui/react-progress"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Progress = React.forwardRef<
|
||||
React.ElementRef<typeof ProgressPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
|
||||
>(({ className, value, ...props }, ref) => (
|
||||
<ProgressPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative h-4 w-full overflow-hidden rounded-full bg-secondary",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
className="h-full w-full flex-1 bg-primary transition-all"
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
))
|
||||
Progress.displayName = ProgressPrimitive.Root.displayName
|
||||
|
||||
export { Progress }
|
||||
@@ -0,0 +1,15 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Skeleton({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn("animate-pulse rounded-md bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
@@ -0,0 +1,55 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Tabs = TabsPrimitive.Root
|
||||
|
||||
const TabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsList.displayName = TabsPrimitive.List.displayName
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
||||
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { Exercise, Stage, TopicCurriculum } from "./types"
|
||||
|
||||
const lecturaModules: Record<string, Stage> = {}
|
||||
const numerosModules: Record<string, Stage> = {}
|
||||
|
||||
async function loadStages(
|
||||
topic: string,
|
||||
modules: Record<string, Stage>,
|
||||
stageFiles: number[],
|
||||
) {
|
||||
for (const s of stageFiles) {
|
||||
try {
|
||||
const mod = await import(`./${topic}/etapa-${s}`)
|
||||
modules[s] = mod.default
|
||||
} catch {
|
||||
// stage not yet implemented
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let lecturaLoaded = false
|
||||
let numerosLoaded = false
|
||||
|
||||
export function getAllLecturaExercises(): Exercise[] {
|
||||
const stages = Object.values(getLecturaStages())
|
||||
return stages.flatMap((s) => s.exercises || [])
|
||||
}
|
||||
|
||||
export function getLecturaStages(): Record<string, Stage> {
|
||||
return lecturaModules as Record<string, Stage>
|
||||
}
|
||||
|
||||
export function getNumerosStages(): Record<string, Stage> {
|
||||
return numerosModules as Record<string, Stage>
|
||||
}
|
||||
|
||||
export async function initCurriculum() {
|
||||
if (!lecturaLoaded) {
|
||||
await loadStages("lectura", lecturaModules, [1, 2, 3, 4, 5, 6, 7, 8, 9])
|
||||
lecturaLoaded = true
|
||||
}
|
||||
if (!numerosLoaded) {
|
||||
await loadStages("numeros", numerosModules, [1, 2, 3, 4, 5, 6, 7, 8])
|
||||
numerosLoaded = true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import type { Stage } from '../types'
|
||||
|
||||
const stage: Stage = {
|
||||
id: 1,
|
||||
name: 'Sensibilización al lenguaje escrito',
|
||||
description: 'Explorar sonidos del entorno, onomatopeyas, ritmo y conciencia de que el lenguaje se puede representar.',
|
||||
exercises: [
|
||||
// --- Sonidos del entorno ---
|
||||
{
|
||||
id: 'lec1-sonido-perro',
|
||||
stage: 1,
|
||||
skillCode: 'sensibilizacion-sonidos',
|
||||
type: 'sensibilizacion',
|
||||
prerequisites: [],
|
||||
content: {
|
||||
instruction: 'Escuchá este sonido. ¿Qué animal hace así?',
|
||||
audioText: '¡El perro hace guau guau!',
|
||||
options: [
|
||||
{ label: '🐱 Gato', correct: false },
|
||||
{ label: '🐕 Perro', correct: true },
|
||||
{ label: '🐄 Vaca', correct: false },
|
||||
],
|
||||
emoji: '🐕',
|
||||
},
|
||||
functionalContext: 'Identificar sonidos cotidianos del entorno.',
|
||||
},
|
||||
{
|
||||
id: 'lec1-sonido-gato',
|
||||
stage: 1,
|
||||
skillCode: 'sensibilizacion-sonidos',
|
||||
type: 'sensibilizacion',
|
||||
prerequisites: [],
|
||||
content: {
|
||||
instruction: '¿Quién hace miau?',
|
||||
audioText: 'El gato hace miau miau',
|
||||
options: [
|
||||
{ label: '🐕 Perro', correct: false },
|
||||
{ label: '🐄 Vaca', correct: false },
|
||||
{ label: '🐱 Gato', correct: true },
|
||||
],
|
||||
emoji: '🐱',
|
||||
},
|
||||
functionalContext: 'Asociar sonidos con animales.',
|
||||
},
|
||||
{
|
||||
id: 'lec1-sonido-vaca',
|
||||
stage: 1,
|
||||
skillCode: 'sensibilizacion-sonidos',
|
||||
type: 'sensibilizacion',
|
||||
prerequisites: [],
|
||||
content: {
|
||||
instruction: '¿Cuál hace muuuu?',
|
||||
audioText: 'La vaca hace muuu',
|
||||
options: [
|
||||
{ label: '🐄 Vaca', correct: true },
|
||||
{ label: '🐔 Gallina', correct: false },
|
||||
{ label: '🐑 Oveja', correct: false },
|
||||
],
|
||||
emoji: '🐄',
|
||||
},
|
||||
functionalContext: 'Reconocer sonidos de animales.',
|
||||
},
|
||||
{
|
||||
id: 'lec1-sonido-auto',
|
||||
stage: 1,
|
||||
skillCode: 'sensibilizacion-sonidos',
|
||||
type: 'sensibilizacion',
|
||||
prerequisites: [],
|
||||
content: {
|
||||
instruction: '¿Qué hace run run?',
|
||||
audioText: 'El auto hace run run',
|
||||
options: [
|
||||
{ label: '🚲 Bici', correct: false },
|
||||
{ label: '🚗 Auto', correct: true },
|
||||
{ label: '✈️ Avión', correct: false },
|
||||
],
|
||||
emoji: '🚗',
|
||||
},
|
||||
functionalContext: 'Identificar sonidos de transporte.',
|
||||
},
|
||||
{
|
||||
id: 'lec1-sonido-lluvia',
|
||||
stage: 1,
|
||||
skillCode: 'sensibilizacion-sonidos',
|
||||
type: 'sensibilizacion',
|
||||
prerequisites: [],
|
||||
content: {
|
||||
instruction: '¿Qué hace pío pío?',
|
||||
audioText: 'El pajarito hace pío pío',
|
||||
options: [
|
||||
{ label: '🐟 Pez', correct: false },
|
||||
{ label: '🐔 Gallina', correct: false },
|
||||
{ label: '🐤 Pájaro', correct: true },
|
||||
],
|
||||
emoji: '🐤',
|
||||
},
|
||||
functionalContext: 'Asociar onomatopeyas con animales.',
|
||||
},
|
||||
// --- Sonidos de la naturaleza ---
|
||||
{
|
||||
id: 'lec1-sonido-abeja',
|
||||
stage: 1,
|
||||
skillCode: 'sensibilizacion-sonidos',
|
||||
type: 'sensibilizacion',
|
||||
prerequisites: [],
|
||||
content: {
|
||||
instruction: '¿Quién hace zzzzz?',
|
||||
audioText: 'La abeja hace zzzz',
|
||||
options: [
|
||||
{ label: '🐝 Abeja', correct: true },
|
||||
{ label: '🦋 Mariposa', correct: false },
|
||||
{ label: '🐞 Mariquita', correct: false },
|
||||
],
|
||||
emoji: '🐝',
|
||||
},
|
||||
functionalContext: 'Discriminar sonidos de insectos.',
|
||||
},
|
||||
{
|
||||
id: 'lec1-sonido-agua',
|
||||
stage: 1,
|
||||
skillCode: 'sensibilizacion-sonidos',
|
||||
type: 'sensibilizacion',
|
||||
prerequisites: [],
|
||||
content: {
|
||||
instruction: '¿Qué suena así: glu glu glu?',
|
||||
audioText: 'El agua hace glu glu',
|
||||
options: [
|
||||
{ label: '💧 Agua', correct: true },
|
||||
{ label: '🔥 Fuego', correct: false },
|
||||
{ label: '💨 Viento', correct: false },
|
||||
],
|
||||
emoji: '💧',
|
||||
},
|
||||
functionalContext: 'Reconocer sonidos del agua.',
|
||||
},
|
||||
// --- Ritmo y rimas ---
|
||||
{
|
||||
id: 'lec1-ritmo-palmas',
|
||||
stage: 1,
|
||||
skillCode: 'sensibilizacion-ritmo',
|
||||
type: 'sensibilizacion',
|
||||
prerequisites: [],
|
||||
content: {
|
||||
instruction: '¡Vamos a aplaudir! ¿Podés seguir el ritmo? DA-da-da, DA-da-da',
|
||||
audioText: 'Vamos a aplaudir juntos',
|
||||
emoji: '👏',
|
||||
},
|
||||
functionalContext: 'Desarrollar conciencia rítmica.',
|
||||
},
|
||||
{
|
||||
id: 'lec1-cancion-infantil',
|
||||
stage: 1,
|
||||
skillCode: 'sensibilizacion-ritmo',
|
||||
type: 'sensibilizacion',
|
||||
prerequisites: [],
|
||||
content: {
|
||||
instruction: 'Cantemos juntos: "Estrellita, ¿dónde estás?"',
|
||||
audioText: 'Estrellita, ¿dónde estás? Me pregunto quién serás',
|
||||
emoji: '⭐',
|
||||
},
|
||||
functionalContext: 'Explorar ritmo y melodía del lenguaje.',
|
||||
},
|
||||
{
|
||||
id: 'lec1-silencio-ruido',
|
||||
stage: 1,
|
||||
skillCode: 'sensibilizacion-discriminacion',
|
||||
type: 'sensibilizacion',
|
||||
prerequisites: [],
|
||||
content: {
|
||||
instruction: 'A veces hay silencio, a veces hay ruido. ¿Cuándo escuchamos silencio?',
|
||||
audioText: 'El silencio es cuando no hay sonido',
|
||||
options: [
|
||||
{ label: '🔊 Música fuerte', correct: false },
|
||||
{ label: '🤫 Todos callados', correct: true },
|
||||
{ label: '📢 Gritos', correct: false },
|
||||
],
|
||||
emoji: '🤫',
|
||||
},
|
||||
functionalContext: 'Discriminar presencia y ausencia de sonido.',
|
||||
},
|
||||
{
|
||||
id: 'lec1-eco',
|
||||
stage: 1,
|
||||
skillCode: 'sensibilizacion-eco',
|
||||
type: 'sensibilizacion',
|
||||
prerequisites: [],
|
||||
content: {
|
||||
instruction: 'Vamos a hacer eco. Decí después de mí: PA... PA... PA',
|
||||
audioText: 'PA... PA... PA... repetí después de mí',
|
||||
options: [
|
||||
{ label: '¡Lo hice!', correct: true },
|
||||
],
|
||||
emoji: '🔊',
|
||||
},
|
||||
functionalContext: 'Tomar conciencia de la propia producción de sonidos.',
|
||||
},
|
||||
{
|
||||
id: 'lec1-onomatopeya-coco',
|
||||
stage: 1,
|
||||
skillCode: 'sensibilizacion-onomatopeya',
|
||||
type: 'sensibilizacion',
|
||||
prerequisites: [],
|
||||
content: {
|
||||
instruction: 'El reloj hace: tic-tac, tic-tac. ¿Cuál es el reloj?',
|
||||
audioText: 'El reloj hace tic tac',
|
||||
options: [
|
||||
{ label: '🕐 Reloj', correct: true },
|
||||
{ label: '📞 Teléfono', correct: false },
|
||||
{ label: '🚪 Puerta', correct: false },
|
||||
],
|
||||
emoji: '🕐',
|
||||
},
|
||||
functionalContext: 'Asociar objetos a sus sonidos.',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export default stage
|
||||
@@ -0,0 +1,345 @@
|
||||
import type { Stage } from '../types'
|
||||
|
||||
const stage: Stage = {
|
||||
id: 2,
|
||||
name: 'Conciencia fonológica',
|
||||
description: 'Rimas, conteo de sílabas, identificar sonidos iniciales y segmentación de palabras.',
|
||||
exercises: [
|
||||
// --- Rimas ---
|
||||
{
|
||||
id: 'lec2-rima-sol',
|
||||
stage: 2,
|
||||
skillCode: 'conciencia-rimas',
|
||||
type: 'conciencia',
|
||||
prerequisites: ['lec1-sonido-perro', 'lec1-ritmo-palmas'],
|
||||
content: {
|
||||
instruction: '¿Cuál palabra rima con SOL?',
|
||||
text: 'SOL',
|
||||
audioText: 'Sol rima con...',
|
||||
options: [
|
||||
{ label: 'SOL', correct: false },
|
||||
{ label: 'CARACOL', correct: true },
|
||||
{ label: 'LUNA', correct: false },
|
||||
],
|
||||
emoji: '☀️',
|
||||
},
|
||||
functionalContext: 'Identificar palabras que terminan igual.',
|
||||
errorType: 'discriminacion-auditiva',
|
||||
},
|
||||
{
|
||||
id: 'lec2-rima-pan',
|
||||
stage: 2,
|
||||
skillCode: 'conciencia-rimas',
|
||||
type: 'conciencia',
|
||||
prerequisites: ['lec1-sonido-perro'],
|
||||
content: {
|
||||
instruction: '¿Qué rima con PAN?',
|
||||
text: 'PAN',
|
||||
audioText: 'Pan rima con...',
|
||||
options: [
|
||||
{ label: 'MANO', correct: false },
|
||||
{ label: 'PAN', correct: false },
|
||||
{ label: 'CHAPAN', correct: true },
|
||||
],
|
||||
emoji: '🍞',
|
||||
},
|
||||
functionalContext: 'Reconocer terminaciones comunes.',
|
||||
errorType: 'discriminacion-auditiva',
|
||||
},
|
||||
{
|
||||
id: 'lec2-rima-flor',
|
||||
stage: 2,
|
||||
skillCode: 'conciencia-rimas',
|
||||
type: 'conciencia',
|
||||
prerequisites: ['lec1-sonido-perro'],
|
||||
content: {
|
||||
instruction: 'Buscá la palabra que rima con FLOR',
|
||||
text: 'FLOR',
|
||||
audioText: 'Flor rima con...',
|
||||
options: [
|
||||
{ label: 'COLOR', correct: true },
|
||||
{ label: 'HOJA', correct: false },
|
||||
{ label: 'FLOR', correct: false },
|
||||
],
|
||||
emoji: '🌸',
|
||||
},
|
||||
functionalContext: 'Identificar rimas en palabras conocidas.',
|
||||
errorType: 'discriminacion-auditiva',
|
||||
},
|
||||
{
|
||||
id: 'lec2-rima-miel',
|
||||
stage: 2,
|
||||
skillCode: 'conciencia-rimas',
|
||||
type: 'conciencia',
|
||||
prerequisites: ['lec1-sonido-abeja'],
|
||||
content: {
|
||||
instruction: '¿Cuál rima con MIEL?',
|
||||
text: 'MIEL',
|
||||
audioText: 'Miel rima con...',
|
||||
options: [
|
||||
{ label: 'MIEL', correct: false },
|
||||
{ label: 'PAN', correct: false },
|
||||
{ label: 'PINCEL', correct: true },
|
||||
],
|
||||
emoji: '🍯',
|
||||
},
|
||||
functionalContext: 'Reconocer patrones sonoros finales.',
|
||||
errorType: 'discriminacion-auditiva',
|
||||
},
|
||||
// --- Conteo de sílabas ---
|
||||
{
|
||||
id: 'lec2-silabas-2',
|
||||
stage: 2,
|
||||
skillCode: 'conciencia-silabas',
|
||||
type: 'conciencia',
|
||||
prerequisites: ['lec1-eco'],
|
||||
content: {
|
||||
instruction: 'Vamos a contar: ¿cuántas palmadas tiene MANO? (MA-NO)',
|
||||
text: 'MANO',
|
||||
audioText: 'MA-NO, dos palmadas',
|
||||
options: [
|
||||
{ label: '👏 Una', correct: false },
|
||||
{ label: '👏👏 Dos', correct: true },
|
||||
{ label: '👏👏👏 Tres', correct: false },
|
||||
],
|
||||
emoji: '✋',
|
||||
},
|
||||
functionalContext: 'Desarrollar conciencia de segmentación silábica.',
|
||||
errorType: 'discriminacion-auditiva',
|
||||
},
|
||||
{
|
||||
id: 'lec2-silabas-3',
|
||||
stage: 2,
|
||||
skillCode: 'conciencia-silabas',
|
||||
type: 'conciencia',
|
||||
prerequisites: ['lec2-silabas-2'],
|
||||
content: {
|
||||
instruction: '¿Cuántas partes tiene VENTANA? (VEN-TA-NA)',
|
||||
text: 'VENTANA',
|
||||
audioText: 'VEN-TA-NA, tres partes',
|
||||
options: [
|
||||
{ label: '👏 Dos', correct: false },
|
||||
{ label: '👏👏👏 Tres', correct: true },
|
||||
{ label: '👏👏👏👏 Cuatro', correct: false },
|
||||
],
|
||||
emoji: '🪟',
|
||||
},
|
||||
functionalContext: 'Segmentar palabras en sílabas.',
|
||||
errorType: 'discriminacion-auditiva',
|
||||
},
|
||||
{
|
||||
id: 'lec2-silabas-1',
|
||||
stage: 2,
|
||||
skillCode: 'conciencia-silabas',
|
||||
type: 'conciencia',
|
||||
prerequisites: ['lec2-silabas-2'],
|
||||
content: {
|
||||
instruction: '¿Cuántas palmadas para SOL? (SOL)',
|
||||
text: 'SOL',
|
||||
audioText: 'SOL, una sola palmada',
|
||||
options: [
|
||||
{ label: '👏 Una', correct: true },
|
||||
{ label: '👏👏 Dos', correct: false },
|
||||
{ label: '👏👏👏 Tres', correct: false },
|
||||
],
|
||||
emoji: '☀️',
|
||||
},
|
||||
functionalContext: 'Reconocer palabras monosílabas.',
|
||||
errorType: 'discriminacion-auditiva',
|
||||
},
|
||||
{
|
||||
id: 'lec2-silabas-4',
|
||||
stage: 2,
|
||||
skillCode: 'conciencia-silabas',
|
||||
type: 'conciencia',
|
||||
prerequisites: ['lec2-silabas-3'],
|
||||
content: {
|
||||
instruction: '¿Cuántas palmadas para MARIPOSA? (MA-RI-PO-SA)',
|
||||
text: 'MARIPOSA',
|
||||
audioText: 'MA-RI-PO-SA, cuatro partes',
|
||||
options: [
|
||||
{ label: '👏 Dos', correct: false },
|
||||
{ label: '👏👏👏 Tres', correct: false },
|
||||
{ label: '👏👏👏👏 Cuatro', correct: true },
|
||||
],
|
||||
emoji: '🦋',
|
||||
},
|
||||
functionalContext: 'Segmentar palabras de 4 sílabas.',
|
||||
errorType: 'discriminacion-auditiva',
|
||||
},
|
||||
// --- Sílaba inicial ---
|
||||
{
|
||||
id: 'lec2-inicial-ma',
|
||||
stage: 2,
|
||||
skillCode: 'conciencia-inicial',
|
||||
type: 'conciencia',
|
||||
prerequisites: ['lec2-silabas-2'],
|
||||
content: {
|
||||
instruction: '¿Cuál empieza como MAMÁ? (MA-MÁ, MA-NO)',
|
||||
text: 'MAMÁ → MA...',
|
||||
audioText: 'Mamá empieza con MA',
|
||||
options: [
|
||||
{ label: 'MANO', correct: true },
|
||||
{ label: 'PATO', correct: false },
|
||||
{ label: 'SAPO', correct: false },
|
||||
],
|
||||
emoji: '👩',
|
||||
},
|
||||
functionalContext: 'Identificar el sonido inicial de las palabras.',
|
||||
errorType: 'discriminacion-auditiva',
|
||||
},
|
||||
{
|
||||
id: 'lec2-inicial-pa',
|
||||
stage: 2,
|
||||
skillCode: 'conciencia-inicial',
|
||||
type: 'conciencia',
|
||||
prerequisites: ['lec2-inicial-ma'],
|
||||
content: {
|
||||
instruction: '¿Cuál empieza como PAPÁ? (PA-PÁ, PA-TO)',
|
||||
text: 'PAPÁ → PA...',
|
||||
audioText: 'Papá empieza con PA',
|
||||
options: [
|
||||
{ label: 'PERRO', correct: false },
|
||||
{ label: 'PATO', correct: true },
|
||||
{ label: 'CASA', correct: false },
|
||||
],
|
||||
emoji: '👨',
|
||||
},
|
||||
functionalContext: 'Generalizar identificación de sílaba inicial.',
|
||||
errorType: 'discriminacion-auditiva',
|
||||
},
|
||||
{
|
||||
id: 'lec2-inicial-ta',
|
||||
stage: 2,
|
||||
skillCode: 'conciencia-inicial',
|
||||
type: 'conciencia',
|
||||
prerequisites: ['lec2-inicial-pa'],
|
||||
content: {
|
||||
instruction: 'Buscá la que empieza como TAZA (TA-ZA, TA-BLA)',
|
||||
text: 'TAZA → TA...',
|
||||
audioText: 'Taza empieza con TA',
|
||||
options: [
|
||||
{ label: 'MESA', correct: false },
|
||||
{ label: 'TAMBOR', correct: true },
|
||||
{ label: 'LÁPIZ', correct: false },
|
||||
],
|
||||
emoji: '☕',
|
||||
},
|
||||
functionalContext: 'Reconocer sílaba TA al inicio.',
|
||||
errorType: 'discriminacion-auditiva',
|
||||
},
|
||||
{
|
||||
id: 'lec2-inicial-sa',
|
||||
stage: 2,
|
||||
skillCode: 'conciencia-inicial',
|
||||
type: 'conciencia',
|
||||
prerequisites: ['lec2-inicial-ta'],
|
||||
content: {
|
||||
instruction: '¿Cuál empieza como SAPO? (SA-PO, SA-LTA)',
|
||||
text: 'SAPO → SA...',
|
||||
audioText: 'Sapo empieza con SA',
|
||||
options: [
|
||||
{ label: 'SAPO', correct: false },
|
||||
{ label: 'SALTA', correct: true },
|
||||
{ label: 'RANA', correct: false },
|
||||
],
|
||||
emoji: '🐸',
|
||||
},
|
||||
functionalContext: 'Identificar sonido SA al inicio.',
|
||||
errorType: 'discriminacion-auditiva',
|
||||
},
|
||||
// --- Segmentación ---
|
||||
{
|
||||
id: 'lec2-segmentar-casa',
|
||||
stage: 2,
|
||||
skillCode: 'conciencia-segmentacion',
|
||||
type: 'conciencia',
|
||||
prerequisites: ['lec2-silabas-2', 'lec2-inicial-ma'],
|
||||
content: {
|
||||
instruction: 'Separala en partes: CA-SA. ¿Cuál tiene dos partes?',
|
||||
text: 'CA-SA',
|
||||
audioText: 'CA-SA',
|
||||
options: [
|
||||
{ label: 'SOL', correct: false },
|
||||
{ label: 'CASA', correct: true },
|
||||
{ label: 'MARIPOSA', correct: false },
|
||||
],
|
||||
emoji: '🏠',
|
||||
},
|
||||
functionalContext: 'Segmentar palabras en sílabas.',
|
||||
errorType: 'omision-letra',
|
||||
},
|
||||
{
|
||||
id: 'lec2-segmentar-mesa',
|
||||
stage: 2,
|
||||
skillCode: 'conciencia-segmentacion',
|
||||
type: 'conciencia',
|
||||
prerequisites: ['lec2-segmentar-casa'],
|
||||
content: {
|
||||
instruction: 'ME-SA. ¿Cuál es?',
|
||||
text: 'ME-SA',
|
||||
audioText: 'ME-SA',
|
||||
options: [
|
||||
{ label: 'MESA', correct: true },
|
||||
{ label: 'MELÓN', correct: false },
|
||||
{ label: 'SAPO', correct: false },
|
||||
],
|
||||
emoji: '🪑',
|
||||
},
|
||||
functionalContext: 'Unir segmentos para formar palabras.',
|
||||
errorType: 'omision-letra',
|
||||
},
|
||||
{
|
||||
id: 'lec2-segmentar-pato',
|
||||
stage: 2,
|
||||
skillCode: 'conciencia-segmentacion',
|
||||
type: 'conciencia',
|
||||
prerequisites: ['lec2-segmentar-mesa'],
|
||||
content: {
|
||||
instruction: 'PA-TO. ¿Qué palabra formamos?',
|
||||
text: 'PA-TO',
|
||||
audioText: 'PA-TO',
|
||||
options: [
|
||||
{ label: 'PATO', correct: true },
|
||||
{ label: 'PERRO', correct: false },
|
||||
{ label: 'GATO', correct: false },
|
||||
],
|
||||
emoji: '🦆',
|
||||
},
|
||||
functionalContext: 'Sintetizar sílabas para formar palabras.',
|
||||
errorType: 'omision-letra',
|
||||
},
|
||||
{
|
||||
id: 'lec2-ordenar-silabas',
|
||||
stage: 2,
|
||||
skillCode: 'conciencia-orden',
|
||||
type: 'secuencia',
|
||||
prerequisites: ['lec2-segmentar-pato'],
|
||||
content: {
|
||||
instruction: 'Ordená las sílabas para formar una palabra',
|
||||
audioText: 'Ordená las sílabas',
|
||||
sequence: ['TO', 'PA'],
|
||||
emoji: '🧩',
|
||||
},
|
||||
functionalContext: 'Desarrollar habilidad de ordenamiento fonológico.',
|
||||
errorType: 'inversion-letra',
|
||||
},
|
||||
{
|
||||
id: 'lec2-ordenar-silabas-2',
|
||||
stage: 2,
|
||||
skillCode: 'conciencia-orden',
|
||||
type: 'secuencia',
|
||||
prerequisites: ['lec2-ordenar-silabas'],
|
||||
content: {
|
||||
instruction: 'Armá la palabra',
|
||||
audioText: 'Ordená estas sílabas',
|
||||
sequence: ['SA', 'CA'],
|
||||
emoji: '🧩',
|
||||
},
|
||||
functionalContext: 'Ordenar sílabas para formar palabras.',
|
||||
errorType: 'inversion-letra',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export default stage
|
||||
@@ -0,0 +1,269 @@
|
||||
import type { Stage } from '../types'
|
||||
|
||||
function vocalChoice(vocal: string, correctLabel: string, distractors: string[], emoji: string, instruction: string, audioText?: string) {
|
||||
const options = [
|
||||
{ label: correctLabel, correct: true, id: vocal },
|
||||
...distractors.map((d) => ({ label: d, correct: false, id: d })),
|
||||
].sort(() => Math.random() - 0.5)
|
||||
|
||||
return {
|
||||
stage: 3,
|
||||
skillCode: `vocal-${vocal}`,
|
||||
type: 'eleccion' as const,
|
||||
prerequisites: getVocalPrereqs(vocal),
|
||||
content: {
|
||||
instruction,
|
||||
text: correctLabel,
|
||||
options,
|
||||
audioText: audioText || `${correctLabel}`,
|
||||
emoji,
|
||||
color: getVocalColor(vocal),
|
||||
},
|
||||
functionalContext: `Identificar la vocal ${vocal.toUpperCase()} en diferentes contextos.`,
|
||||
errorType: 'confusion-letra' as const,
|
||||
}
|
||||
}
|
||||
|
||||
function getVocalColor(vocal: string): string {
|
||||
const colors: Record<string, string> = { a: '#FF6B6B', o: '#4ECDC4', i: '#45B7D1', e: '#96CEB4', u: '#DDA0DD' }
|
||||
return colors[vocal] || '#FF6B6B'
|
||||
}
|
||||
|
||||
function getVocalPrereqs(vocal: string): string[] {
|
||||
const order = ['a', 'o', 'i', 'e', 'u']
|
||||
const idx = order.indexOf(vocal)
|
||||
if (idx === 0) return ['lec2-segmentar-pato']
|
||||
return [`vocal-${order[idx - 1]}-completa`]
|
||||
}
|
||||
|
||||
const gestos: Record<string, { gesto: string, palabra: string, fonema: string, emoji: string }> = {
|
||||
a: { gesto: 'Abrí la mano como una boca grande', palabra: 'ÁRBOL', fonema: 'AAA', emoji: '🌳' },
|
||||
o: { gesto: 'Redondeá la mano como una O', palabra: 'OJO', fonema: 'OOO', emoji: '👁️' },
|
||||
i: { gesto: 'Señalá el cielo con el dedo', palabra: 'INDIO', fonema: 'III', emoji: '🪶' },
|
||||
e: { gesto: 'Estirá la mano como una trompa de elefante', palabra: 'ELEFANTE', fonema: 'EEE', emoji: '🐘' },
|
||||
u: { gesto: 'Señalá el suelo con la mano', palabra: 'UNO', fonema: 'UUU', emoji: '1️⃣' },
|
||||
}
|
||||
|
||||
function buildVocalExercises(vocal: string, index: number): any[] {
|
||||
const g = gestos[vocal]
|
||||
const exs: any[] = []
|
||||
|
||||
// Ejercicio de gesto (sensibilizacion)
|
||||
exs.push({
|
||||
id: `lec3-${vocal}-gesto`,
|
||||
stage: 3,
|
||||
skillCode: `vocal-${vocal}`,
|
||||
type: 'sensibilizacion',
|
||||
prerequisites: getVocalPrereqs(vocal),
|
||||
content: {
|
||||
instruction: `Hacé así para la ${vocal.toUpperCase()}: ${g.gesto}`,
|
||||
audioText: `Esta es la letra ${vocal.toUpperCase()}. ${g.palabra}. ${g.fonema}`,
|
||||
emoji: g.emoji,
|
||||
color: getVocalColor(vocal),
|
||||
},
|
||||
functionalContext: `Aprender el gesto Borel-Maisonny para la vocal ${vocal.toUpperCase()}.`,
|
||||
})
|
||||
|
||||
// Reconocer la vocal entre otras
|
||||
exs.push(vocalChoice(
|
||||
vocal, vocal.toUpperCase(),
|
||||
['A', 'O', 'I', 'E', 'U'].filter((v) => v !== vocal.toUpperCase()),
|
||||
g.emoji,
|
||||
`Señalá la letra ${vocal.toUpperCase()}`,
|
||||
))
|
||||
|
||||
// ¿Con qué vocal empieza?
|
||||
exs.push({
|
||||
id: `lec3-${vocal}-inicial`,
|
||||
stage: 3,
|
||||
skillCode: `vocal-${vocal}`,
|
||||
type: 'eleccion',
|
||||
prerequisites: [`lec3-${vocal}-gesto`],
|
||||
content: {
|
||||
instruction: `¿Con qué vocal empieza ${g.palabra}?`,
|
||||
text: g.palabra,
|
||||
audioText: `${g.palabra} empieza con ${vocal.toUpperCase()}`,
|
||||
options: [
|
||||
{ label: vocal.toUpperCase(), correct: true, id: vocal },
|
||||
...['A', 'O', 'I', 'E', 'U'].filter((v) => v !== vocal.toUpperCase()).slice(0, 2).map((v) => ({ label: v, correct: false, id: v })),
|
||||
],
|
||||
emoji: g.emoji,
|
||||
},
|
||||
functionalContext: `Reconocer la vocal inicial en palabras.`,
|
||||
errorType: 'discriminacion-auditiva',
|
||||
})
|
||||
|
||||
// Emparejar imagen con vocal
|
||||
exs.push({
|
||||
id: `lec3-${vocal}-emparejar`,
|
||||
stage: 3,
|
||||
skillCode: `vocal-${vocal}`,
|
||||
type: 'conciencia',
|
||||
prerequisites: [`lec3-${vocal}-inicial`],
|
||||
content: {
|
||||
instruction: `¿Cuál empieza con ${vocal.toUpperCase()}?`,
|
||||
audioText: `¿Qué empieza con ${vocal.toUpperCase()}?`,
|
||||
options: [
|
||||
{ label: g.emoji, correct: true, id: vocal },
|
||||
...Object.entries(gestos)
|
||||
.filter(([k]) => k !== vocal)
|
||||
.slice(0, 2)
|
||||
.map(([k, v]) => ({ label: v.emoji, correct: false, id: k })),
|
||||
],
|
||||
},
|
||||
functionalContext: `Asociar la vocal con imágenes que empiezan con ella.`,
|
||||
errorType: 'confusion-letra',
|
||||
})
|
||||
|
||||
// Discriminación auditiva
|
||||
exs.push({
|
||||
id: `lec3-${vocal}-auditiva`,
|
||||
stage: 3,
|
||||
skillCode: `vocal-${vocal}`,
|
||||
type: 'conciencia',
|
||||
prerequisites: [`lec3-${vocal}-emparejar`],
|
||||
content: {
|
||||
instruction: `Escuchá bien. ¿Cuál suena como ${vocal.toUpperCase()}?`,
|
||||
audioText: `${g.fonema}... ${g.fonema}... como ${g.palabra}`,
|
||||
options: [
|
||||
{ label: vocal.toUpperCase(), correct: true, id: vocal },
|
||||
...['A', 'O', 'I', 'E', 'U'].filter((v) => v !== vocal.toUpperCase()).slice(0, 2).map((v) => ({ label: v, correct: false, id: v })),
|
||||
],
|
||||
emoji: '👂',
|
||||
},
|
||||
functionalContext: `Discriminar auditivamente la vocal ${vocal.toUpperCase()}.`,
|
||||
errorType: 'discriminacion-auditiva',
|
||||
})
|
||||
|
||||
// Escribir con el dedo (trazo)
|
||||
exs.push({
|
||||
id: `lec3-${vocal}-trazo`,
|
||||
stage: 3,
|
||||
skillCode: `vocal-${vocal}`,
|
||||
type: 'trazo',
|
||||
prerequisites: [`lec3-${vocal}-auditiva`],
|
||||
content: {
|
||||
instruction: `Trazá la letra ${vocal.toUpperCase()} con tu dedo`,
|
||||
text: vocal.toUpperCase(),
|
||||
audioText: `Trazá la ${vocal.toUpperCase()}`,
|
||||
color: getVocalColor(vocal),
|
||||
emoji: g.emoji,
|
||||
},
|
||||
functionalContext: `Desarrollar memoria motriz de la vocal ${vocal.toUpperCase()}.`,
|
||||
errorType: 'confusion-letra',
|
||||
})
|
||||
|
||||
// Repaso final
|
||||
exs.push({
|
||||
id: `lec3-${vocal}-completa`,
|
||||
stage: 3,
|
||||
skillCode: `vocal-${vocal}`,
|
||||
type: 'eleccion',
|
||||
prerequisites: [`lec3-${vocal}-trazo`],
|
||||
content: {
|
||||
instruction: `¡Repaso! Señalá la letra ${vocal.toUpperCase()}`,
|
||||
audioText: `Buscá la ${vocal.toUpperCase()}`,
|
||||
options: [
|
||||
{ label: vocal.toUpperCase(), correct: true, id: vocal },
|
||||
...['A', 'O', 'I', 'E', 'U'].filter((v) => v !== vocal.toUpperCase()).map((v) => ({ label: v, correct: false, id: v })),
|
||||
],
|
||||
emoji: g.emoji,
|
||||
color: getVocalColor(vocal),
|
||||
},
|
||||
functionalContext: `Consolidar reconocimiento de la vocal ${vocal.toUpperCase()}.`,
|
||||
errorType: 'confusion-letra',
|
||||
})
|
||||
|
||||
return exs
|
||||
}
|
||||
|
||||
// Additional cross-vowel discrimination exercises
|
||||
const mixedExercises: any[] = [
|
||||
{
|
||||
id: 'lec3-mix-ae',
|
||||
stage: 3,
|
||||
skillCode: 'vocal-discriminacion',
|
||||
type: 'eleccion',
|
||||
prerequisites: ['lec3-a-completa', 'lec3-e-completa'],
|
||||
content: {
|
||||
instruction: '¿Cuál es la A y cuál es la E? Señalá la A',
|
||||
options: [
|
||||
{ label: 'A', correct: true, id: 'a' },
|
||||
{ label: 'E', correct: false, id: 'e' },
|
||||
{ label: 'O', correct: false, id: 'o' },
|
||||
],
|
||||
emoji: '🔤',
|
||||
},
|
||||
functionalContext: 'Discriminar entre A y E.',
|
||||
errorType: 'confusion-letra',
|
||||
},
|
||||
{
|
||||
id: 'lec3-mix-iu',
|
||||
stage: 3,
|
||||
skillCode: 'vocal-discriminacion',
|
||||
type: 'eleccion',
|
||||
prerequisites: ['lec3-i-completa', 'lec3-u-completa'],
|
||||
content: {
|
||||
instruction: '¿Cuál es la I y cuál es la U? Señalá la I',
|
||||
options: [
|
||||
{ label: 'I', correct: true, id: 'i' },
|
||||
{ label: 'U', correct: false, id: 'u' },
|
||||
{ label: 'A', correct: false, id: 'a' },
|
||||
],
|
||||
emoji: '🔤',
|
||||
},
|
||||
functionalContext: 'Discriminar entre I y U.',
|
||||
errorType: 'confusion-letra',
|
||||
},
|
||||
{
|
||||
id: 'lec3-mix-ao',
|
||||
stage: 3,
|
||||
skillCode: 'vocal-discriminacion',
|
||||
type: 'eleccion',
|
||||
prerequisites: ['lec3-a-completa', 'lec3-o-completa'],
|
||||
content: {
|
||||
instruction: 'Señalá la letra O',
|
||||
options: [
|
||||
{ label: 'A', correct: false, id: 'a' },
|
||||
{ label: 'O', correct: true, id: 'o' },
|
||||
{ label: 'U', correct: false, id: 'u' },
|
||||
],
|
||||
emoji: '🔤',
|
||||
},
|
||||
functionalContext: 'Discriminar entre A y O.',
|
||||
errorType: 'confusion-letra',
|
||||
},
|
||||
{
|
||||
id: 'lec3-mix-ei',
|
||||
stage: 3,
|
||||
skillCode: 'vocal-discriminacion',
|
||||
type: 'eleccion',
|
||||
prerequisites: ['lec3-e-completa', 'lec3-i-completa'],
|
||||
content: {
|
||||
instruction: 'Señalá la E',
|
||||
options: [
|
||||
{ label: 'I', correct: false, id: 'i' },
|
||||
{ label: 'E', correct: true, id: 'e' },
|
||||
{ label: 'U', correct: false, id: 'u' },
|
||||
],
|
||||
emoji: '🔤',
|
||||
},
|
||||
functionalContext: 'Discriminar entre E e I.',
|
||||
errorType: 'confusion-letra',
|
||||
},
|
||||
]
|
||||
|
||||
const exercises: any[] = []
|
||||
for (const [i, vocal] of ['a', 'o', 'i', 'e', 'u'].entries()) {
|
||||
exercises.push(...buildVocalExercises(vocal, i))
|
||||
}
|
||||
exercises.push(...mixedExercises)
|
||||
|
||||
const stage: Stage = {
|
||||
id: 3,
|
||||
name: 'Vocales A-O-I-E-U',
|
||||
description: 'Aprender las 5 vocales con gestos Borel-Maisonny, discriminación auditiva y visual, trazo y reconocimiento.',
|
||||
exercises,
|
||||
}
|
||||
|
||||
export default stage
|
||||
@@ -0,0 +1,235 @@
|
||||
import type { Stage } from '../types'
|
||||
|
||||
type ConInfo = {
|
||||
letra: string
|
||||
palabra: string
|
||||
emoji: string
|
||||
silabas: string[]
|
||||
gesto: string
|
||||
}
|
||||
|
||||
const consonantes: ConInfo[] = [
|
||||
{ letra: 'M', palabra: 'MAMÁ', emoji: '👩', silabas: ['MA', 'ME', 'MI', 'MO', 'MU'], gesto: 'Manos en la boca como mmm' },
|
||||
{ letra: 'P', palabra: 'PAPÁ', emoji: '👨', silabas: ['PA', 'PE', 'PI', 'PO', 'PU'], gesto: 'Manos que abren y cierran como pompa' },
|
||||
{ letra: 'L', palabra: 'LUNA', emoji: '🌙', silabas: ['LA', 'LE', 'LI', 'LO', 'LU'], gesto: 'Lengua arriba como lámpara' },
|
||||
{ letra: 'S', palabra: 'SOL', emoji: '☀️', silabas: ['SA', 'SE', 'SI', 'SO', 'SU'], gesto: 'Dedos como serpiente sss' },
|
||||
{ letra: 'T', palabra: 'TASA', emoji: '☕', silabas: ['TA', 'TE', 'TI', 'TO', 'TU'], gesto: 'Golpecito con la mano' },
|
||||
{ letra: 'N', palabra: 'NIDO', emoji: '🪺', silabas: ['NA', 'NE', 'NI', 'NO', 'NU'], gesto: 'Nariz como nido' },
|
||||
{ letra: 'D', palabra: 'DEDO', emoji: '👉', silabas: ['DA', 'DE', 'DI', 'DO', 'DU'], gesto: 'Dedo señalando' },
|
||||
{ letra: 'R', palabra: 'RATÓN', emoji: '🐭', silabas: ['RA', 'RE', 'RI', 'RO', 'RU'], gesto: 'Lengua que vibra como rrr' },
|
||||
]
|
||||
|
||||
const exercises: any[] = []
|
||||
|
||||
for (const con of consonantes) {
|
||||
// 1. Presentación con gesto
|
||||
exercises.push({
|
||||
id: `lec4-${con.letra.toLowerCase()}-gesto`,
|
||||
stage: 4,
|
||||
skillCode: `consonante-${con.letra.toLowerCase()}`,
|
||||
type: 'sensibilizacion',
|
||||
prerequisites: con.letra === 'M' ? ['lec3-u-completa'] : [`lec4-${consonantes[consonantes.indexOf(con) - 1].letra.toLowerCase()}-completa`],
|
||||
content: {
|
||||
instruction: `Hacé así para la ${con.letra}: ${con.gesto}`,
|
||||
audioText: `Esta es la letra ${con.letra}. ${con.palabra}. ${con.silabas[0].toLowerCase()}`,
|
||||
emoji: con.emoji,
|
||||
},
|
||||
functionalContext: `Conocer el gesto asociado a la consonante ${con.letra}.`,
|
||||
})
|
||||
|
||||
// 2. Reconocer la consonante
|
||||
exercises.push({
|
||||
id: `lec4-${con.letra.toLowerCase()}-reconocer`,
|
||||
stage: 4,
|
||||
skillCode: `consonante-${con.letra.toLowerCase()}`,
|
||||
type: 'eleccion',
|
||||
prerequisites: [`lec4-${con.letra.toLowerCase()}-gesto`],
|
||||
content: {
|
||||
instruction: `Señalá la letra ${con.letra}`,
|
||||
text: `Buscá la ${con.letra}`,
|
||||
audioText: `¿Dónde está la ${con.letra}?`,
|
||||
options: [
|
||||
{ label: con.letra, correct: true, id: con.letra.toLowerCase() },
|
||||
...consonantes.filter(c => c.letra !== con.letra).slice(0, 3).map(c => ({ label: c.letra, correct: false, id: c.letra.toLowerCase() })),
|
||||
],
|
||||
emoji: con.emoji,
|
||||
},
|
||||
functionalContext: `Reconocer visualmente la consonante ${con.letra}.`,
|
||||
errorType: 'confusion-letra',
|
||||
})
|
||||
|
||||
// 3. Discriminación auditiva de la sílaba
|
||||
exercises.push({
|
||||
id: `lec4-${con.letra.toLowerCase()}-auditiva`,
|
||||
stage: 4,
|
||||
skillCode: `consonante-${con.letra.toLowerCase()}`,
|
||||
type: 'conciencia',
|
||||
prerequisites: [`lec4-${con.letra.toLowerCase()}-reconocer`],
|
||||
content: {
|
||||
instruction: `Escuchá. ¿Cuál suena como ${con.silabas[0]}?`,
|
||||
audioText: `${con.silabas[0]}... ${con.silabas[0]}... como ${con.palabra}`,
|
||||
options: [
|
||||
{ label: con.silabas[0], correct: true, id: con.silabas[0].toLowerCase() },
|
||||
...consonantes.filter(c => c.letra !== con.letra).slice(0, 2).map(c => ({ label: c.silabas[0], correct: false, id: c.silabas[0].toLowerCase() })),
|
||||
],
|
||||
emoji: '👂',
|
||||
},
|
||||
functionalContext: `Discriminar auditivamente la sílaba ${con.silabas[0]}.`,
|
||||
errorType: 'discriminacion-auditiva',
|
||||
})
|
||||
|
||||
// 4. Emparejar sílaba con vocal
|
||||
for (let vi = 1; vi < con.silabas.length; vi++) {
|
||||
const sil = con.silabas[vi]
|
||||
const vocal = sil[1]
|
||||
const vocales = ['A', 'E', 'I', 'O', 'U'].filter(v => v !== vocal)
|
||||
exercises.push({
|
||||
id: `lec4-${con.letra.toLowerCase()}-silaba-${sil.toLowerCase()}`,
|
||||
stage: 4,
|
||||
skillCode: `consonante-${con.letra.toLowerCase()}-silabas`,
|
||||
type: 'eleccion',
|
||||
prerequisites: [`lec4-${con.letra.toLowerCase()}-auditiva`, `lec4-${con.letra.toLowerCase()}-silaba-${con.silabas[vi - 1].toLowerCase()}`],
|
||||
content: {
|
||||
instruction: `¿Cuál es ${sil}?`,
|
||||
text: sil,
|
||||
audioText: `${sil}... como ${con.palabra.toLowerCase()}`,
|
||||
options: [
|
||||
{ label: sil, correct: true, id: sil.toLowerCase() },
|
||||
{ label: `${con.letra}${vocales[0]}`, correct: false, id: `${con.letra}${vocales[0]}`.toLowerCase() },
|
||||
{ label: `${con.letra}${vocales[1]}`, correct: false, id: `${con.letra}${vocales[1]}`.toLowerCase() },
|
||||
],
|
||||
emoji: con.emoji,
|
||||
},
|
||||
functionalContext: `Leer la sílaba ${sil}.`,
|
||||
errorType: 'confusion-letra',
|
||||
})
|
||||
}
|
||||
|
||||
// 5. Palabra con la consonante
|
||||
exercises.push({
|
||||
id: `lec4-${con.letra.toLowerCase()}-palabra`,
|
||||
stage: 4,
|
||||
skillCode: `consonante-${con.letra.toLowerCase()}`,
|
||||
type: 'palabra',
|
||||
prerequisites: [`lec4-${con.letra.toLowerCase()}-silaba-${con.silabas[con.silabas.length - 1].toLowerCase()}`],
|
||||
content: {
|
||||
instruction: `¿Cuál palabra empieza con ${con.letra}?`,
|
||||
audioText: `${con.palabra} empieza con ${con.letra}`,
|
||||
imageEmoji: con.emoji,
|
||||
options: [
|
||||
{ label: con.palabra, correct: true, id: con.palabra.toLowerCase() },
|
||||
{ label: 'CASA', correct: false, id: 'casa' },
|
||||
{ label: 'GATO', correct: false, id: 'gato' },
|
||||
{ label: 'Escuchar 👂', correct: false, id: 'escuchar' },
|
||||
],
|
||||
},
|
||||
functionalContext: `Asociar la consonante ${con.letra} con una palabra significativa.`,
|
||||
errorType: 'discriminacion-auditiva',
|
||||
})
|
||||
|
||||
// 6. Final: repaso completo
|
||||
exercises.push({
|
||||
id: `lec4-${con.letra.toLowerCase()}-completa`,
|
||||
stage: 4,
|
||||
skillCode: `consonante-${con.letra.toLowerCase()}`,
|
||||
type: 'eleccion',
|
||||
prerequisites: [`lec4-${con.letra.toLowerCase()}-palabra`],
|
||||
content: {
|
||||
instruction: `¡Repaso! Señalá la letra ${con.letra}`,
|
||||
audioText: `Buscá la ${con.letra}`,
|
||||
options: [
|
||||
{ label: con.letra, correct: true, id: con.letra.toLowerCase() },
|
||||
...consonantes.filter(c => c.letra !== con.letra).slice(0, 3).map(c => ({ label: c.letra, correct: false, id: c.letra.toLowerCase() })),
|
||||
],
|
||||
emoji: con.emoji,
|
||||
},
|
||||
functionalContext: `Consolidar reconocimiento de la consonante ${con.letra}.`,
|
||||
errorType: 'confusion-letra',
|
||||
})
|
||||
}
|
||||
|
||||
// Cross-consonant discrimination exercises
|
||||
exercises.push({
|
||||
id: 'lec4-mix-mp',
|
||||
stage: 4,
|
||||
skillCode: 'consonante-discriminacion',
|
||||
type: 'eleccion',
|
||||
prerequisites: ['lec4-m-completa', 'lec4-p-completa'],
|
||||
content: {
|
||||
instruction: 'Señalá la M',
|
||||
options: [
|
||||
{ label: 'M', correct: true, id: 'm' },
|
||||
{ label: 'P', correct: false, id: 'p' },
|
||||
{ label: 'L', correct: false, id: 'l' },
|
||||
],
|
||||
emoji: '🔤',
|
||||
},
|
||||
functionalContext: 'Discriminar entre M y P.',
|
||||
errorType: 'confusion-letra',
|
||||
})
|
||||
|
||||
exercises.push({
|
||||
id: 'lec4-mix-st',
|
||||
stage: 4,
|
||||
skillCode: 'consonante-discriminacion',
|
||||
type: 'eleccion',
|
||||
prerequisites: ['lec4-s-completa', 'lec4-t-completa'],
|
||||
content: {
|
||||
instruction: 'Señalá la T',
|
||||
options: [
|
||||
{ label: 'S', correct: false, id: 's' },
|
||||
{ label: 'T', correct: true, id: 't' },
|
||||
{ label: 'N', correct: false, id: 'n' },
|
||||
],
|
||||
emoji: '🔤',
|
||||
},
|
||||
functionalContext: 'Discriminar entre S y T.',
|
||||
errorType: 'confusion-letra',
|
||||
})
|
||||
|
||||
exercises.push({
|
||||
id: 'lec4-mix-ld',
|
||||
stage: 4,
|
||||
skillCode: 'consonante-discriminacion',
|
||||
type: 'eleccion',
|
||||
prerequisites: ['lec4-l-completa', 'lec4-d-completa'],
|
||||
content: {
|
||||
instruction: 'Señalá la D',
|
||||
options: [
|
||||
{ label: 'L', correct: false, id: 'l' },
|
||||
{ label: 'D', correct: true, id: 'd' },
|
||||
{ label: 'R', correct: false, id: 'r' },
|
||||
],
|
||||
emoji: '🔤',
|
||||
},
|
||||
functionalContext: 'Discriminar entre L y D.',
|
||||
errorType: 'confusion-letra',
|
||||
})
|
||||
|
||||
exercises.push({
|
||||
id: 'lec4-mix-nr',
|
||||
stage: 4,
|
||||
skillCode: 'consonante-discriminacion',
|
||||
type: 'eleccion',
|
||||
prerequisites: ['lec4-n-completa', 'lec4-r-completa'],
|
||||
content: {
|
||||
instruction: 'Señalá la R',
|
||||
options: [
|
||||
{ label: 'N', correct: false, id: 'n' },
|
||||
{ label: 'R', correct: true, id: 'r' },
|
||||
{ label: 'M', correct: false, id: 'm' },
|
||||
],
|
||||
emoji: '🔤',
|
||||
},
|
||||
functionalContext: 'Discriminar entre N y R.',
|
||||
errorType: 'confusion-letra',
|
||||
})
|
||||
|
||||
const stage: Stage = {
|
||||
id: 4,
|
||||
name: 'Consonantes M-P-L-S-T-N-D-R',
|
||||
description: 'Aprender las consonantes M, P, L, S, T, N, D, R con gestos, sílabas, y palabras significativas.',
|
||||
exercises,
|
||||
}
|
||||
|
||||
export default stage
|
||||
@@ -0,0 +1,293 @@
|
||||
import type { Stage, Exercise } from '../types'
|
||||
|
||||
type Info = {
|
||||
letra: string
|
||||
palabra: string
|
||||
emoji: string
|
||||
gesto: string
|
||||
silaba: string
|
||||
palabraLower: string
|
||||
}
|
||||
|
||||
const moduleA: Info[] = [
|
||||
{ letra: 'A', palabra: 'ÁRBOL', emoji: '🌳', gesto: 'Abrí la mano como una boca grande', silaba: 'a', palabraLower: 'árbol' },
|
||||
{ letra: 'E', palabra: 'ELEFANTE', emoji: '🐘', gesto: 'Estirá la mano como una trompa', silaba: 'e', palabraLower: 'elefante' },
|
||||
{ letra: 'I', palabra: 'INDIO', emoji: '🪶', gesto: 'Señalá el cielo con el dedo', silaba: 'i', palabraLower: 'indio' },
|
||||
{ letra: 'O', palabra: 'OJO', emoji: '👁️', gesto: 'Redondeá la mano como una O', silaba: 'o', palabraLower: 'ojo' },
|
||||
{ letra: 'U', palabra: 'UNO', emoji: '1️⃣', gesto: 'Señalá el suelo con la mano', silaba: 'u', palabraLower: 'uno' },
|
||||
]
|
||||
|
||||
const moduleB: Info[] = [
|
||||
{ letra: 'M', palabra: 'MAMÁ', emoji: '👩', gesto: 'Manos en la boca como mmm', silaba: 'ma', palabraLower: 'mamá' },
|
||||
{ letra: 'P', palabra: 'PAPÁ', emoji: '👨', gesto: 'Manos que abren y cierran como pompa', silaba: 'pa', palabraLower: 'papá' },
|
||||
{ letra: 'L', palabra: 'LUNA', emoji: '🌙', gesto: 'Lengua arriba como lámpara', silaba: 'la', palabraLower: 'luna' },
|
||||
{ letra: 'S', palabra: 'SOL', emoji: '☀️', gesto: 'Dedos como serpiente sss', silaba: 'sa', palabraLower: 'sol' },
|
||||
]
|
||||
|
||||
const moduleC: Info[] = [
|
||||
{ letra: 'T', palabra: 'TASA', emoji: '☕', gesto: 'Golpecito con la mano', silaba: 'ta', palabraLower: 'tasa' },
|
||||
{ letra: 'N', palabra: 'NIDO', emoji: '🪺', gesto: 'Nariz como nido', silaba: 'na', palabraLower: 'nido' },
|
||||
{ letra: 'D', palabra: 'DEDO', emoji: '👉', gesto: 'Dedo señalando', silaba: 'da', palabraLower: 'dedo' },
|
||||
{ letra: 'R', palabra: 'RATÓN', emoji: '🐭', gesto: 'Lengua que vibra como rrr', silaba: 'ra', palabraLower: 'ratón' },
|
||||
]
|
||||
|
||||
const allModules = [moduleA, moduleB, moduleC]
|
||||
|
||||
function getDistractors(pool: Info[], current: Info, count: number): Info[] {
|
||||
return pool.filter((x) => x.letra !== current.letra).slice(0, count)
|
||||
}
|
||||
|
||||
function getPoolIndex(info: Info): number {
|
||||
for (let i = 0; i < allModules.length; i++) {
|
||||
if (allModules[i].find((x) => x.letra === info.letra)) return i
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
function buildExercises(): Exercise[] {
|
||||
const all: Exercise[] = []
|
||||
let prevId: string | null = 'lec4-r-completa'
|
||||
|
||||
for (const pool of allModules) {
|
||||
for (const info of pool) {
|
||||
const MIN = info.letra.toLowerCase()
|
||||
const dist = getDistractors(pool, info, 3)
|
||||
|
||||
// 1. Descubrimiento del par
|
||||
all.push({
|
||||
id: `lec5-${MIN}-descubre`,
|
||||
stage: 5,
|
||||
skillCode: `puente-${MIN}`,
|
||||
type: 'sensibilizacion',
|
||||
prerequisites: prevId ? [prevId] : [],
|
||||
content: {
|
||||
instruction: `${info.letra} y ${MIN} suenan igual`,
|
||||
audioText: `${info.letra} grande y ${MIN} chica suenan igual: ${gestoKey(info.gesto)}`,
|
||||
emoji: info.emoji,
|
||||
},
|
||||
functionalContext: `Descubrir que ${info.letra} y ${MIN} son la misma letra.`,
|
||||
})
|
||||
prevId = `lec5-${MIN}-descubre`
|
||||
|
||||
// 2. Práctica emparejada: Tocá Mm
|
||||
const pairOptions = [
|
||||
{ label: `${info.letra}${MIN}`, correct: true, id: MIN },
|
||||
...dist.map((d) => ({
|
||||
label: `${d.letra}${d.letra.toLowerCase()}`,
|
||||
correct: false,
|
||||
id: d.letra.toLowerCase(),
|
||||
})),
|
||||
].sort(() => Math.random() - 0.5)
|
||||
|
||||
all.push({
|
||||
id: `lec5-${MIN}-pareja`,
|
||||
stage: 5,
|
||||
skillCode: `puente-${MIN}`,
|
||||
type: 'eleccion',
|
||||
prerequisites: [prevId],
|
||||
content: {
|
||||
instruction: `Tocá la ${info.letra}${MIN}`,
|
||||
audioText: `¿Dónde está ${info.letra}${MIN}?`,
|
||||
options: pairOptions,
|
||||
emoji: info.emoji,
|
||||
},
|
||||
functionalContext: `Asociar el par ${info.letra}${MIN}.`,
|
||||
errorType: 'confusion-letra',
|
||||
})
|
||||
prevId = `lec5-${MIN}-pareja`
|
||||
|
||||
// 3. Buscar mayúscula entre minúsculas: Tocá la M
|
||||
const mayusOptions = [
|
||||
{ label: info.letra, correct: true, id: MIN },
|
||||
...dist.map((d) => ({
|
||||
label: d.letra.toLowerCase(),
|
||||
correct: false,
|
||||
id: `${d.letra.toLowerCase()}-may`,
|
||||
})),
|
||||
].sort(() => Math.random() - 0.5)
|
||||
|
||||
all.push({
|
||||
id: `lec5-${MIN}-busca-mayus`,
|
||||
stage: 5,
|
||||
skillCode: `puente-${MIN}`,
|
||||
type: 'eleccion',
|
||||
prerequisites: [prevId],
|
||||
content: {
|
||||
instruction: `Tocá la ${info.letra} grande`,
|
||||
audioText: `¿Dónde está la ${info.letra} grande?`,
|
||||
options: mayusOptions,
|
||||
emoji: info.emoji,
|
||||
},
|
||||
functionalContext: `Reconocer ${info.letra} mayúscula entre minúsculas.`,
|
||||
errorType: 'confusion-letra',
|
||||
})
|
||||
prevId = `lec5-${MIN}-busca-mayus`
|
||||
|
||||
// 4. Buscar minúscula entre mayúsculas: Tocá la m (CLAVE)
|
||||
const minusOptions = [
|
||||
{ label: MIN, correct: true, id: MIN },
|
||||
...dist.map((d) => ({
|
||||
label: d.letra,
|
||||
correct: false,
|
||||
id: `${d.letra.toLowerCase()}-min`,
|
||||
})),
|
||||
].sort(() => Math.random() - 0.5)
|
||||
|
||||
all.push({
|
||||
id: `lec5-${MIN}-busca-minus`,
|
||||
stage: 5,
|
||||
skillCode: `puente-${MIN}`,
|
||||
type: 'eleccion',
|
||||
prerequisites: [prevId],
|
||||
content: {
|
||||
instruction: `Tocá la ${MIN} chica`,
|
||||
audioText: `¿Dónde está la ${MIN} chica?`,
|
||||
options: minusOptions,
|
||||
emoji: info.emoji,
|
||||
},
|
||||
functionalContext: `Reconocer ${MIN} minúscula entre mayúsculas — discriminación clave para dislexia.`,
|
||||
errorType: 'confusion-letra',
|
||||
})
|
||||
prevId = `lec5-${MIN}-busca-minus`
|
||||
|
||||
// 5. Pareja inversa: ¿Cuál es la pareja de M? → m
|
||||
const parejaMayusOptions = [
|
||||
{ label: MIN, correct: true, id: `${MIN}-pm` },
|
||||
...dist.map((d) => ({
|
||||
label: d.letra.toLowerCase(),
|
||||
correct: false,
|
||||
id: `${d.letra.toLowerCase()}-pm`,
|
||||
})),
|
||||
].sort(() => Math.random() - 0.5)
|
||||
|
||||
all.push({
|
||||
id: `lec5-${MIN}-pareja-mayus`,
|
||||
stage: 5,
|
||||
skillCode: `puente-${MIN}`,
|
||||
type: 'eleccion',
|
||||
prerequisites: [prevId],
|
||||
content: {
|
||||
instruction: `¿Cuál es la pareja de ${info.letra}?`,
|
||||
audioText: `Buscá la ${MIN} chica, pareja de la ${info.letra} grande`,
|
||||
options: parejaMayusOptions,
|
||||
emoji: info.emoji,
|
||||
},
|
||||
functionalContext: `Asociar la mayúscula ${info.letra} con su minúscula ${MIN}.`,
|
||||
errorType: 'confusion-letra',
|
||||
})
|
||||
prevId = `lec5-${MIN}-pareja-mayus`
|
||||
|
||||
// 6. Pareja inversa 2: ¿Cuál es la pareja de m? → M
|
||||
const parejaMinusOptions = [
|
||||
{ label: info.letra, correct: true, id: `${MIN}-pm2` },
|
||||
...dist.map((d) => ({
|
||||
label: d.letra,
|
||||
correct: false,
|
||||
id: `${d.letra.toLowerCase()}-pm2`,
|
||||
})),
|
||||
].sort(() => Math.random() - 0.5)
|
||||
|
||||
all.push({
|
||||
id: `lec5-${MIN}-pareja-minus`,
|
||||
stage: 5,
|
||||
skillCode: `puente-${MIN}`,
|
||||
type: 'eleccion',
|
||||
prerequisites: [prevId],
|
||||
content: {
|
||||
instruction: `¿Cuál es la pareja de ${MIN}?`,
|
||||
audioText: `Buscá la ${info.letra} grande, pareja de la ${MIN} chica`,
|
||||
options: parejaMinusOptions,
|
||||
emoji: info.emoji,
|
||||
},
|
||||
functionalContext: `Asociar la minúscula ${MIN} con su mayúscula ${info.letra}.`,
|
||||
errorType: 'confusion-letra',
|
||||
})
|
||||
prevId = `lec5-${MIN}-pareja-minus`
|
||||
|
||||
// 7. Sílabas en minúscula: Tocá ma
|
||||
const silabaOptions = [
|
||||
{ label: info.silaba, correct: true, id: `${MIN}-sil` },
|
||||
...dist.map((d) => ({
|
||||
label: d.silaba,
|
||||
correct: false,
|
||||
id: `${d.letra.toLowerCase()}-sil`,
|
||||
})),
|
||||
].sort(() => Math.random() - 0.5)
|
||||
|
||||
all.push({
|
||||
id: `lec5-${MIN}-silaba`,
|
||||
stage: 5,
|
||||
skillCode: `puente-${MIN}`,
|
||||
type: 'eleccion',
|
||||
prerequisites: [prevId],
|
||||
content: {
|
||||
instruction: `Tocá ${info.silaba}`,
|
||||
audioText: `¿Dónde está ${info.silaba}?`,
|
||||
options: silabaOptions,
|
||||
emoji: info.emoji,
|
||||
},
|
||||
functionalContext: `Leer la sílaba ${info.silaba} en minúscula.`,
|
||||
errorType: 'confusion-letra',
|
||||
})
|
||||
prevId = `lec5-${MIN}-silaba`
|
||||
|
||||
// 8. Palabra en minúscula: Tocá mamá
|
||||
const palabraOptions = [
|
||||
{ label: info.palabraLower, correct: true, id: `${MIN}-pal` },
|
||||
...dist.map((d) => ({
|
||||
label: d.palabraLower,
|
||||
correct: false,
|
||||
id: `${d.letra.toLowerCase()}-pal`,
|
||||
})),
|
||||
].sort(() => Math.random() - 0.5)
|
||||
|
||||
all.push({
|
||||
id: `lec5-${MIN}-palabra`,
|
||||
stage: 5,
|
||||
skillCode: `puente-${MIN}`,
|
||||
type: 'eleccion',
|
||||
prerequisites: [prevId],
|
||||
content: {
|
||||
instruction: `Tocá ${info.palabraLower}`,
|
||||
audioText: `¿Dónde dice ${info.palabraLower}?`,
|
||||
options: palabraOptions,
|
||||
emoji: info.emoji,
|
||||
},
|
||||
functionalContext: `Leer la palabra ${info.palabraLower} en minúscula.`,
|
||||
errorType: 'confusion-letra',
|
||||
})
|
||||
prevId = `lec5-${MIN}-palabra`
|
||||
}
|
||||
}
|
||||
|
||||
return all
|
||||
}
|
||||
|
||||
function gestoKey(gesto: string): string {
|
||||
const map: Record<string, string> = {
|
||||
'Abrí': 'a-a-a',
|
||||
'Estirá': 'e-e-e',
|
||||
'Señalá el cielo': 'i-i-i',
|
||||
'Redondeá': 'o-o-o',
|
||||
'Señalá el suelo': 'u-u-u',
|
||||
'Manos en la boca': 'mmm',
|
||||
'Manos que abren': 'p-p-p',
|
||||
'Dedos como serpiente': 'sss',
|
||||
'Lengua arriba': 'l-l-l',
|
||||
'Golpecito': 't-t-t',
|
||||
'Nariz como nido': 'n-n-n',
|
||||
'Dedo señalando': 'd-d-d',
|
||||
'Lengua que vibra': 'rrr',
|
||||
}
|
||||
const key = Object.keys(map).find((k) => gesto.startsWith(k))
|
||||
return key ? map[key] : gesto
|
||||
}
|
||||
|
||||
const stage: Stage = {
|
||||
id: 5,
|
||||
name: 'Puente Mayúscula-Minúscula',
|
||||
description: 'Asociar letras mayúsculas con sus correspondientes minúsculas. 8 ejercicios por letra — 104 en total.',
|
||||
exercises: buildExercises(),
|
||||
}
|
||||
|
||||
export default stage
|
||||
@@ -0,0 +1,119 @@
|
||||
import type { Stage } from '../types'
|
||||
|
||||
const palabras2silabas = [
|
||||
{ palabra: 'MAPA', silabas: ['MA', 'PA'], emoji: '🗺️' },
|
||||
{ palabra: 'PASA', silabas: ['PA', 'SA'], emoji: '🔄' },
|
||||
{ palabra: 'SALA', silabas: ['SA', 'LA'], emoji: '🏠' },
|
||||
{ palabra: 'TAPA', silabas: ['TA', 'PA'], emoji: '📦' },
|
||||
{ palabra: 'LATA', silabas: ['LA', 'TA'], emoji: '🥫' },
|
||||
{ palabra: 'NIDO', silabas: ['NI', 'DO'], emoji: '🪺' },
|
||||
{ palabra: 'DEDO', silabas: ['DE', 'DO'], emoji: '👉' },
|
||||
{ palabra: 'RATO', silabas: ['RA', 'TO'], emoji: '⏰' },
|
||||
{ palabra: 'MESA', silabas: ['ME', 'SA'], emoji: '🪑' },
|
||||
{ palabra: 'PATO', silabas: ['PA', 'TO'], emoji: '🦆' },
|
||||
{ palabra: 'LIMA', silabas: ['LI', 'MA'], emoji: '🍋' },
|
||||
{ palabra: 'SOPA', silabas: ['SO', 'PA'], emoji: '🥣' },
|
||||
{ palabra: 'TINA', silabas: ['TI', 'NA'], emoji: '🛁' },
|
||||
{ palabra: 'MONO', silabas: ['MO', 'NO'], emoji: '🐵' },
|
||||
{ palabra: 'PINO', silabas: ['PI', 'NO'], emoji: '🌲' },
|
||||
{ palabra: 'LUNES', silabas: ['LU', 'NES'], emoji: '📅' },
|
||||
{ palabra: 'SAPO', silabas: ['SA', 'PO'], emoji: '🐸' },
|
||||
{ palabra: 'TELO', silabas: ['TE', 'LO'], emoji: '🧶' },
|
||||
{ palabra: 'DAMA', silabas: ['DA', 'MA'], emoji: '👩' },
|
||||
{ palabra: 'RAMA', silabas: ['RA', 'MA'], emoji: '🌿' },
|
||||
]
|
||||
|
||||
const exercises: any[] = []
|
||||
|
||||
for (const { palabra, silabas, emoji } of palabras2silabas) {
|
||||
const lowerPalabra = palabra.toLowerCase().replace(/[^a-z]/g, '')
|
||||
const id = `lec6-palabra-${lowerPalabra}`
|
||||
const letras = palabra.split('')
|
||||
const conLetras = letras[0]?.toLowerCase()
|
||||
const consonantDep = conLetras && ['m', 'p', 'l', 's', 't', 'n', 'd', 'r'].includes(conLetras)
|
||||
? `lec4-${conLetras}-completa`
|
||||
: 'lec3-u-completa'
|
||||
|
||||
// Leer la palabra (ver las sílabas separadas)
|
||||
exercises.push({
|
||||
id: `${id}-leer`,
|
||||
stage: 6,
|
||||
skillCode: `silabas-${lowerPalabra}`,
|
||||
type: 'eleccion',
|
||||
prerequisites: [consonantDep],
|
||||
content: {
|
||||
instruction: `Leé esta palabra: ${silabas.join('-')}`,
|
||||
text: `${silabas.join(' ')}`,
|
||||
audioText: `${palabra}: ${silabas.join(', ')}`,
|
||||
emoji,
|
||||
options: [
|
||||
{ label: palabra, correct: true, id: lowerPalabra },
|
||||
...palabras2silabas
|
||||
.filter(p => p.palabra !== palabra)
|
||||
.slice(0, 2)
|
||||
.map(p => ({ label: p.palabra, correct: false, id: p.palabra.toLowerCase() })),
|
||||
],
|
||||
},
|
||||
functionalContext: `Leer la palabra ${palabra} combinando sílabas.`,
|
||||
errorType: 'silaba-dificil',
|
||||
})
|
||||
|
||||
// Emparejar con imagen
|
||||
exercises.push({
|
||||
id: `${id}-imagen`,
|
||||
stage: 6,
|
||||
skillCode: `silabas-${lowerPalabra}`,
|
||||
type: 'palabra',
|
||||
prerequisites: [`${id}-leer`],
|
||||
content: {
|
||||
instruction: `¿Cuál palabra es esta?`,
|
||||
imageEmoji: emoji,
|
||||
audioText: `¿Qué palabra es?`,
|
||||
options: [
|
||||
...palabras2silabas
|
||||
.filter(p => p.palabra !== palabra)
|
||||
.slice(0, 2)
|
||||
.map(p => ({ label: p.palabra, correct: false, id: p.palabra.toLowerCase() })),
|
||||
{ label: palabra, correct: true, id: lowerPalabra },
|
||||
{ label: 'Escuchar 👂', correct: false, id: 'escuchar' },
|
||||
],
|
||||
},
|
||||
functionalContext: `Asociar la palabra ${palabra} con su imagen.`,
|
||||
errorType: 'confusion-letra',
|
||||
})
|
||||
}
|
||||
|
||||
// Silaba order exercises
|
||||
const secuencias = [
|
||||
{ palabra: 'MAPA', silabas: ['PA', 'MA'], emoji: '🗺️' },
|
||||
{ palabra: 'PATO', silabas: ['TO', 'PA'], emoji: '🦆' },
|
||||
{ palabra: 'SAPO', silabas: ['PO', 'SA'], emoji: '🐸' },
|
||||
]
|
||||
|
||||
for (const { palabra, silabas, emoji } of secuencias) {
|
||||
const lower = palabra.toLowerCase()
|
||||
exercises.push({
|
||||
id: `lec6-ordenar-${lower}`,
|
||||
stage: 6,
|
||||
skillCode: `silabas-orden-${lower}`,
|
||||
type: 'secuencia',
|
||||
prerequisites: [`lec6-palabra-${lower}-leer`],
|
||||
content: {
|
||||
instruction: `Ordená las sílabas para formar ${palabra}`,
|
||||
audioText: `Ordená para formar ${palabra}`,
|
||||
sequence: silabas,
|
||||
emoji,
|
||||
},
|
||||
functionalContext: `Ordenar sílabas para formar ${palabra}.`,
|
||||
errorType: 'inversion-letra',
|
||||
})
|
||||
}
|
||||
|
||||
const stage: Stage = {
|
||||
id: 6,
|
||||
name: 'Sílabas y palabras de 2 sílabas',
|
||||
description: 'Combinar consonantes + vocales para formar y leer palabras de 2 sílabas.',
|
||||
exercises,
|
||||
}
|
||||
|
||||
export default stage
|
||||
@@ -0,0 +1,109 @@
|
||||
import type { Stage } from '../types'
|
||||
|
||||
const palabras3silabas = [
|
||||
{ palabra: 'MALETA', silabas: ['MA', 'LE', 'TA'], emoji: '🧳' },
|
||||
{ palabra: 'PALETA', silabas: ['PA', 'LE', 'TA'], emoji: '🎨' },
|
||||
{ palabra: 'SALIDA', silabas: ['SA', 'LI', 'DA'], emoji: '🚪' },
|
||||
{ palabra: 'TELÉFONO', silabas: ['TE', 'LÉ', 'FO', 'NO'], emoji: '📞' },
|
||||
{ palabra: 'MARIPOSA', silabas: ['MA', 'RI', 'PO', 'SA'], emoji: '🦋' },
|
||||
{ palabra: 'MUSICA', silabas: ['MU', 'SI', 'CA'], emoji: '🎵' },
|
||||
{ palabra: 'PLATO', silabas: ['PLA', 'TO'], emoji: '🍽️' },
|
||||
{ palabra: 'TRISTE', silabas: ['TRIS', 'TE'], emoji: '😢' },
|
||||
{ palabra: 'PLUMA', silabas: ['PLU', 'MA'], emoji: '🪶' },
|
||||
{ palabra: 'PANTALLA', silabas: ['PAN', 'TA', 'LLA'], emoji: '📺' },
|
||||
{ palabra: 'PRIMAVERA', silabas: ['PRI', 'MA', 'VE', 'RA'], emoji: '🌸' },
|
||||
{ palabra: 'MELÓN', silabas: ['ME', 'LÓN'], emoji: '🍈' },
|
||||
{ palabra: 'SILLA', silabas: ['SI', 'LLA'], emoji: '🪑' },
|
||||
{ palabra: 'TORTUGA', silabas: ['TOR', 'TU', 'GA'], emoji: '🐢' },
|
||||
{ palabra: 'DORMIR', silabas: ['DOR', 'MIR'], emoji: '😴' },
|
||||
{ palabra: 'RATÓN', silabas: ['RA', 'TÓN'], emoji: '🐭' },
|
||||
{ palabra: 'NARIZ', silabas: ['NA', 'RIZ'], emoji: '👃' },
|
||||
{ palabra: 'LÁPIZ', silabas: ['LÁ', 'PIZ'], emoji: '✏️' },
|
||||
{ palabra: 'DINOSAURIO', silabas: ['DI', 'NO', 'SAU', 'RIO'], emoji: '🦕' },
|
||||
{ palabra: 'ELEFANTE', silabas: ['E', 'LE', 'FAN', 'TE'], emoji: '🐘' },
|
||||
]
|
||||
|
||||
const exercises: any[] = []
|
||||
|
||||
for (const { palabra, silabas, emoji } of palabras3silabas) {
|
||||
const lowerPalabra = palabra.toLowerCase().replace(/[^a-záéíóú]/g, '')
|
||||
|
||||
// Read the word
|
||||
exercises.push({
|
||||
id: `lec7-${lowerPalabra}-leer`,
|
||||
stage: 7,
|
||||
skillCode: `palabra-${lowerPalabra}`,
|
||||
type: 'eleccion',
|
||||
prerequisites: ['lec6-palabra-mapa-leer'],
|
||||
content: {
|
||||
instruction: `Leé esta palabra: ${palabra}`,
|
||||
text: palabra,
|
||||
audioText: `${palabra}`,
|
||||
emoji,
|
||||
options: [
|
||||
{ label: palabra, correct: true, id: lowerPalabra },
|
||||
...palabras3silabas
|
||||
.filter(p => p.palabra !== palabra)
|
||||
.slice(0, 2)
|
||||
.map(p => ({ label: p.palabra, correct: false, id: p.palabra.toLowerCase().replace(/[^a-záéíóú]/g, '') })),
|
||||
],
|
||||
},
|
||||
functionalContext: `Leer la palabra ${palabra}.`,
|
||||
errorType: 'silaba-dificil',
|
||||
})
|
||||
|
||||
// Count syllables
|
||||
exercises.push({
|
||||
id: `lec7-${lowerPalabra}-silabas`,
|
||||
stage: 7,
|
||||
skillCode: `palabra-${lowerPalabra}`,
|
||||
type: 'conciencia',
|
||||
prerequisites: [`lec7-${lowerPalabra}-leer`],
|
||||
content: {
|
||||
instruction: `¿Cuántas sílabas tiene ${palabra}?`,
|
||||
text: palabra,
|
||||
audioText: `¿Cuántas sílabas?`,
|
||||
options: [
|
||||
{ label: `${'👏'.repeat(silabas.length)} ${silabas.length}`, correct: true, id: `${silabas.length}` },
|
||||
{ label: `${'👏'.repeat(Math.max(1, silabas.length - 1))} ${silabas.length - 1}`, correct: false, id: `${silabas.length - 1}` },
|
||||
{ label: `${'👏'.repeat(silabas.length + 1)} ${silabas.length + 1}`, correct: false, id: `${silabas.length + 1}` },
|
||||
],
|
||||
emoji,
|
||||
},
|
||||
functionalContext: `Contar sílabas en ${palabra}.`,
|
||||
errorType: 'discriminacion-auditiva',
|
||||
})
|
||||
|
||||
// Match image
|
||||
exercises.push({
|
||||
id: `lec7-${lowerPalabra}-imagen`,
|
||||
stage: 7,
|
||||
skillCode: `palabra-${lowerPalabra}`,
|
||||
type: 'palabra',
|
||||
prerequisites: [`lec7-${lowerPalabra}-silabas`],
|
||||
content: {
|
||||
instruction: `¿Qué palabra es?`,
|
||||
imageEmoji: emoji,
|
||||
audioText: `¿Cuál es?`,
|
||||
options: [
|
||||
...palabras3silabas
|
||||
.filter(p => p.palabra !== palabra)
|
||||
.slice(0, 2)
|
||||
.map(p => ({ label: p.palabra, correct: false, id: p.palabra.toLowerCase().replace(/[^a-záéíóú]/g, '') })),
|
||||
{ label: palabra, correct: true, id: lowerPalabra },
|
||||
{ label: 'Escuchar 👂', correct: false, id: 'escuchar' },
|
||||
],
|
||||
},
|
||||
functionalContext: `Asociar la palabra ${palabra} con su imagen.`,
|
||||
errorType: 'confusion-letra',
|
||||
})
|
||||
}
|
||||
|
||||
const stage: Stage = {
|
||||
id: 7,
|
||||
name: 'Palabras de 3+ sílabas',
|
||||
description: 'Leer y comprender palabras de 3 o más sílabas con sílabas compuestas.',
|
||||
exercises,
|
||||
}
|
||||
|
||||
export default stage
|
||||
@@ -0,0 +1,147 @@
|
||||
import type { Stage } from '../types'
|
||||
|
||||
const frases = [
|
||||
{
|
||||
frase: 'Mi mamá me ama.',
|
||||
opciones: ['Mi papá me ama.', 'Mi mamá me ama.', 'Mi mamá me mima.'],
|
||||
correcta: 'Mi mamá me ama.',
|
||||
emoji: '💕',
|
||||
},
|
||||
{
|
||||
frase: 'El sol sale temprano.',
|
||||
opciones: ['La luna sale tarde.', 'El sol sale temprano.', 'El sol se esconde.'],
|
||||
correcta: 'El sol sale temprano.',
|
||||
emoji: '☀️',
|
||||
},
|
||||
{
|
||||
frase: 'La luna está en el cielo.',
|
||||
opciones: ['El sol está en el mar.', 'La luna está en el cielo.', 'La luna es redonda.'],
|
||||
correcta: 'La luna está en el cielo.',
|
||||
emoji: '🌙',
|
||||
},
|
||||
{
|
||||
frase: 'El pato nada en el lago.',
|
||||
opciones: ['El pato vuela alto.', 'El pato nada en el lago.', 'El gato nada en el lago.'],
|
||||
correcta: 'El pato nada en el lago.',
|
||||
emoji: '🦆',
|
||||
},
|
||||
{
|
||||
frase: 'Mi gato es muy lindo.',
|
||||
opciones: ['Mi perro es grande.', 'Mi gato es muy lindo.', 'Mi gato salta alto.'],
|
||||
correcta: 'Mi gato es muy lindo.',
|
||||
emoji: '🐱',
|
||||
},
|
||||
{
|
||||
frase: 'La mariposa vuela entre las flores.',
|
||||
opciones: ['La mariposa está dormida.', 'La mariposa vuela entre las flores.', 'La abeja hace miel.'],
|
||||
correcta: 'La mariposa vuela entre las flores.',
|
||||
emoji: '🦋',
|
||||
},
|
||||
{
|
||||
frase: 'Tomo leche con galletitas.',
|
||||
opciones: ['Tomo agua con hielo.', 'Tomo leche con galletitas.', 'Como sopa caliente.'],
|
||||
correcta: 'Tomo leche con galletitas.',
|
||||
emoji: '🥛',
|
||||
},
|
||||
{
|
||||
frase: 'El perro corre en el parque.',
|
||||
opciones: ['El perro duerme en casa.', 'El perro corre en el parque.', 'El gato corre en el parque.'],
|
||||
correcta: 'El perro corre en el parque.',
|
||||
emoji: '🐕',
|
||||
},
|
||||
{
|
||||
frase: 'Mi hermana tiene un lápiz rojo.',
|
||||
opciones: ['Mi mamá tiene una mesa.', 'Mi hermana tiene un lápiz rojo.', 'Mi hermana tiene un libro.'],
|
||||
correcta: 'Mi hermana tiene un lápiz rojo.',
|
||||
emoji: '✏️',
|
||||
},
|
||||
{
|
||||
frase: 'La tortuga camina despacio.',
|
||||
opciones: ['La liebre salta rápido.', 'La tortuga camina despacio.', 'El pez nada veloz.'],
|
||||
correcta: 'La tortuga camina despacio.',
|
||||
emoji: '🐢',
|
||||
},
|
||||
{
|
||||
frase: 'El elefante es muy grande.',
|
||||
opciones: ['El ratón es chiquito.', 'El elefante es muy grande.', 'El perro es mediano.'],
|
||||
correcta: 'El elefante es muy grande.',
|
||||
emoji: '🐘',
|
||||
},
|
||||
{
|
||||
frase: 'Me gusta pintar con mis dedos.',
|
||||
opciones: ['Me gusta cantar canciones.', 'Me gusta pintar con mis dedos.', 'Me gusta mirar tele.'],
|
||||
correcta: 'Me gusta pintar con mis dedos.',
|
||||
emoji: '🎨',
|
||||
},
|
||||
{
|
||||
frase: 'El sol es amarillo.',
|
||||
opciones: ['La luna es blanca.', 'El sol es amarillo.', 'El cielo es azul.'],
|
||||
correcta: 'El sol es amarillo.',
|
||||
emoji: '🟡',
|
||||
},
|
||||
{
|
||||
frase: 'Mi papá me lee un cuento.',
|
||||
opciones: ['Mi mamá me canta.', 'Mi papá me lee un cuento.', 'Mi papá cocina rico.'],
|
||||
correcta: 'Mi papá me lee un cuento.',
|
||||
emoji: '📖',
|
||||
},
|
||||
{
|
||||
frase: 'La mesa está en la sala.',
|
||||
opciones: ['La silla está en la cocina.', 'La mesa está en la sala.', 'La cama está en mi pieza.'],
|
||||
correcta: 'La mesa está en la sala.',
|
||||
emoji: '🪑',
|
||||
},
|
||||
]
|
||||
|
||||
const exercises: any[] = []
|
||||
|
||||
for (const { frase, opciones, correcta, emoji } of frases) {
|
||||
const id = `lec8-${frase.toLowerCase().replace(/[^a-záéíóú]/g, '').slice(0, 20)}`
|
||||
|
||||
exercises.push({
|
||||
id: `${id}-leer`,
|
||||
stage: 8,
|
||||
skillCode: 'frases',
|
||||
type: 'lectura',
|
||||
prerequisites: ['lec7-maleta-leer'],
|
||||
content: {
|
||||
instruction: 'Leé esta oración en voz alta',
|
||||
text: frase,
|
||||
audioText: frase,
|
||||
emoji,
|
||||
options: [
|
||||
{ label: '¡Leída!', correct: true, id: 'leida' },
|
||||
],
|
||||
},
|
||||
functionalContext: 'Leer oraciones simples.',
|
||||
})
|
||||
|
||||
exercises.push({
|
||||
id: `${id}-comprender`,
|
||||
stage: 8,
|
||||
skillCode: 'frases',
|
||||
type: 'lectura',
|
||||
prerequisites: [`${id}-leer`],
|
||||
content: {
|
||||
instruction: `¿Cuál oración es la correcta?`,
|
||||
emoji,
|
||||
audioText: `¿Cuál es correcta?`,
|
||||
options: opciones.map(o => ({
|
||||
label: o,
|
||||
correct: o === correcta,
|
||||
id: o.toLowerCase().replace(/[^a-záéíóú]/g, '').slice(0, 15),
|
||||
})),
|
||||
},
|
||||
functionalContext: 'Comprender la oración leída.',
|
||||
errorType: 'omision-letra',
|
||||
})
|
||||
}
|
||||
|
||||
const stage: Stage = {
|
||||
id: 8,
|
||||
name: 'Frases y oraciones',
|
||||
description: 'Leer oraciones simples y comprender su significado.',
|
||||
exercises,
|
||||
}
|
||||
|
||||
export default stage
|
||||
@@ -0,0 +1,176 @@
|
||||
import type { Stage } from '../types'
|
||||
|
||||
const lecturas = [
|
||||
{
|
||||
texto: 'El sol sale por la mañana. Las flores se abren. Los pájaros cantan. Es un lindo día.',
|
||||
pregunta: '¿Qué pasa por la mañana?',
|
||||
opciones: [
|
||||
{ label: 'La luna sale', correct: false },
|
||||
{ label: 'El sol sale', correct: true },
|
||||
{ label: 'Las estrellas brillan', correct: false },
|
||||
],
|
||||
emoji: '🌄',
|
||||
},
|
||||
{
|
||||
texto: 'Mamá prepara la mesa. Papá sirve la sopa. Todos comen juntos. Después lavan los platos.',
|
||||
pregunta: '¿Qué hace papá?',
|
||||
opciones: [
|
||||
{ label: 'Lava los platos', correct: false },
|
||||
{ label: 'Prepara la mesa', correct: false },
|
||||
{ label: 'Sirve la sopa', correct: true },
|
||||
],
|
||||
emoji: '🍲',
|
||||
},
|
||||
{
|
||||
texto: 'El gato de mi casa es negro. Le gusta dormir al sol. Por la noche sale a pasear.',
|
||||
pregunta: '¿De qué color es el gato?',
|
||||
opciones: [
|
||||
{ label: 'Blanco', correct: false },
|
||||
{ label: 'Negro', correct: true },
|
||||
{ label: 'Gris', correct: false },
|
||||
],
|
||||
emoji: '🐈⬛',
|
||||
},
|
||||
{
|
||||
texto: 'La nena juega en el parque. Sube al tobogán. Baja rápido y se ríe. Después juega en la arena.',
|
||||
pregunta: '¿Dónde juega la nena?',
|
||||
opciones: [
|
||||
{ label: 'En su casa', correct: false },
|
||||
{ label: 'En la escuela', correct: false },
|
||||
{ label: 'En el parque', correct: true },
|
||||
],
|
||||
emoji: '🛝',
|
||||
},
|
||||
{
|
||||
texto: 'En la granja hay vacas, ovejas y gallinas. Las vacas dan leche. Las gallinas ponen huevos.',
|
||||
pregunta: '¿Qué dan las vacas?',
|
||||
opciones: [
|
||||
{ label: 'Huevos', correct: false },
|
||||
{ label: 'Leche', correct: true },
|
||||
{ label: 'Lana', correct: false },
|
||||
],
|
||||
emoji: '🐄',
|
||||
},
|
||||
{
|
||||
texto: 'Luna tiene cuatro patas, dos orejas largas y una cola corta. Come zanahorias. Es una coneja.',
|
||||
pregunta: '¿Qué es Luna?',
|
||||
opciones: [
|
||||
{ label: 'Un perro', correct: false },
|
||||
{ label: 'Un gato', correct: false },
|
||||
{ label: 'Una coneja', correct: true },
|
||||
],
|
||||
emoji: '🐰',
|
||||
},
|
||||
{
|
||||
texto: 'El libro de cuentos tiene dibujos lindos. La nena lo mira todas las noches. Su mamá le lee antes de dormir.',
|
||||
pregunta: '¿Cuándo lee mamá?',
|
||||
opciones: [
|
||||
{ label: 'Por la mañana', correct: false },
|
||||
{ label: 'Antes de dormir', correct: true },
|
||||
{ label: 'A la tarde', correct: false },
|
||||
],
|
||||
emoji: '📚',
|
||||
},
|
||||
{
|
||||
texto: 'La tortuga Tula vive en un estanque. Le gusta nadar y tomar sol en una piedra. Es muy tranquila.',
|
||||
pregunta: '¿Dónde vive Tula?',
|
||||
opciones: [
|
||||
{ label: 'En el mar', correct: false },
|
||||
{ label: 'En un estanque', correct: true },
|
||||
{ label: 'En una casa', correct: false },
|
||||
],
|
||||
emoji: '🐢',
|
||||
},
|
||||
{
|
||||
texto: 'Tomás tiene tres años. Juega con bloques de colores. Arma una torre muy alta. Después la derriba. ¡Plaf!',
|
||||
pregunta: '¿Qué arma Tomás?',
|
||||
opciones: [
|
||||
{ label: 'Un castillo', correct: false },
|
||||
{ label: 'Una torre', correct: true },
|
||||
{ label: 'Un puente', correct: false },
|
||||
],
|
||||
emoji: '🧱',
|
||||
},
|
||||
{
|
||||
texto: 'La abuela teje una bufanda roja para el invierno. Usa lana suave y dos agujas. La bufanda queda muy linda.',
|
||||
pregunta: '¿Qué teje la abuela?',
|
||||
opciones: [
|
||||
{ label: 'Un gorro', correct: false },
|
||||
{ label: 'Una bufanda', correct: true },
|
||||
{ label: 'Un guante', correct: false },
|
||||
],
|
||||
emoji: '🧣',
|
||||
},
|
||||
{
|
||||
texto: 'En la plaza hay un árbol grande. Tiene hojas verdes y flores rosadas. Los pájaros hacen nidos allí.',
|
||||
pregunta: '¿De qué color son las flores?',
|
||||
opciones: [
|
||||
{ label: 'Rosadas', correct: true },
|
||||
{ label: 'Amarillas', correct: false },
|
||||
{ label: 'Blancas', correct: false },
|
||||
],
|
||||
emoji: '🌳',
|
||||
},
|
||||
{
|
||||
texto: 'El niño se pone las botas de lluvia. Abre el paraguas azul. Salta en los charcos. ¡Qué divertido!',
|
||||
pregunta: '¿Qué se pone el niño?',
|
||||
opciones: [
|
||||
{ label: 'Un abrigo', correct: false },
|
||||
{ label: 'Botas de lluvia', correct: true },
|
||||
{ label: 'Un sombrero', correct: false },
|
||||
],
|
||||
emoji: '☂️',
|
||||
},
|
||||
]
|
||||
|
||||
const exercises: any[] = []
|
||||
|
||||
for (const { texto, pregunta, opciones, emoji } of lecturas) {
|
||||
const id = `lec9-${texto.toLowerCase().replace(/[^a-záéíóú]/g, '').slice(0, 15)}`
|
||||
|
||||
exercises.push({
|
||||
id: `${id}-texto`,
|
||||
stage: 9,
|
||||
skillCode: 'comprension',
|
||||
type: 'lectura',
|
||||
prerequisites: ['lec8-frase-mimamameama-leer'],
|
||||
content: {
|
||||
instruction: 'Leé este texto',
|
||||
text: texto,
|
||||
audioText: texto,
|
||||
emoji,
|
||||
options: [
|
||||
{ label: '¡Leído!', correct: true, id: 'leido' },
|
||||
],
|
||||
},
|
||||
functionalContext: 'Leer un texto corto.',
|
||||
})
|
||||
|
||||
exercises.push({
|
||||
id: `${id}-pregunta`,
|
||||
stage: 9,
|
||||
skillCode: 'comprension',
|
||||
type: 'lectura',
|
||||
prerequisites: [`${id}-texto`],
|
||||
content: {
|
||||
instruction: `Respondé: ${pregunta}`,
|
||||
text: texto,
|
||||
audioText: pregunta,
|
||||
emoji,
|
||||
options: [
|
||||
...opciones.map(o => ({ label: o.label, correct: o.correct, id: o.label.toLowerCase().replace(/[^a-záéíóú]/g, '') })),
|
||||
],
|
||||
},
|
||||
functionalContext: 'Comprender y responder preguntas sobre el texto.',
|
||||
errorType: 'omision-letra',
|
||||
})
|
||||
}
|
||||
|
||||
const stage: Stage = {
|
||||
id: 9,
|
||||
name: 'Comprensión lectora',
|
||||
description: 'Leer textos cortos y responder preguntas de comprensión.',
|
||||
exercises,
|
||||
}
|
||||
|
||||
export default stage
|
||||
@@ -0,0 +1,82 @@
|
||||
import type { Stage } from '../types'
|
||||
|
||||
const exercises: any[] = []
|
||||
|
||||
// Count 1-5
|
||||
for (let n = 1; n <= 5; n++) {
|
||||
const emojis = '⭐'.repeat(n)
|
||||
exercises.push({
|
||||
id: `num1-contar-${n}`,
|
||||
stage: 1,
|
||||
skillCode: `contar-${n}`,
|
||||
type: 'sensibilizacion',
|
||||
prerequisites: n === 1 ? [] : [`num1-contar-${n - 1}`],
|
||||
content: {
|
||||
instruction: `Contá. ¿Cuántas estrellas hay?`,
|
||||
audioText: `Uno, dos, tres...`,
|
||||
emoji: emojis,
|
||||
options: [
|
||||
...Array.from({ length: 5 }, (_, i) => i + 1)
|
||||
.filter(j => j !== n)
|
||||
.slice(0, 2)
|
||||
.map(j => ({ label: String(j), correct: false, id: String(j) })),
|
||||
{ label: String(n), correct: true, id: String(n) },
|
||||
].sort(() => Math.random() - 0.5),
|
||||
},
|
||||
functionalContext: `Contar hasta ${n} elementos.`,
|
||||
errorType: 'discriminacion-auditiva',
|
||||
})
|
||||
}
|
||||
|
||||
// Recognize written digits 1-5
|
||||
for (let n = 1; n <= 5; n++) {
|
||||
exercises.push({
|
||||
id: `num1-digito-${n}`,
|
||||
stage: 1,
|
||||
skillCode: `digito-${n}`,
|
||||
type: 'eleccion',
|
||||
prerequisites: [`num1-contar-${n}`],
|
||||
content: {
|
||||
instruction: `Señalá el número ${n}`,
|
||||
audioText: `Buscá el número ${n}`,
|
||||
options: [
|
||||
...Array.from({ length: 5 }, (_, i) => i + 1)
|
||||
.filter(j => j !== n)
|
||||
.slice(0, 2)
|
||||
.map(j => ({ label: String(j), correct: false, id: String(j) })),
|
||||
{ label: String(n), correct: true, id: String(n) },
|
||||
].sort(() => Math.random() - 0.5),
|
||||
emoji: String(n),
|
||||
},
|
||||
functionalContext: `Reconocer el dígito ${n}.`,
|
||||
errorType: 'confusion-letra',
|
||||
})
|
||||
}
|
||||
|
||||
// Trace with finger
|
||||
for (let n = 1; n <= 5; n++) {
|
||||
exercises.push({
|
||||
id: `num1-trazo-${n}`,
|
||||
stage: 1,
|
||||
skillCode: `digito-${n}`,
|
||||
type: 'trazo',
|
||||
prerequisites: [`num1-digito-${n}`],
|
||||
content: {
|
||||
instruction: `Trazá el número ${n} con tu dedo`,
|
||||
text: String(n),
|
||||
audioText: `Trazá el número ${n}`,
|
||||
emoji: '✍️',
|
||||
},
|
||||
functionalContext: `Aprender a trazar el número ${n}.`,
|
||||
errorType: 'confusion-letra',
|
||||
})
|
||||
}
|
||||
|
||||
const stage: Stage = {
|
||||
id: 1,
|
||||
name: 'Conteo del 1 al 5',
|
||||
description: 'Contar objetos, reconocer dígitos y trazar números del 1 al 5.',
|
||||
exercises,
|
||||
}
|
||||
|
||||
export default stage
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { Stage } from '../types'
|
||||
|
||||
const exercises: any[] = []
|
||||
|
||||
// Count 6-10
|
||||
for (let n = 6; n <= 10; n++) {
|
||||
const emojis = '🟢'.repeat(n)
|
||||
exercises.push({
|
||||
id: `num2-contar-${n}`,
|
||||
stage: 2,
|
||||
skillCode: `contar-${n}`,
|
||||
prerequisites: n === 6 ? ['num1-trazo-5'] : [`num2-contar-${n - 1}`],
|
||||
type: 'sensibilizacion',
|
||||
content: {
|
||||
instruction: `¿Cuántos círculos verdes hay?`,
|
||||
audioText: `Contá los círculos`,
|
||||
emoji: emojis,
|
||||
options: [
|
||||
...Array.from({ length: 5 }, (_, i) => i + 6)
|
||||
.filter(j => j !== n)
|
||||
.slice(0, 2)
|
||||
.map(j => ({ label: String(j), correct: false, id: String(j) })),
|
||||
{ label: String(n), correct: true, id: String(n) },
|
||||
].sort(() => Math.random() - 0.5),
|
||||
},
|
||||
functionalContext: `Contar hasta ${n} elementos.`,
|
||||
errorType: 'discriminacion-auditiva',
|
||||
})
|
||||
}
|
||||
|
||||
// Recognize written digits 6-10
|
||||
for (let n = 6; n <= 10; n++) {
|
||||
exercises.push({
|
||||
id: `num2-digito-${n}`,
|
||||
stage: 2,
|
||||
skillCode: `digito-${n}`,
|
||||
type: 'eleccion',
|
||||
prerequisites: [`num2-contar-${n}`],
|
||||
content: {
|
||||
instruction: `Señalá el número ${n}`,
|
||||
audioText: `Buscá el ${n}`,
|
||||
options: [
|
||||
...Array.from({ length: 5 }, (_, i) => i + 6)
|
||||
.filter(j => j !== n)
|
||||
.slice(0, 2)
|
||||
.map(j => ({ label: String(j), correct: false, id: String(j) })),
|
||||
{ label: String(n), correct: true, id: String(n) },
|
||||
].sort(() => Math.random() - 0.5),
|
||||
emoji: String(n),
|
||||
},
|
||||
functionalContext: `Reconocer el dígito ${n}.`,
|
||||
errorType: 'confusion-letra',
|
||||
})
|
||||
}
|
||||
|
||||
// Order sequence 1-10
|
||||
exercises.push({
|
||||
id: 'num2-secuencia-1-10',
|
||||
stage: 2,
|
||||
skillCode: 'secuencia-1-10',
|
||||
type: 'secuencia',
|
||||
prerequisites: ['num2-digito-10'],
|
||||
content: {
|
||||
instruction: 'Ordená los números del 1 al 10',
|
||||
audioText: 'Ordená del 1 al 10',
|
||||
sequence: ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10'],
|
||||
emoji: '🔢',
|
||||
},
|
||||
functionalContext: 'Conocer la secuencia numérica del 1 al 10.',
|
||||
errorType: 'inversion-letra',
|
||||
})
|
||||
|
||||
// Trace 6-10
|
||||
for (let n = 6; n <= 10; n++) {
|
||||
exercises.push({
|
||||
id: `num2-trazo-${n}`,
|
||||
stage: 2,
|
||||
skillCode: `digito-${n}`,
|
||||
type: 'trazo',
|
||||
prerequisites: [`num2-digito-${n}`, ...(n > 6 ? [`num2-trazo-${n - 1}`] : [])],
|
||||
content: {
|
||||
instruction: `Trazá el número ${n} con tu dedo`,
|
||||
text: String(n),
|
||||
audioText: `Trazá el ${n}`,
|
||||
emoji: '✍️',
|
||||
},
|
||||
functionalContext: `Aprender a trazar el número ${n}.`,
|
||||
errorType: 'confusion-letra',
|
||||
})
|
||||
}
|
||||
|
||||
const stage: Stage = {
|
||||
id: 2,
|
||||
name: 'Conteo del 6 al 10',
|
||||
description: 'Contar objetos, reconocer dígitos y trazar números del 6 al 10, y ordenar del 1 al 10.',
|
||||
exercises,
|
||||
}
|
||||
|
||||
export default stage
|
||||
@@ -0,0 +1,107 @@
|
||||
import type { Stage } from '../types'
|
||||
|
||||
const exercises: any[] = []
|
||||
|
||||
// Pair number with quantity
|
||||
for (let n = 1; n <= 10; n++) {
|
||||
const emojis = ['🐶', '🐱', '🐰', '🐸', '🐵', '🐼', '🐨', '🦊', '🐻', '🐯']
|
||||
const animal = emojis[n - 1]
|
||||
const howMany = animal.repeat(Math.min(n, 5)) + (n > 5 ? ` (${n})` : '')
|
||||
|
||||
exercises.push({
|
||||
id: `num3-cantidad-${n}`,
|
||||
stage: 3,
|
||||
skillCode: `correspondencia-${n}`,
|
||||
type: 'conciencia',
|
||||
prerequisites: n === 1 ? ['num2-trazo-10'] : [`num3-cantidad-${n - 1}`],
|
||||
content: {
|
||||
instruction: `¿Cuántos ${animal} hay?`,
|
||||
audioText: `Contá los animalitos`,
|
||||
text: n <= 5 ? animal.repeat(n) : `${animal.repeat(5)}... +${n - 5}`,
|
||||
options: [
|
||||
...Array.from({ length: 10 }, (_, i) => i + 1)
|
||||
.filter(j => j !== n)
|
||||
.slice(0, 2)
|
||||
.map(j => ({ label: String(j), correct: false, id: String(j) })),
|
||||
{ label: String(n), correct: true, id: String(n) },
|
||||
].sort(() => Math.random() - 0.5),
|
||||
emoji: animal,
|
||||
},
|
||||
functionalContext: 'Asociar cantidad con número.',
|
||||
errorType: 'discriminacion-auditiva',
|
||||
})
|
||||
}
|
||||
|
||||
// Match groups that are equal
|
||||
const pares = [
|
||||
{ a: '🐶🐶🐶', b: '3️⃣', num: 3 },
|
||||
{ a: '🐱🐱🐱🐱', b: '4️⃣', num: 4 },
|
||||
{ a: '🐰🐰🐰🐰🐰', b: '5️⃣', num: 5 },
|
||||
{ a: '🐸🐸', b: '2️⃣', num: 2 },
|
||||
{ a: '🐵🐵🐵🐵🐵🐵', b: '6️⃣', num: 6 },
|
||||
]
|
||||
|
||||
for (const { a, b, num } of pares) {
|
||||
exercises.push({
|
||||
id: `num3-emparejar-${num}`,
|
||||
stage: 3,
|
||||
skillCode: 'correspondencia',
|
||||
type: 'conciencia',
|
||||
prerequisites: [`num3-cantidad-${num}`],
|
||||
content: {
|
||||
instruction: `¿Cuál número le corresponde a ${a}?`,
|
||||
audioText: `¿Qué número es?`,
|
||||
text: a,
|
||||
options: [
|
||||
...pares.filter(p => p.num !== num).slice(0, 2).map(p => ({ label: p.b, correct: false, id: String(p.num) })),
|
||||
{ label: b, correct: true, id: String(num) },
|
||||
].sort(() => Math.random() - 0.5),
|
||||
emoji: '🔄',
|
||||
},
|
||||
functionalContext: 'Emparejar grupos con su número.',
|
||||
errorType: 'confusion-letra',
|
||||
})
|
||||
}
|
||||
|
||||
// More/less comparison
|
||||
const comparaciones = [
|
||||
{ a: 3, b: 5, mas: 5, menos: 3 },
|
||||
{ a: 2, b: 7, mas: 7, menos: 2 },
|
||||
{ a: 4, b: 4, mas: 4, menos: 4 },
|
||||
{ a: 6, b: 3, mas: 6, menos: 3 },
|
||||
{ a: 1, b: 9, mas: 9, menos: 1 },
|
||||
]
|
||||
|
||||
for (const { a, b, mas, menos } of comparaciones) {
|
||||
const idA = '⭐'.repeat(a)
|
||||
const idB = '⭐'.repeat(b)
|
||||
exercises.push({
|
||||
id: `num3-mas-${a}-${b}`,
|
||||
stage: 3,
|
||||
skillCode: 'comparacion',
|
||||
type: 'conciencia',
|
||||
prerequisites: [`num3-cantidad-${Math.max(a, b)}`],
|
||||
content: {
|
||||
instruction: `¿Dónde hay MÁS?`,
|
||||
text: `${idA} vs ${idB}`,
|
||||
audioText: `¿Cuál tiene más?`,
|
||||
options: [
|
||||
{ label: `👈 ${a}`, correct: mas === a, id: `izq-${a}` },
|
||||
{ label: `👉 ${b}`, correct: mas === b, id: `der-${b}` },
|
||||
{ label: '😐 Igual', correct: a === b, id: 'igual' },
|
||||
],
|
||||
emoji: '⚖️',
|
||||
},
|
||||
functionalContext: 'Comparar cantidades (más/menos).',
|
||||
errorType: 'discriminacion-auditiva',
|
||||
})
|
||||
}
|
||||
|
||||
const stage: Stage = {
|
||||
id: 3,
|
||||
name: 'Correspondencia uno a uno',
|
||||
description: 'Asociar cantidades con números, emparejar grupos iguales, comparar más y menos.',
|
||||
exercises,
|
||||
}
|
||||
|
||||
export default stage
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { Stage } from '../types'
|
||||
|
||||
const exercises: any[] = []
|
||||
|
||||
for (let n = 11; n <= 20; n++) {
|
||||
exercises.push({
|
||||
id: `num4-contar-${n}`,
|
||||
stage: 4,
|
||||
skillCode: `contar-${n}`,
|
||||
type: 'conciencia',
|
||||
prerequisites: n === 11 ? ['num3-cantidad-10'] : [`num4-contar-${n - 1}`],
|
||||
content: {
|
||||
instruction: `Contá. ¿Cuántos hay? (${n})`,
|
||||
audioText: `Son ${n}`,
|
||||
text: `🔵🔵🔵🔵🔵 🟣🟣🟣🟣🟣 ${n > 10 ? `+${n - 10}` : ''}`,
|
||||
options: [
|
||||
...Array.from({ length: 5 }, (_, i) => n - 2 + i)
|
||||
.filter(j => j >= 10 && j <= 20 && j !== n)
|
||||
.slice(0, 2)
|
||||
.map(j => ({ label: String(j), correct: false, id: String(j) })),
|
||||
{ label: String(n), correct: true, id: String(n) },
|
||||
].sort(() => Math.random() - 0.5),
|
||||
emoji: String(n),
|
||||
},
|
||||
functionalContext: `Reconocer el número ${n}.`,
|
||||
errorType: 'confusion-letra',
|
||||
})
|
||||
}
|
||||
|
||||
// Decenas y unidades visual
|
||||
for (let n = 10; n <= 20; n++) {
|
||||
const decenas = Math.floor(n / 10)
|
||||
const unidades = n % 10
|
||||
exercises.push({
|
||||
id: `num4-descomponer-${n}`,
|
||||
stage: 4,
|
||||
skillCode: 'decenas-unidades',
|
||||
type: 'eleccion',
|
||||
prerequisites: [`num4-contar-${n}`],
|
||||
content: {
|
||||
instruction: `${n} son ${decenas} decena${decenas > 1 ? 's' : ''} y ${unidades} unidades`,
|
||||
text: `🔟🔟${'●'.repeat(unidades)} = ${n}`,
|
||||
audioText: `${n} es ${decenas} decenas y ${unidades} unidades`,
|
||||
options: [
|
||||
{ label: '🔟 Decenas', correct: false, id: `decenas-${n}` },
|
||||
{ label: String(n), correct: true, id: String(n) },
|
||||
...Array.from({ length: 3 }, (_, i) => n + i - 1)
|
||||
.filter(j => j >= 10 && j <= 20 && j !== n)
|
||||
.slice(0, 1)
|
||||
.map(j => ({ label: String(j), correct: false, id: String(j) })),
|
||||
].sort(() => Math.random() - 0.5),
|
||||
emoji: '🔟',
|
||||
},
|
||||
functionalContext: 'Introducir decenas y unidades.',
|
||||
errorType: 'confusion-letra',
|
||||
})
|
||||
}
|
||||
|
||||
// Secuencia 11-20
|
||||
exercises.push({
|
||||
id: 'num4-secuencia-11-20',
|
||||
stage: 4,
|
||||
skillCode: 'secuencia-11-20',
|
||||
type: 'secuencia',
|
||||
prerequisites: ['num4-descomponer-20'],
|
||||
content: {
|
||||
instruction: 'Ordená los números del 11 al 20',
|
||||
audioText: 'Ordená del 11 al 20',
|
||||
sequence: ['11', '12', '13', '14', '15', '16', '17', '18', '19', '20'],
|
||||
emoji: '🔢',
|
||||
},
|
||||
functionalContext: 'Conocer la secuencia del 11 al 20.',
|
||||
errorType: 'inversion-letra',
|
||||
})
|
||||
|
||||
const stage: Stage = {
|
||||
id: 4,
|
||||
name: 'Números del 11 al 20',
|
||||
description: 'Aprender los números del 11 al 20. Introducir decenas y unidades.',
|
||||
exercises,
|
||||
}
|
||||
|
||||
export default stage
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { Stage } from '../types'
|
||||
|
||||
const sumas = [
|
||||
{ a: 1, b: 1, r: 2, emoji: '🐶+🐶' },
|
||||
{ a: 2, b: 1, r: 3, emoji: '🐱+🐶' },
|
||||
{ a: 2, b: 2, r: 4, emoji: '🐱+🐱' },
|
||||
{ a: 3, b: 1, r: 5, emoji: '🐰+🐶' },
|
||||
{ a: 3, b: 2, r: 5, emoji: '🐰+🐱' },
|
||||
{ a: 4, b: 1, r: 5, emoji: '🐸+🐶' },
|
||||
{ a: 5, b: 1, r: 6, emoji: '🐵+🐶' },
|
||||
{ a: 3, b: 3, r: 6, emoji: '🐰+🐰' },
|
||||
{ a: 5, b: 2, r: 7, emoji: '🐵+🐱' },
|
||||
{ a: 4, b: 3, r: 7, emoji: '🐸+🐰' },
|
||||
{ a: 5, b: 3, r: 8, emoji: '🐵+🐰' },
|
||||
{ a: 4, b: 4, r: 8, emoji: '🐸+🐸' },
|
||||
{ a: 5, b: 4, r: 9, emoji: '🐵+🐸' },
|
||||
{ a: 5, b: 5, r: 10, emoji: '🐵+🐵' },
|
||||
{ a: 6, b: 1, r: 7, emoji: '🐼+🐶' },
|
||||
{ a: 6, b: 2, r: 8, emoji: '🐼+🐱' },
|
||||
{ a: 7, b: 1, r: 8, emoji: '🐨+🐶' },
|
||||
{ a: 7, b: 2, r: 9, emoji: '🐨+🐱' },
|
||||
{ a: 8, b: 1, r: 9, emoji: '🦊+🐶' },
|
||||
{ a: 8, b: 2, r: 10, emoji: '🦊+🐱' },
|
||||
{ a: 9, b: 1, r: 10, emoji: '🐻+🐶' },
|
||||
{ a: 3, b: 4, r: 7, emoji: '🐰+🐸' },
|
||||
{ a: 4, b: 5, r: 9, emoji: '🐸+🐵' },
|
||||
{ a: 6, b: 3, r: 9, emoji: '🐼+🐰' },
|
||||
{ a: 7, b: 3, r: 10, emoji: '🐨+🐰' },
|
||||
]
|
||||
|
||||
const exercises: any[] = []
|
||||
|
||||
for (const { a, b, r, emoji } of sumas) {
|
||||
const id = `num5-suma-${a}-${b}`
|
||||
exercises.push({
|
||||
id,
|
||||
stage: 5,
|
||||
skillCode: 'suma',
|
||||
type: 'problema',
|
||||
prerequisites: a <= 5 && b <= 5 ? ['num4-secuencia-11-20'] : [`num5-suma-${a}-${b - 1}`],
|
||||
content: {
|
||||
instruction: `¿Cuánto es ${a} + ${b}?`,
|
||||
text: `${a} + ${b} = ?`,
|
||||
audioText: `${a} más ${b}`,
|
||||
emoji,
|
||||
options: [
|
||||
...Array.from({ length: 4 }, (_, i) => r - 2 + i + (i >= 2 ? 2 : 0))
|
||||
.filter(j => j >= 1 && j <= 12 && j !== r)
|
||||
.slice(0, 2)
|
||||
.map(j => ({ label: String(j), correct: false, id: String(j) })),
|
||||
{ label: String(r), correct: true, id: String(r) },
|
||||
].sort(() => Math.random() - 0.5),
|
||||
},
|
||||
functionalContext: 'Sumar dos números pequeños.',
|
||||
errorType: 'discriminacion-auditiva',
|
||||
})
|
||||
}
|
||||
|
||||
// Represent sum with objects
|
||||
for (const { a, b, r } of sumas.slice(0, 8)) {
|
||||
exercises.push({
|
||||
id: `num5-suma-visual-${a}-${b}`,
|
||||
stage: 5,
|
||||
skillCode: 'suma-visual',
|
||||
type: 'conciencia',
|
||||
prerequisites: [`num5-suma-${a}-${b}`],
|
||||
content: {
|
||||
instruction: `${a} + ${b} es lo mismo que...`,
|
||||
text: `${'●'.repeat(a)} + ${'●'.repeat(b)}`,
|
||||
audioText: `Juntamos ${a} y ${b}, tenemos ${r}`,
|
||||
options: [
|
||||
{ label: '●'.repeat(r), correct: true, id: String(r) },
|
||||
{ label: '●'.repeat(r + 1), correct: false, id: String(r + 1) },
|
||||
{ label: '●'.repeat(Math.max(1, r - 1)), correct: false, id: String(r - 1) },
|
||||
],
|
||||
emoji: '➕',
|
||||
},
|
||||
functionalContext: 'Visualizar la suma como unión de conjuntos.',
|
||||
errorType: 'discriminacion-auditiva',
|
||||
})
|
||||
}
|
||||
|
||||
const stage: Stage = {
|
||||
id: 5,
|
||||
name: 'Sumas simples (1-10)',
|
||||
description: 'Sumar números del 1 al 10 con ayudas visuales y ejercicios progresivos.',
|
||||
exercises,
|
||||
}
|
||||
|
||||
export default stage
|
||||
@@ -0,0 +1,95 @@
|
||||
import type { Stage } from '../types'
|
||||
|
||||
const restas = [
|
||||
{ a: 2, b: 1, r: 1, emoji: '🍎-🍎' },
|
||||
{ a: 3, b: 1, r: 2, emoji: '🍎🍎-🍎' },
|
||||
{ a: 3, b: 2, r: 1, emoji: '🍎🍎-🍎🍎' },
|
||||
{ a: 4, b: 1, r: 3, emoji: '🍪🍪🍪🍪-🍪' },
|
||||
{ a: 4, b: 2, r: 2, emoji: '🍪🍪🍪🍪-🍪🍪' },
|
||||
{ a: 5, b: 1, r: 4, emoji: '⭐-⭐' },
|
||||
{ a: 5, b: 2, r: 3, emoji: '⭐⭐-⭐⭐' },
|
||||
{ a: 5, b: 3, r: 2, emoji: '⭐⭐⭐-⭐⭐⭐' },
|
||||
{ a: 5, b: 4, r: 1, emoji: '⭐⭐⭐⭐-⭐⭐⭐⭐' },
|
||||
{ a: 6, b: 1, r: 5, emoji: '🌸-🌸' },
|
||||
{ a: 6, b: 2, r: 4, emoji: '🌸🌸-🌸🌸' },
|
||||
{ a: 6, b: 3, r: 3, emoji: '🌸🌸🌸-🌸🌸🌸' },
|
||||
{ a: 7, b: 2, r: 5, emoji: '🌻🌻-🌻🌻' },
|
||||
{ a: 7, b: 3, r: 4, emoji: '🌻🌻🌻-🌻🌻🌻' },
|
||||
{ a: 8, b: 3, r: 5, emoji: '🍄🍄🍄-🍄🍄🍄' },
|
||||
{ a: 8, b: 4, r: 4, emoji: '🍄🍄🍄🍄-🍄🍄🍄🍄' },
|
||||
{ a: 9, b: 4, r: 5, emoji: '🍂🍂🍂🍂-🍂🍂🍂🍂' },
|
||||
{ a: 9, b: 5, r: 4, emoji: '🍂🍂🍂🍂🍂-🍂🍂🍂🍂🍂' },
|
||||
{ a: 10, b: 1, r: 9, emoji: '💎-💎' },
|
||||
{ a: 10, b: 2, r: 8, emoji: '💎💎-💎💎' },
|
||||
{ a: 10, b: 3, r: 7, emoji: '💎💎💎-💎💎💎' },
|
||||
{ a: 10, b: 4, r: 6, emoji: '💎💎💎💎-💎💎💎💎' },
|
||||
{ a: 10, b: 5, r: 5, emoji: '💎💎💎💎💎-💎💎💎💎💎' },
|
||||
{ a: 8, b: 5, r: 3, emoji: '🍀🍀🍀🍀🍀-🍀🍀🍀🍀🍀' },
|
||||
{ a: 7, b: 5, r: 2, emoji: '🌺🌺🌺🌺🌺-🌺🌺🌺🌺🌺' },
|
||||
{ a: 6, b: 5, r: 1, emoji: '🌷🌷🌷🌷🌷-🌷🌷🌷🌷🌷' },
|
||||
]
|
||||
|
||||
const exercises: any[] = []
|
||||
|
||||
for (const { a, b, r, emoji } of restas) {
|
||||
exercises.push({
|
||||
id: `num6-resta-${a}-${b}`,
|
||||
stage: 6,
|
||||
skillCode: 'resta',
|
||||
type: 'problema',
|
||||
prerequisites: a <= 5 ? ['num5-suma-visual-1-1'] : [`num6-resta-${a - 1}-${Math.max(1, b - 1)}` || 'num5-suma-10-1'],
|
||||
content: {
|
||||
instruction: `¿Cuánto es ${a} - ${b}?`,
|
||||
text: `${a} - ${b} = ?`,
|
||||
audioText: `${a} menos ${b}`,
|
||||
emoji,
|
||||
options: [
|
||||
...Array.from({ length: 4 }, (_, i) => r - 2 + i + (i >= 2 ? 2 : 0))
|
||||
.filter(j => j >= 0 && j <= 10 && j !== r)
|
||||
.slice(0, 2)
|
||||
.map(j => ({ label: String(j), correct: false, id: String(j) })),
|
||||
{ label: String(r), correct: true, id: String(r) },
|
||||
].sort(() => Math.random() - 0.5),
|
||||
},
|
||||
functionalContext: 'Restar números pequeños.',
|
||||
errorType: 'discriminacion-auditiva',
|
||||
})
|
||||
}
|
||||
|
||||
// Visual subtraction: take away
|
||||
const visuales = [
|
||||
{ a: 3, b: 1, r: 2 },
|
||||
{ a: 4, b: 2, r: 2 },
|
||||
{ a: 5, b: 3, r: 2 },
|
||||
]
|
||||
for (const { a, b, r } of visuales) {
|
||||
exercises.push({
|
||||
id: `num6-resta-visual-${a}-${b}`,
|
||||
stage: 6,
|
||||
skillCode: 'resta-visual',
|
||||
type: 'conciencia',
|
||||
prerequisites: [`num6-resta-${a}-${b}`],
|
||||
content: {
|
||||
instruction: `${a} - ${b} = ? Sacamos ${b}`,
|
||||
text: `${'🟢'.repeat(a)}\nSacamos ${'🔴'.repeat(b)}\nQuedan...`,
|
||||
audioText: `Sacamos ${b}, quedan ${r}`,
|
||||
options: [
|
||||
{ label: '🟢'.repeat(r), correct: true, id: String(r) },
|
||||
{ label: '🟢'.repeat(r + 2), correct: false, id: String(r + 2) },
|
||||
{ label: '🟢'.repeat(Math.max(1, r + 1)), correct: false, id: String(r + 1) },
|
||||
],
|
||||
emoji: '➖',
|
||||
},
|
||||
functionalContext: 'Visualizar la resta como quitar elementos.',
|
||||
errorType: 'discriminacion-auditiva',
|
||||
})
|
||||
}
|
||||
|
||||
const stage: Stage = {
|
||||
id: 6,
|
||||
name: 'Restas simples (1-10)',
|
||||
description: 'Restar números del 1 al 10 con ayudas visuales.',
|
||||
exercises,
|
||||
}
|
||||
|
||||
export default stage
|
||||
@@ -0,0 +1,103 @@
|
||||
import type { Stage } from '../types'
|
||||
|
||||
const numeros2d = [11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 30, 33, 40, 44, 50]
|
||||
|
||||
const exercises: any[] = []
|
||||
|
||||
for (const n of numeros2d) {
|
||||
const dec = Math.floor(n / 10)
|
||||
const uni = n % 10
|
||||
const decStr = '🔟'.repeat(dec)
|
||||
const uniStr = '●'.repeat(uni)
|
||||
|
||||
exercises.push({
|
||||
id: `num7-componer-${n}`,
|
||||
stage: 7,
|
||||
skillCode: 'valor-posicional',
|
||||
type: 'eleccion',
|
||||
prerequisites: n <= 15 ? ['num6-resta-visual-3-1'] : [`num7-componer-${numeros2d[numeros2d.indexOf(n) - 1]}`],
|
||||
content: {
|
||||
instruction: `${dec} decenas + ${uni} unidades = ?`,
|
||||
text: `${dec}×10 + ${uni}×1 = ?`,
|
||||
audioText: `${n}: ${dec} decenas y ${uni} unidades`,
|
||||
options: [
|
||||
...Array.from({ length: 4 }, (_, i) => n - 2 + i + (i >= 2 ? 2 : 0))
|
||||
.filter(j => j >= 10 && j <= 55 && j !== n)
|
||||
.slice(0, 2)
|
||||
.map(j => ({ label: String(j), correct: false, id: String(j) })),
|
||||
{ label: String(n), correct: true, id: String(n) },
|
||||
].sort(() => Math.random() - 0.5),
|
||||
emoji: decStr,
|
||||
},
|
||||
functionalContext: 'Componer números de 2 dígitos en decenas y unidades.',
|
||||
errorType: 'confusion-letra',
|
||||
})
|
||||
}
|
||||
|
||||
// Write the number of tens and units
|
||||
for (const n of [15, 23, 30, 47, 50]) {
|
||||
const dec = Math.floor(n / 10)
|
||||
const uni = n % 10
|
||||
exercises.push({
|
||||
id: `num7-descomponer-${n}`,
|
||||
stage: 7,
|
||||
skillCode: 'valor-posicional',
|
||||
type: 'conciencia',
|
||||
prerequisites: [`num7-componer-${n}`],
|
||||
content: {
|
||||
instruction: `¿Cuántas decenas y unidades tiene ${n}?`,
|
||||
text: `${n} = ? decenas + ? unidades`,
|
||||
audioText: `${n} tiene ${dec} decenas y ${uni} unidades`,
|
||||
options: [
|
||||
{ label: `${dec} dec, ${uni} uni`, correct: true, id: `d${dec}u${uni}` },
|
||||
{ label: `${uni} dec, ${dec} uni`, correct: false, id: `d${uni}u${dec}` },
|
||||
{ label: `${dec + 1} dec, ${Math.max(0, uni - 1)} uni`, correct: false, id: `d${dec + 1}u${Math.max(0, uni - 1)}` },
|
||||
],
|
||||
emoji: '🔟',
|
||||
},
|
||||
functionalContext: 'Descomponer números en decenas y unidades.',
|
||||
errorType: 'inversion-letra',
|
||||
})
|
||||
}
|
||||
|
||||
// Compare 2-digit numbers
|
||||
const comparaciones = [
|
||||
{ a: 15, b: 20 },
|
||||
{ a: 23, b: 18 },
|
||||
{ a: 30, b: 25 },
|
||||
{ a: 42, b: 39 },
|
||||
{ a: 50, b: 49 },
|
||||
]
|
||||
|
||||
for (const { a, b } of comparaciones) {
|
||||
const mayor = a > b ? a : b
|
||||
const menor = a < b ? a : b
|
||||
exercises.push({
|
||||
id: `num7-comparar-${a}-${b}`,
|
||||
stage: 7,
|
||||
skillCode: 'comparacion-2d',
|
||||
type: 'eleccion',
|
||||
prerequisites: [`num7-descomponer-${mayor}`],
|
||||
content: {
|
||||
instruction: `¿Cuál número es más grande?`,
|
||||
text: `${a} vs ${b}`,
|
||||
audioText: `¿Cuál es más grande?`,
|
||||
options: [
|
||||
{ label: String(a), correct: a === mayor, id: String(a) },
|
||||
{ label: String(b), correct: b === mayor, id: String(b) },
|
||||
],
|
||||
emoji: '📏',
|
||||
},
|
||||
functionalContext: 'Comparar números de 2 dígitos.',
|
||||
errorType: 'confusion-letra',
|
||||
})
|
||||
}
|
||||
|
||||
const stage: Stage = {
|
||||
id: 7,
|
||||
name: 'Decenas y unidades',
|
||||
description: 'Componer y descomponer números de 2 dígitos en decenas y unidades. Comparar números.',
|
||||
exercises,
|
||||
}
|
||||
|
||||
export default stage
|
||||
@@ -0,0 +1,214 @@
|
||||
import type { Stage } from '../types'
|
||||
|
||||
const problemas = [
|
||||
{
|
||||
text: 'Tenés 2 manzanas. Mamá te da 1 más. ¿Cuántas manzanas tenés?',
|
||||
respuesta: 3,
|
||||
opciones: [2, 3, 4],
|
||||
emoji: '🍎',
|
||||
op: 'suma',
|
||||
},
|
||||
{
|
||||
text: 'Hay 3 pájaros en el árbol. 1 se va volando. ¿Cuántos quedan?',
|
||||
respuesta: 2,
|
||||
opciones: [1, 2, 4],
|
||||
emoji: '🐦',
|
||||
op: 'resta',
|
||||
},
|
||||
{
|
||||
text: 'Tenés 4 galletitas. Te comés 1. ¿Cuántas te quedan?',
|
||||
respuesta: 3,
|
||||
opciones: [2, 3, 5],
|
||||
emoji: '🍪',
|
||||
op: 'resta',
|
||||
},
|
||||
{
|
||||
text: 'En el patio hay 2 perros y 3 gatos. ¿Cuántos animales hay?',
|
||||
respuesta: 5,
|
||||
opciones: [3, 4, 5],
|
||||
emoji: '🐕',
|
||||
op: 'suma',
|
||||
},
|
||||
{
|
||||
text: 'Tenés 5 lápices. Tu amiga te presta 2 más. ¿Cuántos lápices tenés ahora?',
|
||||
respuesta: 7,
|
||||
opciones: [5, 7, 8],
|
||||
emoji: '✏️',
|
||||
op: 'suma',
|
||||
},
|
||||
{
|
||||
text: 'Hay 6 flores en el jardín. Cortás 2 para mamá. ¿Cuántas quedan?',
|
||||
respuesta: 4,
|
||||
opciones: [3, 4, 8],
|
||||
emoji: '🌸',
|
||||
op: 'resta',
|
||||
},
|
||||
{
|
||||
text: 'En la mesa hay 3 platos y 3 vasos. ¿Cuántas cosas hay?',
|
||||
respuesta: 6,
|
||||
opciones: [5, 6, 9],
|
||||
emoji: '🍽️',
|
||||
op: 'suma',
|
||||
},
|
||||
{
|
||||
text: 'Tenés 7 stickers. Le das 2 a tu hermana. ¿Cuántos te quedan?',
|
||||
respuesta: 5,
|
||||
opciones: [4, 5, 9],
|
||||
emoji: '⭐',
|
||||
op: 'resta',
|
||||
},
|
||||
{
|
||||
text: 'Compraste 8 globos. 3 se explotan. ¿Cuántos globos quedan?',
|
||||
respuesta: 5,
|
||||
opciones: [3, 5, 11],
|
||||
emoji: '🎈',
|
||||
op: 'resta',
|
||||
},
|
||||
{
|
||||
text: 'En la pecera hay 4 peces naranjas y 2 azules. ¿Cuántos peces hay?',
|
||||
respuesta: 6,
|
||||
opciones: [4, 6, 8],
|
||||
emoji: '🐠',
|
||||
op: 'suma',
|
||||
},
|
||||
{
|
||||
text: 'Tenés 9 bloques. Usás 4 para hacer una torre. ¿Cuántos sobran?',
|
||||
respuesta: 5,
|
||||
opciones: [4, 5, 13],
|
||||
emoji: '🧱',
|
||||
op: 'resta',
|
||||
},
|
||||
{
|
||||
text: 'En tu plato hay 5 frutillas y 3 uvas. ¿Cuántas frutas tenés?',
|
||||
respuesta: 8,
|
||||
opciones: [5, 8, 15],
|
||||
emoji: '🍓',
|
||||
op: 'suma',
|
||||
},
|
||||
{
|
||||
text: 'Hay 10 libros en el estante. Sacás 4 para leer. ¿Cuántos quedan?',
|
||||
respuesta: 6,
|
||||
opciones: [4, 6, 14],
|
||||
emoji: '📚',
|
||||
op: 'resta',
|
||||
},
|
||||
{
|
||||
text: 'Tenés 2 manos. Cada mano tiene 5 dedos. ¿Cuántos dedos tenés en total?',
|
||||
respuesta: 10,
|
||||
opciones: [5, 7, 10],
|
||||
emoji: '🖐️',
|
||||
op: 'suma',
|
||||
},
|
||||
{
|
||||
text: 'Compartís 6 caramelos con tu amiga. Le das la mitad. ¿Cuántos te quedan?',
|
||||
respuesta: 3,
|
||||
opciones: [2, 3, 6],
|
||||
emoji: '🍬',
|
||||
op: 'resta',
|
||||
},
|
||||
{
|
||||
text: 'Hay 7 días en la semana. Ya pasaron 3. ¿Cuántos días quedan?',
|
||||
respuesta: 4,
|
||||
opciones: [3, 4, 10],
|
||||
emoji: '📅',
|
||||
op: 'resta',
|
||||
},
|
||||
{
|
||||
text: 'Viste 3 mariquitas en una hoja y 2 en otra. ¿Cuántas viste?',
|
||||
respuesta: 5,
|
||||
opciones: [3, 5, 6],
|
||||
emoji: '🐞',
|
||||
op: 'suma',
|
||||
},
|
||||
{
|
||||
text: 'Tenés 4 monedas. Encontrás 3 más en tu bolsillo. ¿Cuántas tenés?',
|
||||
respuesta: 7,
|
||||
opciones: [4, 7, 12],
|
||||
emoji: '🪙',
|
||||
op: 'suma',
|
||||
},
|
||||
{
|
||||
text: 'Son las 3 de la tarde. Pasaron 2 horas. ¿Qué hora es?',
|
||||
respuesta: 5,
|
||||
opciones: [1, 5, 6],
|
||||
emoji: '🕐',
|
||||
op: 'suma',
|
||||
},
|
||||
{
|
||||
text: 'El nene tiene 8 años. La nena tiene 3 años menos. ¿Cuántos años tiene la nena?',
|
||||
respuesta: 5,
|
||||
opciones: [3, 5, 11],
|
||||
emoji: '👶',
|
||||
op: 'resta',
|
||||
},
|
||||
{
|
||||
text: 'En la granja hay 2 caballos, 3 vacas y 1 cerdo. ¿Cuántos animales?',
|
||||
respuesta: 6,
|
||||
opciones: [4, 6, 7],
|
||||
emoji: '🐴',
|
||||
op: 'suma',
|
||||
},
|
||||
{
|
||||
text: 'Papá horneó 12 galletitas. Te comiste 2. ¿Cuántas quedan?',
|
||||
respuesta: 10,
|
||||
opciones: [8, 10, 14],
|
||||
emoji: '🍪',
|
||||
op: 'resta',
|
||||
},
|
||||
{
|
||||
text: 'Un tren tiene 4 vagones rojos y 4 azules. ¿Cuántos vagones tiene?',
|
||||
respuesta: 8,
|
||||
opciones: [4, 8, 16],
|
||||
emoji: '🚂',
|
||||
op: 'suma',
|
||||
},
|
||||
{
|
||||
text: 'Tenés 5 años. Cuando cumplas 5 más, ¿cuántos vas a tener?',
|
||||
respuesta: 10,
|
||||
opciones: [5, 10, 15],
|
||||
emoji: '🎂',
|
||||
op: 'suma',
|
||||
},
|
||||
{
|
||||
text: 'El árbol tiene 10 manzanas. Caen 3. ¿Cuántas manzanas quedan en el árbol?',
|
||||
respuesta: 7,
|
||||
opciones: [3, 7, 13],
|
||||
emoji: '🍎',
|
||||
op: 'resta',
|
||||
},
|
||||
]
|
||||
|
||||
const exercises: any[] = []
|
||||
|
||||
for (let i = 0; i < problemas.length; i++) {
|
||||
const p = problemas[i]
|
||||
exercises.push({
|
||||
id: `num8-problema-${i + 1}`,
|
||||
stage: 8,
|
||||
skillCode: 'problemas-cotidianos',
|
||||
type: 'problema',
|
||||
prerequisites: i === 0 ? ['num7-comparar-15-20'] : [`num8-problema-${i}`],
|
||||
content: {
|
||||
instruction: 'Escuchá bien el problema',
|
||||
text: p.text,
|
||||
audioText: p.text,
|
||||
emoji: p.emoji,
|
||||
options: p.opciones.map(o => ({
|
||||
label: String(o),
|
||||
correct: o === p.respuesta,
|
||||
id: String(o),
|
||||
})).sort(() => Math.random() - 0.5),
|
||||
},
|
||||
functionalContext: 'Resolver problemas matemáticos de la vida cotidiana.',
|
||||
errorType: p.op === 'resta' ? 'inversion-letra' : 'discriminacion-auditiva',
|
||||
})
|
||||
}
|
||||
|
||||
const stage: Stage = {
|
||||
id: 8,
|
||||
name: 'Problemas de la vida cotidiana',
|
||||
description: 'Resolver problemas cotidianos que requieren sumar y restar números hasta 12.',
|
||||
exercises,
|
||||
}
|
||||
|
||||
export default stage
|
||||
@@ -0,0 +1,54 @@
|
||||
export type ExerciseType =
|
||||
| "sensibilizacion"
|
||||
| "conciencia"
|
||||
| "trazo"
|
||||
| "eleccion"
|
||||
| "emparejar"
|
||||
| "secuencia"
|
||||
| "palabra"
|
||||
| "problema"
|
||||
| "lectura"
|
||||
|
||||
export type ErrorType =
|
||||
| "discriminacion-auditiva"
|
||||
| "confusion-letra"
|
||||
| "silaba-dificil"
|
||||
| "omision-letra"
|
||||
| "inversion-letra"
|
||||
| null
|
||||
|
||||
export interface ExerciseContent {
|
||||
instruction: string
|
||||
text?: string
|
||||
options?: { label: string; correct: boolean; id?: string }[]
|
||||
pairs?: { left: string; right: string; id: string }[]
|
||||
sequence?: string[]
|
||||
emoji?: string
|
||||
color?: string
|
||||
audioText?: string
|
||||
imageEmoji?: string
|
||||
}
|
||||
|
||||
export interface Exercise {
|
||||
id: string
|
||||
stage: number
|
||||
skillCode: string
|
||||
type: ExerciseType
|
||||
content: ExerciseContent
|
||||
prerequisites: string[]
|
||||
errorType?: ErrorType
|
||||
functionalContext?: string
|
||||
}
|
||||
|
||||
export interface Stage {
|
||||
id: number
|
||||
name: string
|
||||
description: string
|
||||
exercises: Exercise[]
|
||||
}
|
||||
|
||||
export interface TopicCurriculum {
|
||||
topic: string
|
||||
name: string
|
||||
stages: Stage[]
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: edueasy-db
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: edueasy
|
||||
POSTGRES_USER: edueasy
|
||||
POSTGRES_PASSWORD: edueasy_pass
|
||||
volumes:
|
||||
- edueasy_pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U edueasy -d edueasy"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- caddy
|
||||
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: edueasy
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
DATABASE_URL: "postgresql://edueasy:edueasy_pass@postgres:5432/edueasy?schema=public"
|
||||
BETTER_AUTH_SECRET: "${BETTER_AUTH_SECRET:-change-me-in-production}"
|
||||
BETTER_AUTH_URL: "${BETTER_AUTH_URL:-https://mate.cbcren.online}"
|
||||
NODE_ENV: production
|
||||
networks:
|
||||
- caddy
|
||||
labels:
|
||||
caddy: mate.cbcren.online
|
||||
caddy.reverse_proxy: "{{upstreams 3000}}"
|
||||
|
||||
networks:
|
||||
caddy:
|
||||
external: true
|
||||
|
||||
volumes:
|
||||
edueasy_pgdata:
|
||||
@@ -0,0 +1,40 @@
|
||||
# Dominios Libres para Edueasy
|
||||
|
||||
> Generado: 2026-07-20
|
||||
> IP del servidor: `194.163.191.200`
|
||||
|
||||
## Dominios disponibles (sin contenedor activo)
|
||||
|
||||
| Dominio | Ex-servicio | Ideal para |
|
||||
|---------|-------------|------------|
|
||||
| `mate.cbcren.online` | math-frontend + math-backend | API, dashboard, web app |
|
||||
| `manga.cbcren.online` | Kavita (lector manga) | Static site, app web |
|
||||
| `eco2.cbcren.online` | eco2-app (economía) | App Node.js / web |
|
||||
| `simulacro.cbcren.online` | simulacro-app (flask) | Python app, web app |
|
||||
|
||||
> **Nota:** `am.cbcren.online` (Open WebUI) y `demo2.cbcren.online` (pymesbot) no están libres — responden 401/307, probablemente con contenedor activo.
|
||||
|
||||
## Cómo usar un dominio
|
||||
|
||||
```bash
|
||||
# 1. Crear el contenedor conectado a la red caddy
|
||||
docker run -d --name edueasy-api --network caddy edueasy-image
|
||||
|
||||
# 2. Editar /home/ren/immich/Caddyfile agregando:
|
||||
# mate.cbcren.online {
|
||||
# reverse_proxy edueasy-api:3000
|
||||
# }
|
||||
|
||||
# 3. Recargar Caddy sin downtime
|
||||
docker exec caddy-ingress caddy reload --config /etc/caddy/Caddyfile
|
||||
```
|
||||
|
||||
## Config existente en Caddy (referencia)
|
||||
|
||||
Cada dominio ya está definido en `/home/ren/immich/Caddyfile`. Solo hay que cambiar el `reverse_proxy` al nuevo contenedor:
|
||||
|
||||
```caddy
|
||||
mate.cbcren.online {
|
||||
reverse_proxy nuevo-contenedor:3000
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,71 @@
|
||||
import { test, expect } from "@playwright/test"
|
||||
|
||||
test.describe("Curriculum API", () => {
|
||||
test("TTS endpoint generates audio", async ({ request }) => {
|
||||
const res = await request.get("/api/tts?text=hola%20mundo")
|
||||
expect(res.ok()).toBeTruthy()
|
||||
expect(res.headers()["content-type"]).toBe("audio/wav")
|
||||
const body = await res.body()
|
||||
expect(body.length).toBeGreaterThan(1000)
|
||||
})
|
||||
|
||||
test("TTS requires text param", async ({ request }) => {
|
||||
const res = await request.get("/api/tts")
|
||||
expect(res.status()).toBe(400)
|
||||
const json = await res.json()
|
||||
expect(json.error).toContain("text param")
|
||||
})
|
||||
|
||||
test("TTS text too long returns 400", async ({ request }) => {
|
||||
const long = "a".repeat(250)
|
||||
const res = await request.get(`/api/tts?text=${long}`)
|
||||
expect(res.status()).toBe(400)
|
||||
})
|
||||
|
||||
test("curriculum/next requires childId", async ({ request }) => {
|
||||
const res = await request.get("/api/curriculum/next")
|
||||
expect(res.status()).toBe(400)
|
||||
const json = await res.json()
|
||||
expect(json.error).toContain("childId")
|
||||
})
|
||||
|
||||
test("curriculum/next accepts childId param", async ({ request }) => {
|
||||
const res = await request.get("/api/curriculum/next?childId=any&topic=lectura")
|
||||
expect(res.status()).toBe(200)
|
||||
const json = await res.json()
|
||||
expect(json).toBeDefined()
|
||||
})
|
||||
|
||||
test("curriculum/grade missing fields returns 400", async ({ request }) => {
|
||||
const res = await request.post("/api/curriculum/grade", {
|
||||
data: {},
|
||||
})
|
||||
expect(res.status()).toBe(400)
|
||||
const json = await res.json()
|
||||
expect(json.error).toBe("Missing required fields")
|
||||
})
|
||||
|
||||
test("curriculum/grade validates required fields", async ({ request }) => {
|
||||
const res = await request.post("/api/curriculum/grade", {
|
||||
data: { childId: "test", exerciseId: "ex1", skillCode: "test" },
|
||||
headers: { "Content-Type": "application/json" },
|
||||
})
|
||||
expect(res.status()).toBe(400)
|
||||
const json = await res.json()
|
||||
expect(json.error).toBe("No active session")
|
||||
})
|
||||
|
||||
test("curriculum/progress requires childId", async ({ request }) => {
|
||||
const res = await request.get("/api/curriculum/progress")
|
||||
expect(res.status()).toBe(400)
|
||||
const json = await res.json()
|
||||
expect(json.error).toContain("childId")
|
||||
})
|
||||
|
||||
test("dashboard/summary requires childId", async ({ request }) => {
|
||||
const res = await request.get("/api/dashboard/summary")
|
||||
expect(res.status()).toBe(400)
|
||||
const json = await res.json()
|
||||
expect(json.error).toContain("childId")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,78 @@
|
||||
import { test, expect } from "@playwright/test"
|
||||
import { readFileSync } from "fs"
|
||||
import { join } from "path"
|
||||
|
||||
const lecturaStages = 9
|
||||
const numerosStages = 8
|
||||
|
||||
test.describe("Exercise content integrity", () => {
|
||||
for (let i = 1; i <= lecturaStages; i++) {
|
||||
test(`lectura etapa ${i} file exists and has exercises`, () => {
|
||||
const file = join(process.cwd(), "curriculum", "lectura", `etapa-${i}.ts`)
|
||||
const content = readFileSync(file, "utf-8")
|
||||
expect(content.length).toBeGreaterThan(200)
|
||||
expect(content).toContain("stage")
|
||||
expect(content).toContain("exercises")
|
||||
})
|
||||
}
|
||||
|
||||
for (let i = 1; i <= numerosStages; i++) {
|
||||
test(`numeros etapa ${i} file exists and has exercises`, () => {
|
||||
const file = join(process.cwd(), "curriculum", "numeros", `etapa-${i}.ts`)
|
||||
const content = readFileSync(file, "utf-8")
|
||||
expect(content.length).toBeGreaterThan(200)
|
||||
expect(content).toContain("stage")
|
||||
expect(content).toContain("exercises")
|
||||
})
|
||||
}
|
||||
|
||||
test("curriculum index imports all stages via loadStages", () => {
|
||||
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]")
|
||||
})
|
||||
|
||||
test("type definitions export required types", () => {
|
||||
const file = join(process.cwd(), "curriculum", "types.ts")
|
||||
const content = readFileSync(file, "utf-8")
|
||||
expect(content).toContain("export type Exercise")
|
||||
expect(content).toContain("export interface Stage")
|
||||
expect(content).toContain("export type ExerciseType")
|
||||
expect(content).toContain("export type ErrorType")
|
||||
})
|
||||
|
||||
test("exercise dispatcher maps all exercise types", () => {
|
||||
const file = join(process.cwd(), "components", "exercises", "exercise-dispatcher.tsx")
|
||||
const content = readFileSync(file, "utf-8")
|
||||
const types = ["sensibilizacion", "conciencia", "trazo", "eleccion", "emparejar", "secuencia", "palabra", "problema", "lectura"]
|
||||
for (const t of types) {
|
||||
expect(content).toContain(t)
|
||||
}
|
||||
})
|
||||
|
||||
test("all exercise component files exist", () => {
|
||||
const components = [
|
||||
"ejercicio-sensibilizacion",
|
||||
"ejercicio-conciencia",
|
||||
"ejercicio-eleccion",
|
||||
"ejercicio-secuencia",
|
||||
"ejercicio-palabra",
|
||||
"ejercicio-lectura",
|
||||
"ejercicio-trazo",
|
||||
"exercise-dispatcher",
|
||||
]
|
||||
for (const c of components) {
|
||||
const file = join(process.cwd(), "components", "exercises", `${c}.tsx`)
|
||||
const content = readFileSync(file, "utf-8")
|
||||
expect(content.length).toBeGreaterThan(100)
|
||||
}
|
||||
})
|
||||
|
||||
test("TTS route exists", () => {
|
||||
const file = join(process.cwd(), "app", "api", "tts", "route.ts")
|
||||
const content = readFileSync(file, "utf-8")
|
||||
expect(content).toContain("espeak-ng")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,50 @@
|
||||
import { test, expect } from "@playwright/test"
|
||||
|
||||
async function dismissAudioUnlock(page: any) {
|
||||
const btn = page.getByRole("button", { name: /tocá/i })
|
||||
if (await btn.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||
await btn.click()
|
||||
await page.waitForTimeout(1200)
|
||||
}
|
||||
}
|
||||
|
||||
test.describe("Pairing flow", () => {
|
||||
|
||||
test("pairing page loads form with code input", async ({ page }) => {
|
||||
await page.goto("/child/pairing", { waitUntil: "networkidle", timeout: 15000 })
|
||||
await dismissAudioUnlock(page)
|
||||
await expect(page.getByPlaceholder("Código")).toBeVisible({ timeout: 10000 })
|
||||
})
|
||||
|
||||
test("pairing page has submit button", async ({ page }) => {
|
||||
await page.goto("/child/pairing", { waitUntil: "networkidle", timeout: 15000 })
|
||||
await dismissAudioUnlock(page)
|
||||
await expect(page.getByRole("button", { name: "Listo" })).toBeVisible({ timeout: 10000 })
|
||||
})
|
||||
|
||||
test("pairing code input accepts text", async ({ page }) => {
|
||||
await page.goto("/child/pairing", { waitUntil: "networkidle", timeout: 15000 })
|
||||
await dismissAudioUnlock(page)
|
||||
const input = page.getByPlaceholder("Código")
|
||||
await input.fill("ABCD1234")
|
||||
await expect(input).toHaveValue("ABCD1234")
|
||||
})
|
||||
|
||||
test("lectura page shows pairing message when not paired", async ({ page }) => {
|
||||
await page.goto("/child/lectura", { waitUntil: "networkidle", timeout: 15000 })
|
||||
await dismissAudioUnlock(page)
|
||||
await expect(page.getByText("emparejar", { exact: false }).first()).toBeVisible({ timeout: 10000 })
|
||||
})
|
||||
|
||||
test("numeros page shows pairing message when not paired", async ({ page }) => {
|
||||
await page.goto("/child/numeros", { waitUntil: "networkidle", timeout: 15000 })
|
||||
await dismissAudioUnlock(page)
|
||||
await expect(page.getByText("emparejar", { exact: false }).first()).toBeVisible({ timeout: 10000 })
|
||||
})
|
||||
|
||||
test("lectura page has ir a emparejar button", async ({ page }) => {
|
||||
await page.goto("/child/lectura", { waitUntil: "networkidle", timeout: 15000 })
|
||||
await dismissAudioUnlock(page)
|
||||
await expect(page.getByRole("button", { name: /emparejar/i })).toBeVisible({ timeout: 10000 })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,14 @@
|
||||
import { dirname } from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
import { FlatCompat } from "@eslint/eslintrc"
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = dirname(__filename)
|
||||
|
||||
const compat = new FlatCompat({ baseDirectory: __dirname })
|
||||
|
||||
const eslintConfig = [
|
||||
...compat.extends("next/core-web-vitals"),
|
||||
]
|
||||
|
||||
export default eslintConfig
|
||||
@@ -0,0 +1,9 @@
|
||||
"use client"
|
||||
|
||||
import { createAuthClient } from "better-auth/react"
|
||||
|
||||
export const authClient = createAuthClient({
|
||||
baseURL: process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000",
|
||||
})
|
||||
|
||||
export const { signIn, signUp, useSession } = authClient
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
import { betterAuth } from "better-auth"
|
||||
import { prismaAdapter } from "better-auth/adapters/prisma"
|
||||
import { prisma } from "@/lib/db"
|
||||
|
||||
export const auth = betterAuth({
|
||||
database: prismaAdapter(prisma, {
|
||||
provider: "postgresql",
|
||||
}),
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
},
|
||||
user: {
|
||||
modelName: "Parent",
|
||||
},
|
||||
session: {
|
||||
modelName: "Session",
|
||||
},
|
||||
databaseHooks: {
|
||||
user: {
|
||||
create: {
|
||||
after: async (user) => {
|
||||
const family = await prisma.family.create({ data: { name: "" } })
|
||||
await prisma.parent.update({
|
||||
where: { id: user.id },
|
||||
data: { familyId: family.id },
|
||||
})
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,124 @@
|
||||
import { prisma } from "@/lib/db"
|
||||
import { initCurriculum, getLecturaStages, getNumerosStages, getAllLecturaExercises } from "@/curriculum/index"
|
||||
import type { Exercise } from "@/curriculum/types"
|
||||
|
||||
const STAGE_THRESHOLD = 0.8
|
||||
|
||||
async function ensureInitialStage(childId: string, topic: string) {
|
||||
try {
|
||||
const exists = await prisma.curriculumProgress.findFirst({
|
||||
where: { childId, topic },
|
||||
})
|
||||
if (!exists) {
|
||||
await prisma.curriculumProgress.create({
|
||||
data: { childId, topic, stage: 1 },
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
// Child may not exist yet in FK constraint — proceed gracefully
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCurrentStage(childId: string, topic: string) {
|
||||
await ensureInitialStage(childId, topic)
|
||||
const progress = await prisma.curriculumProgress.findFirst({
|
||||
where: { childId, topic },
|
||||
orderBy: { stage: "asc" },
|
||||
})
|
||||
return progress
|
||||
}
|
||||
|
||||
export async function getNextExercise(
|
||||
childId: string,
|
||||
topic: "lectura" | "numeros",
|
||||
): Promise<Exercise | null> {
|
||||
await initCurriculum()
|
||||
await ensureInitialStage(childId, topic)
|
||||
|
||||
const progress = await prisma.curriculumProgress.findFirst({
|
||||
where: { childId, topic, completedAt: null },
|
||||
orderBy: { stage: "asc" },
|
||||
})
|
||||
|
||||
if (!progress) return null
|
||||
|
||||
const stages = topic === "lectura" ? getLecturaStages() : getNumerosStages()
|
||||
const stage = stages[progress.stage]
|
||||
if (!stage || !stage.exercises.length) return null
|
||||
|
||||
const allAttemps = await prisma.skillAttempt.findMany({
|
||||
where: { exerciseId: { not: null }, sessionLog: { childId } },
|
||||
select: { exerciseId: true, correct: true },
|
||||
})
|
||||
|
||||
const mastered = new Set(
|
||||
allAttemps
|
||||
.filter((a) => a.correct)
|
||||
.map((a) => a.exerciseId),
|
||||
)
|
||||
|
||||
const attempted = new Set(allAttemps.map((a) => a.exerciseId))
|
||||
|
||||
const available = stage.exercises.filter((ex) => {
|
||||
if (mastered.has(ex.id)) return false
|
||||
const prereqsMet = ex.prerequisites.every((p) => mastered.has(p))
|
||||
return prereqsMet
|
||||
})
|
||||
|
||||
if (available.length === 0) {
|
||||
if (attempted.size > 0 && stage.exercises.length > 0) {
|
||||
const masteryRatio = mastered.size / stage.exercises.length
|
||||
if (masteryRatio >= STAGE_THRESHOLD) {
|
||||
await advanceStage(childId, topic, progress.stage)
|
||||
return getNextExercise(childId, topic)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const fsrsCards = await prisma.fsrsCard.findMany({
|
||||
where: { childId, skillCode: { in: available.map((e) => e.skillCode) } },
|
||||
})
|
||||
|
||||
const dueMap = new Map(fsrsCards.map((c) => [c.skillCode, c.dueAt.getTime()]))
|
||||
const now = Date.now()
|
||||
|
||||
const unsorted = [...available]
|
||||
unsorted.sort((a, b) => {
|
||||
const dueA = dueMap.get(a.skillCode) ?? 0
|
||||
const dueB = dueMap.get(b.skillCode) ?? 0
|
||||
const isDueA = dueA <= now ? 0 : 1
|
||||
const isDueB = dueB <= now ? 0 : 1
|
||||
if (isDueA !== isDueB) return isDueA - isDueB
|
||||
if (!attempted.has(a.id) && !attempted.has(b.id)) return 0
|
||||
if (!attempted.has(a.id)) return -1
|
||||
if (!attempted.has(b.id)) return 1
|
||||
return dueA - dueB
|
||||
})
|
||||
|
||||
return unsorted[0] || null
|
||||
}
|
||||
|
||||
async function advanceStage(childId: string, topic: string, currentStage: number) {
|
||||
await prisma.curriculumProgress.updateMany({
|
||||
where: { childId, topic, stage: currentStage },
|
||||
data: { completedAt: new Date() },
|
||||
})
|
||||
const nextStage = currentStage + 1
|
||||
const exists = await prisma.curriculumProgress.findUnique({
|
||||
where: { childId_topic_stage: { childId, topic, stage: nextStage } },
|
||||
})
|
||||
if (!exists) {
|
||||
await prisma.curriculumProgress.create({
|
||||
data: { childId, topic, stage: nextStage },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export function getErrorTypeForExercise(
|
||||
exercise: Exercise,
|
||||
correct: boolean,
|
||||
): string | null {
|
||||
if (correct) return null
|
||||
return exercise.errorType || "discriminacion-auditiva"
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { PrismaClient } from "@prisma/client"
|
||||
|
||||
const globalForPrisma = globalThis as unknown as {
|
||||
prisma: PrismaClient | undefined
|
||||
}
|
||||
|
||||
export const prisma = globalForPrisma.prisma ?? new PrismaClient()
|
||||
|
||||
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
import { fsrs as createFSRS, createEmptyCard, Rating, type Card, type Grade } from "ts-fsrs"
|
||||
|
||||
const scheduler = createFSRS({ maximum_interval: 365, request_retention: 0.9 })
|
||||
|
||||
export type FsrsGrade = "again" | "hard" | "good" | "easy"
|
||||
|
||||
const gradeMap: Record<FsrsGrade, Grade> = {
|
||||
again: Rating.Again,
|
||||
hard: Rating.Hard,
|
||||
good: Rating.Good,
|
||||
easy: Rating.Easy,
|
||||
}
|
||||
|
||||
export function buildFsrsCard(params: {
|
||||
stability?: number
|
||||
difficulty?: number
|
||||
reps?: number
|
||||
lapses?: number
|
||||
due?: Date
|
||||
}): Card {
|
||||
const card = createEmptyCard(params.due ?? new Date())
|
||||
return {
|
||||
...card,
|
||||
stability: params.stability ?? card.stability,
|
||||
difficulty: params.difficulty ?? card.difficulty,
|
||||
reps: params.reps ?? 0,
|
||||
lapses: params.lapses ?? 0,
|
||||
due: params.due ?? card.due,
|
||||
}
|
||||
}
|
||||
|
||||
export function gradeCard(card: Card, grade: FsrsGrade, now: Date = new Date()) {
|
||||
const result = scheduler.next(card, now, gradeMap[grade])
|
||||
return {
|
||||
card: result.card,
|
||||
log: result.log,
|
||||
}
|
||||
}
|
||||
|
||||
export function getRetrievability(card: Card, now: Date = new Date()): number {
|
||||
const r = scheduler.get_retrievability(card, now, false)
|
||||
return typeof r === "number" ? r : parseFloat(r)
|
||||
}
|
||||
|
||||
export function skillCodeFromGrade(correct: boolean, responseMs: number): FsrsGrade {
|
||||
if (!correct) return "again"
|
||||
if (responseMs > 8000) return "hard"
|
||||
if (responseMs > 4000) return "good"
|
||||
return "easy"
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
export interface Fonema {
|
||||
letra: string
|
||||
fonema: string
|
||||
palabra: string
|
||||
imageEmoji: string
|
||||
gesto: string // description of Borel-Maisonny gesture
|
||||
color: string
|
||||
orden: number
|
||||
}
|
||||
|
||||
// Vocales first (Jolly Phonics order adapted for Spanish)
|
||||
export const vocales: Fonema[] = [
|
||||
{
|
||||
letra: "a",
|
||||
fonema: "a",
|
||||
palabra: "avión",
|
||||
imageEmoji: "✈️",
|
||||
gesto: "Abrir la mano como si saludaras, con los dedos juntos.",
|
||||
color: "#E74C3C",
|
||||
orden: 1,
|
||||
},
|
||||
{
|
||||
letra: "e",
|
||||
fonema: "e",
|
||||
palabra: "elefante",
|
||||
imageEmoji: "🐘",
|
||||
gesto: "Señalar hacia la derecha con la mano abierta.",
|
||||
color: "#3498DB",
|
||||
orden: 2,
|
||||
},
|
||||
{
|
||||
letra: "i",
|
||||
fonema: "i",
|
||||
palabra: "iglú",
|
||||
imageEmoji: "🏔️",
|
||||
gesto: "Señalar hacia arriba con el dedo índice.",
|
||||
color: "#9B59B6",
|
||||
orden: 3,
|
||||
},
|
||||
{
|
||||
letra: "o",
|
||||
fonema: "o",
|
||||
palabra: "oso",
|
||||
imageEmoji: "🐻",
|
||||
gesto: "Hacer un círculo con la mano frente a la boca.",
|
||||
color: "#F39C12",
|
||||
orden: 4,
|
||||
},
|
||||
{
|
||||
letra: "u",
|
||||
fonema: "u",
|
||||
palabra: "uva",
|
||||
imageEmoji: "🍇",
|
||||
gesto: "Estirar los dos brazos hacia adelante como un tubo.",
|
||||
color: "#1ABC9C",
|
||||
orden: 5,
|
||||
},
|
||||
]
|
||||
|
||||
export function getFonema(letra: string): Fonema | undefined {
|
||||
return vocales.find((v) => v.letra === letra)
|
||||
}
|
||||
|
||||
export function getGestoDescription(letra: string): string {
|
||||
const f = getFonema(letra)
|
||||
return f?.gesto || ""
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
export type CPAStage = "concrete" | "pictoric" | "abstract"
|
||||
|
||||
export type GalperinStage = "hand" | "external_speech" | "inner_speech" | "mental" | "mastered"
|
||||
|
||||
export interface CPAState {
|
||||
stage: CPAStage
|
||||
currentValue: number
|
||||
attempts: number
|
||||
mastered: boolean
|
||||
galperinStage: GalperinStage
|
||||
}
|
||||
|
||||
export function getCPAProgression(skillValue: number): CPAStage[] {
|
||||
return ["concrete", "pictoric", "abstract"]
|
||||
}
|
||||
|
||||
export function createCPAState(value: number): CPAState {
|
||||
return {
|
||||
stage: "concrete",
|
||||
currentValue: value,
|
||||
attempts: 0,
|
||||
mastered: false,
|
||||
galperinStage: "hand",
|
||||
}
|
||||
}
|
||||
|
||||
export function advanceStage(state: CPAState): CPAState {
|
||||
const stages: CPAStage[] = ["concrete", "pictoric", "abstract"]
|
||||
const currentIdx = stages.indexOf(state.stage)
|
||||
if (currentIdx < stages.length - 1) {
|
||||
return { ...state, stage: stages[currentIdx + 1], galperinStage: "hand" }
|
||||
}
|
||||
return { ...state, mastered: true }
|
||||
}
|
||||
|
||||
export function advanceGalperin(stage: GalperinStage): GalperinStage {
|
||||
const order: GalperinStage[] = ["hand", "external_speech", "inner_speech", "mental", "mastered"]
|
||||
const idx = order.indexOf(stage)
|
||||
if (idx < order.length - 1) return order[idx + 1]
|
||||
return "mastered"
|
||||
}
|
||||
|
||||
export function getGalperinInstruction(stage: GalperinStage, value: number): string {
|
||||
switch (stage) {
|
||||
case "hand":
|
||||
return `Tocá ${value} cosas con tu dedo`
|
||||
case "external_speech":
|
||||
return "Contá en voz alta mientras tocás"
|
||||
case "inner_speech":
|
||||
return "Ahora contá susurrando"
|
||||
case "mental":
|
||||
return "Pensá el número en tu cabeza y señalalo"
|
||||
case "mastered":
|
||||
return "¡Ya sabés este número!"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
export interface Rod {
|
||||
value: number
|
||||
color: string
|
||||
lengthPx: number
|
||||
label: string
|
||||
}
|
||||
|
||||
export const rodColors: Record<number, string> = {
|
||||
1: "#FFFFFF",
|
||||
2: "#E74C3C",
|
||||
3: "#2ECC71",
|
||||
4: "#E91E63",
|
||||
5: "#F1C40F",
|
||||
6: "#1ABC9C",
|
||||
7: "#2C3E50",
|
||||
8: "#3498DB",
|
||||
9: "#9B59B6",
|
||||
10: "#E67E22",
|
||||
}
|
||||
|
||||
const rodLengths: Record<number, number> = {
|
||||
1: 24,
|
||||
2: 44,
|
||||
3: 64,
|
||||
4: 84,
|
||||
5: 104,
|
||||
6: 124,
|
||||
7: 144,
|
||||
8: 164,
|
||||
9: 184,
|
||||
10: 204,
|
||||
}
|
||||
|
||||
export function getRods(maxValue: number = 10): Rod[] {
|
||||
return Array.from({ length: maxValue }, (_, i) => ({
|
||||
value: i + 1,
|
||||
color: rodColors[i + 1],
|
||||
lengthPx: rodLengths[i + 1],
|
||||
label: `${i + 1}`,
|
||||
}))
|
||||
}
|
||||
|
||||
export function compareRods(a: number, b: number): "mayor" | "menor" | "igual" {
|
||||
if (a > b) return "mayor"
|
||||
if (a < b) return "menor"
|
||||
return "igual"
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
export type PromptLevel = 0 | 1 | 2 | 3 | 4
|
||||
|
||||
// Galperin stages mapped to prompt levels
|
||||
export const GALPERIN_STAGES = {
|
||||
HAND: 0, // Concrete: full model, show the answer
|
||||
EXTERNAL_SPEECH: 1, // Prompts the child to say out loud
|
||||
INNER_SPEECH: 2, // Partial prompt, whisper
|
||||
MENTAL: 3, // Verbal cue only
|
||||
MASTERED: 4, // No prompt
|
||||
} as const
|
||||
|
||||
export interface PromptState {
|
||||
level: PromptLevel
|
||||
consecutiveCorrect: number
|
||||
consecutiveIncorrect: number
|
||||
}
|
||||
|
||||
export function getInitialState(): PromptState {
|
||||
return {
|
||||
level: 0,
|
||||
consecutiveCorrect: 0,
|
||||
consecutiveIncorrect: 0,
|
||||
}
|
||||
}
|
||||
|
||||
export function evaluateResponse(
|
||||
state: PromptState,
|
||||
correct: boolean,
|
||||
masterThreshold: number = 3,
|
||||
regressThreshold: number = 2,
|
||||
): PromptState {
|
||||
if (correct) {
|
||||
const newCorrect = state.consecutiveCorrect + 1
|
||||
const newLevel =
|
||||
newCorrect >= masterThreshold
|
||||
? (Math.min(state.level + 1, 4) as PromptLevel)
|
||||
: state.level
|
||||
return {
|
||||
level: newLevel,
|
||||
consecutiveCorrect: newCorrect,
|
||||
consecutiveIncorrect: 0,
|
||||
}
|
||||
}
|
||||
|
||||
const newIncorrect = state.consecutiveIncorrect + 1
|
||||
const newLevel =
|
||||
newIncorrect >= regressThreshold
|
||||
? (Math.max(state.level - 1, 0) as PromptLevel)
|
||||
: state.level
|
||||
return {
|
||||
level: newLevel,
|
||||
consecutiveCorrect: 0,
|
||||
consecutiveIncorrect: newIncorrect,
|
||||
}
|
||||
}
|
||||
|
||||
export function shouldShowPrompt(level: PromptLevel): boolean {
|
||||
return level < 3
|
||||
}
|
||||
|
||||
export function isMastered(level: PromptLevel): boolean {
|
||||
return level >= 4
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
"use client"
|
||||
|
||||
export interface SkillAttemptPayload {
|
||||
skillCode: string
|
||||
promptLevel: number
|
||||
correct: boolean
|
||||
responseMs: number
|
||||
}
|
||||
|
||||
export interface SessionPayload {
|
||||
childId: string
|
||||
startedAt: string
|
||||
endedAt: string
|
||||
durationSec: number
|
||||
skillsPracticed: string
|
||||
attempts: SkillAttemptPayload[]
|
||||
}
|
||||
|
||||
export async function syncSession(payload: SessionPayload): Promise<string | null> {
|
||||
try {
|
||||
const res = await fetch("/api/sessions/sync", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
childId: payload.childId,
|
||||
session: {
|
||||
startedAt: payload.startedAt,
|
||||
endedAt: payload.endedAt,
|
||||
durationSec: payload.durationSec,
|
||||
skillsPracticed: payload.skillsPracticed,
|
||||
},
|
||||
attempts: payload.attempts,
|
||||
}),
|
||||
})
|
||||
const data = await res.json()
|
||||
return data.sessionId ?? null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function createMilestone(childId: string, skillCode: string): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch("/api/milestones", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ childId, skillCode }),
|
||||
})
|
||||
return res.ok
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
"use client"
|
||||
|
||||
let utterance: SpeechSynthesisUtterance | null = null
|
||||
|
||||
export async function speak(
|
||||
text: string,
|
||||
_rate = 0.85,
|
||||
): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
stopSpeaking()
|
||||
|
||||
const u = new SpeechSynthesisUtterance(text)
|
||||
u.lang = "es-AR"
|
||||
u.rate = _rate
|
||||
|
||||
u.onend = () => resolve()
|
||||
u.onerror = () => resolve()
|
||||
|
||||
utterance = u
|
||||
speechSynthesis.speak(u)
|
||||
})
|
||||
}
|
||||
|
||||
export function stopSpeaking(): void {
|
||||
if (utterance) {
|
||||
speechSynthesis.cancel()
|
||||
utterance = null
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user