feat: EduEasy checkpoint - plan, scaffolding, pedagogia modules, components child+parent, API routes, Prisma schema, Docker infra, Better Auth
This commit is contained in:
@@ -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
@@ -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",
|
||||
},
|
||||
})
|
||||
@@ -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
|
||||
@@ -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,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 }
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
Reference in New Issue
Block a user