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
+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))
}