feat: Puente Mayúscula-Minúscula + TTS SpeechSynthesis + E2E fixes

This commit is contained in:
renato97
2026-07-21 15:31:46 -03:00
commit fa8e0c58ce
120 changed files with 16013 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
+31
View File
@@ -0,0 +1,31 @@
import { betterAuth } from "better-auth"
import { prismaAdapter } from "better-auth/adapters/prisma"
import { prisma } from "@/lib/db"
export const auth = betterAuth({
database: prismaAdapter(prisma, {
provider: "postgresql",
}),
emailAndPassword: {
enabled: true,
},
user: {
modelName: "Parent",
},
session: {
modelName: "Session",
},
databaseHooks: {
user: {
create: {
after: async (user) => {
const family = await prisma.family.create({ data: { name: "" } })
await prisma.parent.update({
where: { id: user.id },
data: { familyId: family.id },
})
},
},
},
},
})
+124
View File
@@ -0,0 +1,124 @@
import { prisma } from "@/lib/db"
import { initCurriculum, getLecturaStages, getNumerosStages, getAllLecturaExercises } from "@/curriculum/index"
import type { Exercise } from "@/curriculum/types"
const STAGE_THRESHOLD = 0.8
async function ensureInitialStage(childId: string, topic: string) {
try {
const exists = await prisma.curriculumProgress.findFirst({
where: { childId, topic },
})
if (!exists) {
await prisma.curriculumProgress.create({
data: { childId, topic, stage: 1 },
})
}
} catch {
// Child may not exist yet in FK constraint — proceed gracefully
}
}
export async function getCurrentStage(childId: string, topic: string) {
await ensureInitialStage(childId, topic)
const progress = await prisma.curriculumProgress.findFirst({
where: { childId, topic },
orderBy: { stage: "asc" },
})
return progress
}
export async function getNextExercise(
childId: string,
topic: "lectura" | "numeros",
): Promise<Exercise | null> {
await initCurriculum()
await ensureInitialStage(childId, topic)
const progress = await prisma.curriculumProgress.findFirst({
where: { childId, topic, completedAt: null },
orderBy: { stage: "asc" },
})
if (!progress) return null
const stages = topic === "lectura" ? getLecturaStages() : getNumerosStages()
const stage = stages[progress.stage]
if (!stage || !stage.exercises.length) return null
const allAttemps = await prisma.skillAttempt.findMany({
where: { exerciseId: { not: null }, sessionLog: { childId } },
select: { exerciseId: true, correct: true },
})
const mastered = new Set(
allAttemps
.filter((a) => a.correct)
.map((a) => a.exerciseId),
)
const attempted = new Set(allAttemps.map((a) => a.exerciseId))
const available = stage.exercises.filter((ex) => {
if (mastered.has(ex.id)) return false
const prereqsMet = ex.prerequisites.every((p) => mastered.has(p))
return prereqsMet
})
if (available.length === 0) {
if (attempted.size > 0 && stage.exercises.length > 0) {
const masteryRatio = mastered.size / stage.exercises.length
if (masteryRatio >= STAGE_THRESHOLD) {
await advanceStage(childId, topic, progress.stage)
return getNextExercise(childId, topic)
}
}
return null
}
const fsrsCards = await prisma.fsrsCard.findMany({
where: { childId, skillCode: { in: available.map((e) => e.skillCode) } },
})
const dueMap = new Map(fsrsCards.map((c) => [c.skillCode, c.dueAt.getTime()]))
const now = Date.now()
const unsorted = [...available]
unsorted.sort((a, b) => {
const dueA = dueMap.get(a.skillCode) ?? 0
const dueB = dueMap.get(b.skillCode) ?? 0
const isDueA = dueA <= now ? 0 : 1
const isDueB = dueB <= now ? 0 : 1
if (isDueA !== isDueB) return isDueA - isDueB
if (!attempted.has(a.id) && !attempted.has(b.id)) return 0
if (!attempted.has(a.id)) return -1
if (!attempted.has(b.id)) return 1
return dueA - dueB
})
return unsorted[0] || null
}
async function advanceStage(childId: string, topic: string, currentStage: number) {
await prisma.curriculumProgress.updateMany({
where: { childId, topic, stage: currentStage },
data: { completedAt: new Date() },
})
const nextStage = currentStage + 1
const exists = await prisma.curriculumProgress.findUnique({
where: { childId_topic_stage: { childId, topic, stage: nextStage } },
})
if (!exists) {
await prisma.curriculumProgress.create({
data: { childId, topic, stage: nextStage },
})
}
}
export function getErrorTypeForExercise(
exercise: Exercise,
correct: boolean,
): string | null {
if (correct) return null
return exercise.errorType || "discriminacion-auditiva"
}
+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
+50
View File
@@ -0,0 +1,50 @@
import { fsrs as createFSRS, createEmptyCard, Rating, type Card, type Grade } from "ts-fsrs"
const scheduler = createFSRS({ maximum_interval: 365, request_retention: 0.9 })
export type FsrsGrade = "again" | "hard" | "good" | "easy"
const gradeMap: Record<FsrsGrade, Grade> = {
again: Rating.Again,
hard: Rating.Hard,
good: Rating.Good,
easy: Rating.Easy,
}
export function buildFsrsCard(params: {
stability?: number
difficulty?: number
reps?: number
lapses?: number
due?: Date
}): Card {
const card = createEmptyCard(params.due ?? new Date())
return {
...card,
stability: params.stability ?? card.stability,
difficulty: params.difficulty ?? card.difficulty,
reps: params.reps ?? 0,
lapses: params.lapses ?? 0,
due: params.due ?? card.due,
}
}
export function gradeCard(card: Card, grade: FsrsGrade, now: Date = new Date()) {
const result = scheduler.next(card, now, gradeMap[grade])
return {
card: result.card,
log: result.log,
}
}
export function getRetrievability(card: Card, now: Date = new Date()): number {
const r = scheduler.get_retrievability(card, now, false)
return typeof r === "number" ? r : parseFloat(r)
}
export function skillCodeFromGrade(correct: boolean, responseMs: number): FsrsGrade {
if (!correct) return "again"
if (responseMs > 8000) return "hard"
if (responseMs > 4000) return "good"
return "easy"
}
+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 || ""
}
+56
View File
@@ -0,0 +1,56 @@
export type CPAStage = "concrete" | "pictoric" | "abstract"
export type GalperinStage = "hand" | "external_speech" | "inner_speech" | "mental" | "mastered"
export interface CPAState {
stage: CPAStage
currentValue: number
attempts: number
mastered: boolean
galperinStage: GalperinStage
}
export function getCPAProgression(skillValue: number): CPAStage[] {
return ["concrete", "pictoric", "abstract"]
}
export function createCPAState(value: number): CPAState {
return {
stage: "concrete",
currentValue: value,
attempts: 0,
mastered: false,
galperinStage: "hand",
}
}
export function advanceStage(state: CPAState): CPAState {
const stages: CPAStage[] = ["concrete", "pictoric", "abstract"]
const currentIdx = stages.indexOf(state.stage)
if (currentIdx < stages.length - 1) {
return { ...state, stage: stages[currentIdx + 1], galperinStage: "hand" }
}
return { ...state, mastered: true }
}
export function advanceGalperin(stage: GalperinStage): GalperinStage {
const order: GalperinStage[] = ["hand", "external_speech", "inner_speech", "mental", "mastered"]
const idx = order.indexOf(stage)
if (idx < order.length - 1) return order[idx + 1]
return "mastered"
}
export function getGalperinInstruction(stage: GalperinStage, value: number): string {
switch (stage) {
case "hand":
return `Tocá ${value} cosas con tu dedo`
case "external_speech":
return "Contá en voz alta mientras tocás"
case "inner_speech":
return "Ahora contá susurrando"
case "mental":
return "Pensá el número en tu cabeza y señalalo"
case "mastered":
return "¡Ya sabés este número!"
}
}
+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: "#2C3E50",
8: "#3498DB",
9: "#9B59B6",
10: "#E67E22",
}
const rodLengths: Record<number, number> = {
1: 24,
2: 44,
3: 64,
4: 84,
5: 104,
6: 124,
7: 144,
8: 164,
9: 184,
10: 204,
}
export function getRods(maxValue: number = 10): Rod[] {
return Array.from({ length: maxValue }, (_, i) => ({
value: i + 1,
color: rodColors[i + 1],
lengthPx: rodLengths[i + 1],
label: `${i + 1}`,
}))
}
export function compareRods(a: number, b: number): "mayor" | "menor" | "igual" {
if (a > b) return "mayor"
if (a < b) return "menor"
return "igual"
}
+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"
export interface SkillAttemptPayload {
skillCode: string
promptLevel: number
correct: boolean
responseMs: number
}
export interface SessionPayload {
childId: string
startedAt: string
endedAt: string
durationSec: number
skillsPracticed: string
attempts: SkillAttemptPayload[]
}
export async function syncSession(payload: SessionPayload): Promise<string | null> {
try {
const res = await fetch("/api/sessions/sync", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
childId: payload.childId,
session: {
startedAt: payload.startedAt,
endedAt: payload.endedAt,
durationSec: payload.durationSec,
skillsPracticed: payload.skillsPracticed,
},
attempts: payload.attempts,
}),
})
const data = await res.json()
return data.sessionId ?? null
} catch {
return null
}
}
export async function createMilestone(childId: string, skillCode: string): Promise<boolean> {
try {
const res = await fetch("/api/milestones", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ childId, skillCode }),
})
return res.ok
} catch {
return false
}
}
+29
View File
@@ -0,0 +1,29 @@
"use client"
let utterance: SpeechSynthesisUtterance | null = null
export async function speak(
text: string,
_rate = 0.85,
): Promise<void> {
return new Promise((resolve) => {
stopSpeaking()
const u = new SpeechSynthesisUtterance(text)
u.lang = "es-AR"
u.rate = _rate
u.onend = () => resolve()
u.onerror = () => resolve()
utterance = u
speechSynthesis.speak(u)
})
}
export function stopSpeaking(): void {
if (utterance) {
speechSynthesis.cancel()
utterance = null
}
}
+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))
}