feat: EduEasy checkpoint - plan, scaffolding, pedagogia modules, components child+parent, API routes, Prisma schema, Docker infra, Better Auth

This commit is contained in:
Renato
2026-07-20 17:58:49 +02:00
commit e69b18abaa
49 changed files with 9842 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
{
"fingerprint": "c6be2155b5d458c41adc0623b75fe4fad880b5f6"
}
+84
View File
@@ -0,0 +1,84 @@
# 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/.claude/skills
- /home/ren/.codex/skills
- /home/ren/.qwen/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 |
| --- | --- | --- | --- |
| `api-design` | REST API design patterns including resource naming, status codes, pagination, filtering, error responses, versioning, and rate limiting for production APIs. | user | `/home/ren/.claude/skills/api-design/SKILL.md` |
| `backend-patterns` | Backend architecture patterns, API design, database optimization, and server-side best practices for Node.js, Express, and Next.js API routes. | user | `/home/ren/.claude/skills/backend-patterns/SKILL.md` |
| `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` |
| `clickhouse-io` | ClickHouse database patterns, query optimization, analytics, and data engineering best practices for high-performance analytical workloads. | user | `/home/ren/.claude/skills/clickhouse-io/SKILL.md` |
| `coding-standards` | Universal coding standards, best practices, and patterns for TypeScript, JavaScript, React, and Node.js development. | user | `/home/ren/.claude/skills/coding-standards/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` |
| `configure-ecc` | Interactive installer for Everything Claude Code — guides users through selecting and installing skills and rules to user-level or project-level directories, verifies paths, and optionally optimizes installed files. | user | `/home/ren/.claude/skills/configure-ecc/SKILL.md` |
| `constructor` | Build, rebuild, flatten, clean, and verify structures in the Minecraft world managed by Crafty Controller on this VPS. Use when requests involve placing/removing blocks, terraforming areas, teleporting players, recreating murals/flags from images, validating coordinates or block states, and keeping ren.md updated after world changes. | user | `/home/ren/.codex/skills/constructor/SKILL.md` |
| `continuous-learning` | Automatically extract reusable patterns from Claude Code sessions and save them as learned skills for future use. | user | `/home/ren/.claude/skills/continuous-learning/SKILL.md` |
| `continuous-learning-v2` | Instinct-based learning system that observes sessions via hooks, creates atomic instincts with confidence scoring, and evolves them into skills/commands/agents. | user | `/home/ren/.claude/skills/continuous-learning-v2/SKILL.md` |
| `cpp-testing` | Use only when writing/updating/fixing C++ tests, configuring GoogleTest/CTest, diagnosing failing or flaky tests, or adding coverage/sanitizers. | user | `/home/ren/.claude/skills/cpp-testing/SKILL.md` |
| `database-migrations` | Database migration best practices for schema changes, data migrations, rollbacks, and zero-downtime deployments across PostgreSQL, MySQL, and common ORMs (Prisma, Drizzle, Django, TypeORM, golang-migrate). | user | `/home/ren/.claude/skills/database-migrations/SKILL.md` |
| `deployment-patterns` | Deployment workflows, CI/CD pipeline patterns, Docker containerization, health checks, rollback strategies, and production readiness checklists for web applications. | user | `/home/ren/.claude/skills/deployment-patterns/SKILL.md` |
| `django-patterns` | Django architecture patterns, REST API design with DRF, ORM best practices, caching, signals, middleware, and production-grade Django apps. | user | `/home/ren/.claude/skills/django-patterns/SKILL.md` |
| `django-security` | Django security best practices, authentication, authorization, CSRF protection, SQL injection prevention, XSS prevention, and secure deployment configurations. | user | `/home/ren/.claude/skills/django-security/SKILL.md` |
| `django-tdd` | Django testing strategies with pytest-django, TDD methodology, factory_boy, mocking, coverage, and testing Django REST Framework APIs. | user | `/home/ren/.claude/skills/django-tdd/SKILL.md` |
| `django-verification` | Verification loop for Django projects: migrations, linting, tests with coverage, security scans, and deployment readiness checks before release or PR. | user | `/home/ren/.claude/skills/django-verification/SKILL.md` |
| `docker-patterns` | Docker and Docker Compose patterns for local development, container security, networking, volume strategies, and multi-service orchestration. | user | `/home/ren/.claude/skills/docker-patterns/SKILL.md` |
| `e2e-testing` | Playwright E2E testing patterns, Page Object Model, configuration, CI/CD integration, artifact management, and flaky test strategies. | user | `/home/ren/.claude/skills/e2e-testing/SKILL.md` |
| `eval-harness` | Formal evaluation framework for Claude Code sessions implementing eval-driven development (EDD) principles | user | `/home/ren/.claude/skills/eval-harness/SKILL.md` |
| `frontend-patterns` | Frontend development patterns for React, Next.js, state management, performance optimization, and UI best practices. | user | `/home/ren/.claude/skills/frontend-patterns/SKILL.md` |
| `gentle-ai-chained-pr` | Split large changes into chained or stacked pull requests that protect reviewer focus and stay within Gentle AI's 400-line cognitive review budget. Trigger: when a PR would exceed 400 changed lines, when planning chained PRs, stacked PRs, or reviewable slices. | user | `/home/ren/.claude/skills/chained-pr/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` |
| `golang-patterns` | Idiomatic Go patterns, best practices, and conventions for building robust, efficient, and maintainable Go applications. | user | `/home/ren/.claude/skills/golang-patterns/SKILL.md` |
| `golang-testing` | Go testing patterns including table-driven tests, subtests, benchmarks, fuzzing, and test coverage. Follows TDD methodology with idiomatic Go practices. | user | `/home/ren/.claude/skills/golang-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` |
| `iterative-retrieval` | Pattern for progressively refining context retrieval to solve the subagent context problem | user | `/home/ren/.claude/skills/iterative-retrieval/SKILL.md` |
| `java-coding-standards` | Java coding standards for Spring Boot services: naming, immutability, Optional usage, streams, exceptions, generics, and project layout. | user | `/home/ren/.claude/skills/java-coding-standards/SKILL.md` |
| `jpa-patterns` | JPA/Hibernate patterns for entity design, relationships, query optimization, transactions, auditing, indexing, pagination, and pooling in Spring Boot. | user | `/home/ren/.claude/skills/jpa-patterns/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` |
| `kilo-force-multiplier` | Use Kilo (OpenCode's AI agent) as a force multiplier for development work. Covers subagent architecture, custom commands, skills system, MCP integration, multi-model routing, context management, and the SDD workflow. Trigger: when setting up Kilo, creating custom commands, building skills, configuring MCP servers, or optimizing AI-assisted development workflows. | user | `/home/ren/.claude/skills/kilo-force-multiplier/SKILL.md` |
| `minebuilder` | — | user | `/home/ren/.claude/skills/minebuilder/SKILL.md` |
| `minecraft-construction` | Minecraft large-scale construction via Crafty API. Parallel section processing, proper terrain flattening, chunk management, and multi-agent coordination for efficient building. | user | `/home/ren/.claude/skills/minecraft-construction/SKILL.md` |
| `nutrient-document-processing` | Process, convert, OCR, extract, redact, sign, and fill documents using the Nutrient DWS API. Works with PDFs, DOCX, XLSX, PPTX, HTML, and images. | user | `/home/ren/.claude/skills/nutrient-document-processing/SKILL.md` |
| `postgres-patterns` | PostgreSQL database patterns for query optimization, schema design, indexing, and security. Based on Supabase best practices. | user | `/home/ren/.claude/skills/postgres-patterns/SKILL.md` |
| `project-guidelines-example` | Example project-specific skill template based on a real production application. | user | `/home/ren/.claude/skills/project-guidelines-example/SKILL.md` |
| `python-patterns` | Pythonic idioms, PEP 8 standards, type hints, and best practices for building robust, efficient, and maintainable Python applications. | user | `/home/ren/.claude/skills/python-patterns/SKILL.md` |
| `python-testing` | Python testing strategies using pytest, TDD methodology, fixtures, mocking, parametrization, and coverage requirements. | user | `/home/ren/.claude/skills/python-testing/SKILL.md` |
| `security-review` | Use this skill when adding authentication, handling user input, working with secrets, creating API endpoints, or implementing payment/sensitive features. Provides comprehensive security checklist and patterns. | user | `/home/ren/.claude/skills/security-review/SKILL.md` |
| `security-scan` | Scan your Claude Code configuration (.claude/ directory) for security vulnerabilities, misconfigurations, and injection risks using AgentShield. Checks CLAUDE.md, settings.json, MCP servers, hooks, and agent definitions. | user | `/home/ren/.claude/skills/security-scan/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` |
| `skill-installer` | Install Codex skills into $CODEX_HOME/skills from a curated list or a GitHub repo path. Use when a user asks to list installable skills, install a curated skill, or install a skill from another repo (including private repos). | user | `/home/ren/.codex/skills/.system/skill-installer/SKILL.md` |
| `springboot-patterns` | Spring Boot architecture patterns, REST API design, layered services, data access, caching, async processing, and logging. Use for Java Spring Boot backend work. | user | `/home/ren/.claude/skills/springboot-patterns/SKILL.md` |
| `springboot-security` | Spring Security best practices for authn/authz, validation, CSRF, secrets, headers, rate limiting, and dependency security in Java Spring Boot services. | user | `/home/ren/.claude/skills/springboot-security/SKILL.md` |
| `springboot-tdd` | Test-driven development for Spring Boot using JUnit 5, Mockito, MockMvc, Testcontainers, and JaCoCo. Use when adding features, fixing bugs, or refactoring. | user | `/home/ren/.claude/skills/springboot-tdd/SKILL.md` |
| `springboot-verification` | Verification loop for Spring Boot projects: build, static analysis, tests with coverage, security scans, and diff review before release or PR. | user | `/home/ren/.claude/skills/springboot-verification/SKILL.md` |
| `strategic-compact` | Suggests manual context compaction at logical intervals to preserve context through task phases rather than arbitrary auto-compaction. | user | `/home/ren/.claude/skills/strategic-compact/SKILL.md` |
| `tdd-workflow` | Use this skill when writing new features, fixing bugs, or refactoring code. Enforces test-driven development with 80%+ coverage including unit, integration, and E2E tests. | user | `/home/ren/.claude/skills/tdd-workflow/SKILL.md` |
| `verification-loop` | A comprehensive verification system for Claude Code sessions. | user | `/home/ren/.claude/skills/verification-loop/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` |
| `zai-vision` | Analyze images via Z.AI GLM-4.6V vision model. Use when the user shares an image file path, base64 image data, image URL, or asks to describe/analyze/OCR/read visual content. Gives vision capabilities to text-only models. | user | `/home/ren/.claude/skills/zai-vision/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`.
+8
View File
@@ -0,0 +1,8 @@
node_modules/
.next/
*.tsbuildinfo
next-env.d.ts
.env.local
.env*.local
.cache/
dist/
+38
View File
@@ -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"]
+219
View File
@@ -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**: 6080px 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.
---
+4
View File
@@ -0,0 +1,4 @@
import { auth } from "@/lib/auth"
import { toNextJsHandler } from "better-auth/next-js"
export const { POST, GET } = toNextJsHandler(auth)
+49
View File
@@ -0,0 +1,49 @@
import { NextResponse } from "next/server"
import { prisma } from "@/lib/db"
import { randomUUID } from "crypto"
export async function POST(request: Request) {
try {
const { deviceFingerprint, pairingCode: incomingCode } = await request.json()
if (incomingCode) {
// Step 2: Parent's device confirms pairing
const device = await prisma.device.findFirst({
where: { deviceFingerprint: incomingCode },
include: { child: true },
})
if (!device) {
return NextResponse.json({ error: "Código inválido" }, { status: 404 })
}
return NextResponse.json({
success: true,
childId: device.childId,
childName: device.child.name,
})
}
// Step 1: Child device requests pairing
const existingDevice = await prisma.device.findFirst({
where: { deviceFingerprint },
})
if (existingDevice) {
return NextResponse.json({
paired: true,
childId: existingDevice.childId,
})
}
const pairingCode = randomUUID().slice(0, 8)
return NextResponse.json({
paired: false,
pairingCode,
})
} catch (error) {
console.error("Pairing error:", error)
return NextResponse.json({ error: "Internal error" }, { status: 500 })
}
}
+36
View File
@@ -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 })
}
}
+27
View File
@@ -0,0 +1,27 @@
"use client"
import { useEffect, useState } from "react"
import GatoMascota from "@/components/child/gato-mascota"
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>
)
}
+202
View File
@@ -0,0 +1,202 @@
"use client"
import { useState, useCallback } from "react"
import { motion, AnimatePresence } from "framer-motion"
import { GatoMascota } from "@/components/child/gato-mascota"
import VocalCard from "@/components/child/vocal-card"
import TrazoLetra from "@/components/child/trazo-letra"
import TemporizadorVisual from "@/components/child/temporizador-visual"
import { speak } from "@/lib/speech"
import { vocales } from "@/lib/pedagogia/borel-maisonny"
import { getInitialState, evaluateResponse, isMastered } from "@/lib/pedagogia/errorless"
type Step = "gesto" | "escuchar" | "trazar" | "elegir" | "completo"
export default function LecturaSesion() {
const [currentIndex, setCurrentIndex] = useState(0)
const [step, setStep] = useState<Step>("gesto")
const [promptState, setPromptState] = useState(getInitialState())
const [sessionComplete, setSessionComplete] = useState(false)
const [running, setRunning] = useState(true)
const [score, setScore] = useState(0)
const vocal = vocales[currentIndex]
const handleGestoComplete = useCallback(() => {
speak(`Escuchá. Esta es la letra ${vocal.letra}. ${vocal.palabra}. ${vocal.fonema} ${vocal.fonema} ${vocal.fonema}`)
setStep("escuchar")
}, [vocal])
const handleEscucharComplete = useCallback(() => {
speak(`Ahora trazala con tu dedo`)
setStep("trazar")
}, [])
const handleTrazoComplete = useCallback(() => {
setScore((s) => s + 1)
const newState = evaluateResponse(promptState, true)
setPromptState(newState)
if (isMastered(newState.level)) {
speak(`¡Muy bien! Pasemos a la siguiente`)
if (currentIndex < vocales.length - 1) {
setCurrentIndex((i) => i + 1)
setStep("gesto")
setPromptState(getInitialState())
} else {
setSessionComplete(true)
setRunning(false)
}
} else {
setStep("elegir")
}
}, [promptState, currentIndex])
const handleChoice = useCallback(
(correct: boolean) => {
const newState = evaluateResponse(promptState, correct)
setPromptState(newState)
if (correct) {
setScore((s) => s + 1)
}
if (isMastered(newState.level)) {
if (currentIndex < vocales.length - 1) {
setCurrentIndex((i) => i + 1)
setStep("gesto")
setPromptState(getInitialState())
} else {
setSessionComplete(true)
setRunning(false)
}
}
},
[promptState, currentIndex],
)
if (sessionComplete) {
return (
<div className="flex-1 flex flex-col items-center justify-center p-8 gap-6">
<GatoMascota mood="celebrate" message="¡Terminaste todas las vocales!" size="lg" />
<p className="text-lg font-display">Puntuación: {score}/{vocales.length * 2}</p>
<button
onClick={() => {
setCurrentIndex(0)
setStep("gesto")
setPromptState(getInitialState())
setSessionComplete(false)
setRunning(true)
setScore(0)
}}
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)
setSessionComplete(true)
}}
running={running}
/>
<GatoMascota
mood={step === "gesto" ? "thinking" : step === "completo" ? "celebrate" : "coach"}
message={
step === "gesto"
? `Hacé así: ${vocal.gesto}`
: step === "escuchar"
? "Escuchá bien"
: step === "trazar"
? "Traza con tu dedo"
: ""
}
size="sm"
/>
<AnimatePresence mode="wait">
<motion.div
key={`${vocal.letra}-${step}`}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.3 }}
>
{step === "gesto" && (
<div className="flex flex-col items-center gap-4">
<span className="text-8xl">{vocal.imageEmoji}</span>
<p className="text-lg text-foreground/60 italic max-w-xs text-center">
{vocal.gesto}
</p>
<button
onClick={handleGestoComplete}
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>
)}
{step === "escuchar" && (
<div className="flex flex-col items-center gap-6">
<VocalCard
letra={vocal.letra}
fonema={vocal.fonema}
palabra={vocal.palabra}
imageEmoji={vocal.imageEmoji}
color={vocal.color}
onClick={() => {
speak(`${vocal.fonema}... de ${vocal.palabra}`)
}}
/>
<button
onClick={handleEscucharComplete}
className="bg-secondary text-secondary-foreground touch-target-lg rounded-2xl px-8 py-4 text-xl font-display font-bold"
>
Siguiente
</button>
</div>
)}
{step === "trazar" && (
<div className="flex flex-col items-center gap-4">
<TrazoLetra
letra={vocal.letra}
fonema={vocal.fonema}
color={vocal.color}
onComplete={handleTrazoComplete}
promptLevel={promptState.level}
/>
</div>
)}
{step === "elegir" && (
<div className="flex flex-col items-center gap-6">
<p className="text-lg font-display">¿Cuál es la {vocal.letra.toUpperCase()}?</p>
<div className="flex flex-wrap gap-4 justify-center">
{[...vocales].sort(() => Math.random() - 0.5).slice(0, 3).map((v) => (
<button
key={v.letra}
onClick={() => handleChoice(v.letra === vocal.letra)}
className={`touch-target-lg rounded-2xl text-2xl font-display font-bold px-8 py-6 shadow-md active:scale-95 transition-transform`}
style={{ backgroundColor: v.color }}
>
{v.letra.toUpperCase()}
</button>
))}
</div>
</div>
)}
</motion.div>
</AnimatePresence>
</div>
)
}
+45
View File
@@ -0,0 +1,45 @@
"use client"
import { motion } from "framer-motion"
import { GatoMascota } from "@/components/child/gato-mascota"
const achievements = [
{ id: "a", label: "Letra A", emoji: "✈️", completed: true },
{ id: "e", label: "Letra E", emoji: "🐘", completed: false },
{ id: "i", label: "Letra I", emoji: "🏔️", completed: false },
{ id: "o", label: "Letra O", emoji: "🐻", completed: false },
{ id: "u", label: "Letra U", emoji: "🍇", completed: false },
{ id: "1", label: "Número 1", emoji: "1️⃣", completed: false },
{ id: "2", label: "Número 2", emoji: "2️⃣", completed: false },
]
export default function LogrosPage() {
const completedCount = achievements.filter((a) => a.completed).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 {achievements.length} completados
</p>
<div className="flex flex-wrap gap-3 justify-center max-w-sm">
{achievements.map((a, i) => (
<motion.div
key={a.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 ${
a.completed ? "bg-primary/20 border-2 border-primary" : "bg-muted opacity-50"
}`}
>
<span className="text-3xl">{a.emoji}</span>
<span className="text-xs text-center font-display font-medium">{a.label}</span>
</motion.div>
))}
</div>
</div>
)
}
+217
View File
@@ -0,0 +1,217 @@
"use client"
import { useState, useCallback } from "react"
import { motion, AnimatePresence } from "framer-motion"
import { GatoMascota } from "@/components/child/gato-mascota"
import { CuisenaireRod, rodColors } from "@/components/child/cuisenaire-rod"
import TemporizadorVisual from "@/components/child/temporizador-visual"
import { speak } from "@/lib/speech"
import { getRods } from "@/lib/pedagogia/cuisenaire"
import { createCPAState, advanceStage } from "@/lib/pedagogia/cpa-progression"
import { generateCPAItems, speakCPAInstruction } from "@/components/child/cpa-exercise"
type CPAStage = "concrete" | "pictoric" | "abstract"
export default function NumerosPage() {
const [currentValue, setCurrentValue] = useState(1)
const [stage, setStage] = useState<CPAStage>("concrete")
const [running, setRunning] = useState(true)
const [sessionComplete, setSessionComplete] = useState(false)
const [score, setScore] = useState(0)
const [tappedItems, setTappedItems] = useState<number[]>([])
const rods = getRods(10)
const cpaItems = generateCPAItems(currentValue, stage)
const startValue = useCallback(
(value: number) => {
setCurrentValue(value)
const newStage = value <= 3 ? "concrete" : value <= 6 ? "pictoric" : "abstract"
setStage(newStage)
setTappedItems([])
speakCPAInstruction(value, newStage)
},
[],
)
const handleItemTap = useCallback(
(id: number) => {
setTappedItems((prev) => {
const next = [...prev, id]
if (next.length === currentValue) {
setScore((s) => s + 1)
if (currentValue >= 5) {
setSessionComplete(true)
setRunning(false)
} else {
const nextValue = currentValue + 1
setCurrentValue(nextValue)
const newStage: CPAStage = nextValue <= 3 ? "concrete" : nextValue <= 6 ? "pictoric" : "abstract"
setStage(newStage)
setTappedItems([])
setTimeout(() => speakCPAInstruction(nextValue, newStage), 500)
}
}
return next
})
},
[currentValue],
)
if (sessionComplete) {
return (
<div className="flex-1 flex flex-col items-center justify-center p-8 gap-6">
<GatoMascota mood="celebrate" message="¡Muy bien con los números!" size="lg" />
<p className="text-lg font-display">Puntuación: {score}/5</p>
<button
onClick={() => {
setCurrentValue(1)
setStage("concrete")
setScore(0)
setTappedItems([])
setSessionComplete(false)
}}
className="bg-accent text-accent-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)
setSessionComplete(true)
}}
running={running}
/>
<GatoMascota
mood="coach"
message={
stage === "concrete"
? `Tocá ${currentValue} cosas`
: stage === "pictoric"
? "¿Cuántos hay?"
: "Señalá el número"
}
size="sm"
/>
<AnimatePresence mode="wait">
<motion.div
key={`${currentValue}-${stage}`}
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
className="flex flex-col items-center gap-4"
>
{/* Level indicator */}
<div className="flex gap-3 items-center">
{[1, 2, 3, 4, 5].map((n) => (
<button
key={n}
onClick={() => startValue(n)}
className={`w-12 h-12 rounded-full text-lg font-display font-bold ${
n <= currentValue ? "bg-primary text-white" : "bg-muted text-muted-foreground"
}`}
>
{n}
</button>
))}
</div>
{/* CPA area */}
<div className="w-full max-w-sm min-h-[200px] flex flex-wrap items-center justify-center gap-4 p-4 bg-white rounded-3xl shadow-md">
{stage === "concrete" &&
cpaItems.map((item) => (
<motion.button
key={item.id}
onClick={() => handleItemTap(item.id)}
className="text-4xl touch-target active:scale-110 transition-transform"
whileTap={{ scale: 1.2 }}
animate={
tappedItems.includes(item.id)
? { scale: 0.8, opacity: 0.5 }
: { scale: 1, opacity: 1 }
}
>
{item.emoji}
</motion.button>
))}
{stage === "pictoric" && (
<div className="flex flex-col items-center gap-2">
<div className="flex flex-wrap justify-center gap-2">
{cpaItems.map((item, i) => (
<motion.span
key={item.id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.1 }}
className="text-3xl"
>
{item.emoji}
</motion.span>
))}
</div>
<div className="flex gap-3 mt-4">
{[1, 2, 3, 4, 5].map((n) => (
<button
key={n}
onClick={() => {
if (n === currentValue) {
handleItemTap(n)
}
}}
className="w-14 h-14 rounded-2xl bg-muted text-2xl font-display font-bold active:scale-90 transition-transform"
>
{n}
</button>
))}
</div>
</div>
)}
{stage === "abstract" && (
<div className="flex flex-col items-center gap-4">
<span className="text-6xl font-display font-bold">{currentValue}</span>
<p className="text-muted-foreground">Tocá el número que ves arriba</p>
<div className="flex flex-wrap gap-3 justify-center">
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((n) => (
<button
key={n}
onClick={() => {
if (n === currentValue) {
handleItemTap(n)
}
}}
className="w-16 h-16 rounded-2xl bg-muted text-2xl font-display font-bold active:scale-90 transition-transform"
>
{n}
</button>
))}
</div>
</div>
)}
</div>
{/* Cuisenaire reference */}
<div className="flex flex-wrap justify-center gap-1 mt-2">
{rods.slice(0, currentValue).map((rod) => (
<CuisenaireRod
key={rod.value}
value={rod.value}
color={rod.color}
length={rod.lengthPx}
/>
))}
</div>
</motion.div>
</AnimatePresence>
</div>
)
}
+33
View File
@@ -0,0 +1,33 @@
"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" },
]
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>
)
}
+52
View File
@@ -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;
}
+33
View File
@@ -0,0 +1,33 @@
import type { Metadata } 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",
viewport: "width=device-width, initial-scale=1, maximum-scale=1, viewport-fit=cover",
appleWebApp: {
capable: true,
statusBarStyle: "default",
title: "EduEasy",
},
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="es" className={inter.variable}>
<head>
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<link rel="apple-touch-icon" href="/icon.png" />
</head>
<body className="dvh-screen overflow-x-hidden">{children}</body>
</html>
)
}
+19
View File
@@ -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
}
+72
View File
@@ -0,0 +1,72 @@
"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>
</form>
</div>
)
}
+91
View File
@@ -0,0 +1,91 @@
"use client"
import { useState } from "react"
import { authClient } from "@/lib/auth-client"
import { useRouter } from "next/navigation"
import { GatoMascota } from "@/components/child/gato-mascota"
export default function PairingPage() {
const [code, setCode] = useState("")
const [loading, setLoading] = useState(false)
const [error, setError] = useState("")
const [paired, setPaired] = useState(false)
const { data: session } = authClient.useSession()
const router = useRouter()
const handlePairing = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
setError("")
const res = await fetch("/api/pairing", {
method: "POST",
body: JSON.stringify({ pairingCode: code, deviceFingerprint: navigator.userAgent }),
})
const data = await res.json()
if (data.error) {
setError(data.error)
setLoading(false)
return
}
setPaired(true)
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>
<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"
required
/>
{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>
)
}
+36
View File
@@ -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>
)
}
+70
View File
@@ -0,0 +1,70 @@
"use client"
import { useRouter } from "next/navigation"
import { authClient } from "@/lib/auth-client"
export default function ParentDashboard() {
const { data: session } = authClient.useSession()
const router = useRouter()
if (!session) {
router.push("/parent/auth/login")
return null
}
return (
<div className="min-h-dvh p-6">
<header className="flex items-center justify-between mb-8">
<div>
<h1 className="text-2xl font-display font-bold">EduEasy</h1>
<p className="text-muted-foreground text-sm">
Bienvenida, {session.user?.name || "Mamá"}
</p>
</div>
<button
onClick={() => authClient.signOut()}
className="text-sm text-muted-foreground underline"
>
Cerrar sesión
</button>
</header>
<div className="grid grid-cols-2 gap-4 max-w-lg">
<div className="bg-white rounded-2xl p-4 shadow-sm col-span-2">
<p className="text-sm text-muted-foreground">Racha actual</p>
<p className="text-3xl font-display font-bold text-primary"> días</p>
</div>
<div className="bg-white rounded-2xl p-4 shadow-sm">
<p className="text-sm text-muted-foreground">Hoy</p>
<p className="text-2xl font-display font-bold"> min</p>
</div>
<div className="bg-white rounded-2xl p-4 shadow-sm">
<p className="text-sm text-muted-foreground">Logros</p>
<p className="text-2xl font-display font-bold"> %</p>
</div>
<div className="bg-white rounded-2xl p-4 shadow-sm col-span-2">
<p className="text-sm text-muted-foreground mb-2">Última sesión</p>
<p className="text-muted-foreground italic">No hay sesiones todavía</p>
</div>
</div>
<div className="flex flex-col gap-3 mt-6 max-w-lg">
<button
onClick={() => router.push("/parent/auth/pairing")}
className="bg-primary text-primary-foreground touch-target rounded-xl font-display font-semibold"
>
Emparejar iPad
</button>
<button
onClick={() => router.push("/parent/configuracion")}
className="bg-muted text-foreground touch-target rounded-xl font-display"
>
Configuración
</button>
</div>
</div>
)
}
+11
View File
@@ -0,0 +1,11 @@
export default function ParentLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<div className="min-h-dvh bg-background">
{children}
</div>
)
}
+21
View File
@@ -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
}
+20
View File
@@ -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"
}
}
+43
View File
@@ -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>
)
}
+33
View File
@@ -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"
}
+57
View File
@@ -0,0 +1,57 @@
"use client"
import { motion } from "framer-motion"
export interface CuisenaireRodProps {
value: number
color: string
length: number // in vw or px
draggable?: boolean
onDrop?: (value: number) => void
}
const rodColors: Record<number, string> = {
1: "#FFFFFF", // blanco
2: "#E74C3C", // rojo
3: "#2ECC71", // verde claro
4: "#E91E63", // rosa
5: "#F1C40F", // amarillo
6: "#1ABC9C", // verde oscuro
7: "#D35400", // naranja
8: "#3498DB", // azul
9: "#9B59B6", // púrpura
10: "#FF5733", // naranja
}
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) {
return (
<motion.div
className="rounded-lg border border-border/50 shadow-sm touch-target cursor-grab active:cursor-grabbing"
style={{
width: `${length}px`,
height: "40px",
backgroundColor: color,
borderColor: value === 1 ? "#ddd" : undefined,
}}
whileTap={{ scale: 1.05 }}
drag={draggable}
dragElastic={0.2}
dragConstraints={{ left: -200, right: 200, top: -200, bottom: 200 }}
/>
)
}
+56
View File
@@ -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
+74
View File
@@ -0,0 +1,74 @@
"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)
useEffect(() => {
if (running) {
intervalRef.current = setInterval(() => {
setRemaining((prev) => {
if (prev <= 1) {
clearInterval(intervalRef.current!)
onComplete()
return 0
}
return prev - 1
})
}, 1000)
} else {
if (intervalRef.current) clearInterval(intervalRef.current)
}
return () => {
if (intervalRef.current) clearInterval(intervalRef.current)
}
}, [running, onComplete])
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>
)
}
+125
View File
@@ -0,0 +1,125 @@
"use client"
import { useState, useRef, useCallback, useEffect } from "react"
import { getStroke } from "perfect-freehand"
import { speak } from "@/lib/speech"
interface TrazoLetraProps {
letra: string
fonema: string
color?: string
onComplete?: () => void
promptLevel?: number
}
export default function TrazoLetra({
letra,
fonema,
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) {
// draw direction arrow if early prompt level
}
}
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}
/>
)
}
+43
View File
@@ -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>
)
}
+45
View File
@@ -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: "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:
+40
View File
@@ -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
}
```
+9
View File
@@ -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
+19
View File
@@ -0,0 +1,19 @@
import { PrismaClient } from "@prisma/client"
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",
},
})
+9
View File
@@ -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
+67
View File
@@ -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 || ""
}
+37
View File
@@ -0,0 +1,37 @@
export type CPAStage = "concrete" | "pictoric" | "abstract"
export interface CPAState {
stage: CPAStage
currentValue: number
attempts: number
mastered: boolean
}
/**
* CPA (Concrete-Pictórico-Abstracto) progression engine.
*
* - Concrete: show objects (animated) for the child to count with finger.
* - Pictoric: show images of objects, then hide them progressively.
* - Abstract: show only the number symbol → child says/selects the quantity.
*/
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,
}
}
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] }
}
return { ...state, mastered: true }
}
+47
View File
@@ -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: "#D35400",
8: "#3498DB",
9: "#9B59B6",
10: "#FF5733",
}
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"
}
+63
View File
@@ -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
}
+53
View File
@@ -0,0 +1,53 @@
"use client"
let speechSynth: SpeechSynthesis | null = null
let voices: SpeechSynthesisVoice[] = []
export function initSpeech(): void {
if (typeof window === "undefined") return
speechSynth = window.speechSynthesis
const loadVoices = () => {
voices = speechSynth!.getVoices().filter(
(v) => v.lang.startsWith("es") && v.lang.includes("ES") || v.lang.includes("MX")
)
}
loadVoices()
speechSynth.onvoiceschanged = loadVoices
}
export function speak(text: string, rate = 0.85): Promise<void> {
return new Promise((resolve) => {
if (!speechSynth) {
initSpeech()
}
if (!speechSynth || typeof window === "undefined") {
resolve()
return
}
window.speechSynthesis.cancel()
const utterance = new SpeechSynthesisUtterance(text)
utterance.lang = "es-ES"
utterance.rate = rate
utterance.pitch = 1.1
const spanishVoice = voices.length > 0 ? voices[0] : null
if (spanishVoice) {
utterance.voice = spanishVoice
}
utterance.onend = () => resolve()
utterance.onerror = () => resolve()
speechSynth.speak(utterance)
})
}
export function stopSpeaking(): void {
if (typeof window !== "undefined") {
window.speechSynthesis.cancel()
}
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+38
View File
@@ -0,0 +1,38 @@
import { NextResponse } from "next/server"
import type { NextRequest } from "next/server"
const CHILD_HOST = "mate"
const PARENT_HOST = "simulacro"
export function middleware(request: NextRequest) {
const hostname = request.headers.get("host") || ""
const { pathname } = request.nextUrl
if (pathname.startsWith("/api") || pathname.startsWith("/_next") || pathname.startsWith("/static") || pathname === "/favicon.ico") {
return NextResponse.next()
}
if (hostname.includes(CHILD_HOST)) {
if (pathname === "/" || pathname.startsWith("/child")) {
return NextResponse.next()
}
const url = request.nextUrl.clone()
url.pathname = `/child${pathname === "/" ? "" : pathname}`
return NextResponse.rewrite(url)
}
if (hostname.includes(PARENT_HOST)) {
if (pathname === "/" || pathname.startsWith("/parent")) {
return NextResponse.next()
}
const url = request.nextUrl.clone()
url.pathname = `/parent${pathname === "/" ? "" : pathname}`
return NextResponse.rewrite(url)
}
return NextResponse.next()
}
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
}
+8
View File
@@ -0,0 +1,8 @@
import type { NextConfig } from "next"
const nextConfig: NextConfig = {
output: "standalone",
images: { unoptimized: true },
}
export default nextConfig
+7267
View File
File diff suppressed because it is too large Load Diff
+45
View File
@@ -0,0 +1,45 @@
{
"name": "edueasy",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"db:generate": "prisma generate",
"db:push": "prisma db push",
"db:migrate": "prisma migrate dev",
"db:studio": "prisma studio"
},
"dependencies": {
"@prisma/client": "^5.20.0",
"@radix-ui/react-slot": "^1.1.0",
"@use-gesture/react": "^10.3.0",
"better-auth": "^1.0.0",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"framer-motion": "^11.0.0",
"lucide-react": "^0.400.0",
"next": "^15.0.0",
"perfect-freehand": "^1.2.0",
"prisma": "^5.20.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"recharts": "^2.12.0",
"tailwind-merge": "^2.5.0",
"tailwindcss-animate": "^1.0.7",
"ts-fsrs": "^4.0.0"
},
"devDependencies": {
"@types/node": "^20.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"autoprefixer": "^10.5.4",
"eslint": "^8.57.0",
"eslint-config-next": "^15.0.0",
"postcss": "^8.4.0",
"tailwindcss": "^3.4.0",
"typescript": "^5.6.0"
}
}
+6
View File
@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+152
View File
@@ -0,0 +1,152 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model Parent {
id String @id @default(cuid())
email String @unique
name String?
emailVerified Boolean @default(false)
image String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
familyId String
family Family @relation(fields: [familyId], references: [id])
sessions Session[]
accounts Account[]
}
model Session {
id String @id @default(cuid())
email String?
userId String?
parentId String?
parent Parent? @relation(fields: [parentId], references: [id])
token String @unique
expiresAt DateTime
ipAddress String?
userAgent String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Account {
id String @id @default(cuid())
accountId String
providerId String
parentId String
parent Parent @relation(fields: [parentId], references: [id])
accessToken String?
refreshToken String?
idToken String?
accessTokenExpiresAt DateTime?
scope String?
password String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Verification {
id String @id @default(cuid())
identifier String
value String
expiresAt DateTime
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Family {
id String @id @default(cuid())
name String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
parents Parent[]
children Child[]
}
model Child {
id String @id @default(cuid())
name String?
avatar String?
birthdate DateTime?
profileNotes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
familyId String
family Family @relation(fields: [familyId], references: [id])
devices Device[]
sessions SessionLog[]
fsrsCards FsrsCard[]
milestones Milestone[]
}
model Device {
id String @id @default(cuid())
deviceFingerprint String
pairedAt DateTime @default(now())
lastSeen DateTime @updatedAt
role String @default("child")
createdAt DateTime @default(now())
childId String
child Child @relation(fields: [childId], references: [id])
}
model SessionLog {
id String @id @default(cuid())
startedAt DateTime @default(now())
endedAt DateTime?
durationSec Int?
skillsPracticed String?
childId String
child Child @relation(fields: [childId], references: [id])
skillAttempts SkillAttempt[]
}
model SkillAttempt {
id String @id @default(cuid())
skillCode String
promptLevel Int @default(0)
correct Boolean
responseMs Int?
createdAt DateTime @default(now())
sessionLogId String
sessionLog SessionLog @relation(fields: [sessionLogId], references: [id])
}
model FsrsCard {
id String @id @default(cuid())
skillCode String
dueAt DateTime @default(now())
stability Float @default(0)
difficulty Float @default(0)
reps Int @default(0)
lapses Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
childId String
child Child @relation(fields: [childId], references: [id])
}
model Milestone {
id String @id @default(cuid())
skillCode String
reachedAt DateTime @default(now())
childId String
child Child @relation(fields: [childId], references: [id])
}
+70
View File
@@ -0,0 +1,70 @@
import type { Config } from "tailwindcss"
const config: Config = {
darkMode: ["class"],
content: [
"./app/**/*.{ts,tsx}",
"./components/**/*.{ts,tsx}",
],
theme: {
extend: {
colors: {
background: "#FFF8F0",
foreground: "#2D3436",
primary: {
DEFAULT: "#8CB8A0",
foreground: "#FFFFFF",
},
secondary: {
DEFAULT: "#6B8FA3",
foreground: "#FFFFFF",
},
accent: {
DEFAULT: "#D4A574",
foreground: "#FFFFFF",
},
success: "#7FB685",
muted: "#E8E0D8",
card: "#FFFFFF",
border: "#E8E0D8",
ring: "#8CB8A0",
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
fontFamily: {
display: ["'Fredoka One'", "Fredoka", "sans-serif"],
sans: ["Inter", "system-ui", "sans-serif"],
},
keyframes: {
"fade-in": {
from: { opacity: "0" },
to: { opacity: "1" },
},
"slide-up": {
from: { transform: "translateY(10px)", opacity: "0" },
to: { transform: "translateY(0)", opacity: "1" },
},
"bounce-soft": {
"0%, 100%": { transform: "translateY(0)" },
"50%": { transform: "translateY(-5px)" },
},
"pulse-soft": {
"0%, 100%": { opacity: "1" },
"50%": { opacity: "0.7" },
},
},
animation: {
"fade-in": "fade-in 0.4s ease-out",
"slide-up": "slide-up 0.4s ease-out",
"bounce-soft": "bounce-soft 2s ease-in-out infinite",
"pulse-soft": "pulse-soft 2s ease-in-out infinite",
},
},
},
plugins: [require("tailwindcss-animate")],
}
export default config
+40
View File
@@ -0,0 +1,40 @@
{
"compilerOptions": {
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./*"
]
},
"target": "ES2017"
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}