feat: Puente Mayúscula-Minúscula + TTS SpeechSynthesis + E2E fixes
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
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
import { betterAuth } from "better-auth"
|
||||
import { prismaAdapter } from "better-auth/adapters/prisma"
|
||||
import { prisma } from "@/lib/db"
|
||||
|
||||
export const auth = betterAuth({
|
||||
database: prismaAdapter(prisma, {
|
||||
provider: "postgresql",
|
||||
}),
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
},
|
||||
user: {
|
||||
modelName: "Parent",
|
||||
},
|
||||
session: {
|
||||
modelName: "Session",
|
||||
},
|
||||
databaseHooks: {
|
||||
user: {
|
||||
create: {
|
||||
after: async (user) => {
|
||||
const family = await prisma.family.create({ data: { name: "" } })
|
||||
await prisma.parent.update({
|
||||
where: { id: user.id },
|
||||
data: { familyId: family.id },
|
||||
})
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,124 @@
|
||||
import { prisma } from "@/lib/db"
|
||||
import { initCurriculum, getLecturaStages, getNumerosStages, getAllLecturaExercises } from "@/curriculum/index"
|
||||
import type { Exercise } from "@/curriculum/types"
|
||||
|
||||
const STAGE_THRESHOLD = 0.8
|
||||
|
||||
async function ensureInitialStage(childId: string, topic: string) {
|
||||
try {
|
||||
const exists = await prisma.curriculumProgress.findFirst({
|
||||
where: { childId, topic },
|
||||
})
|
||||
if (!exists) {
|
||||
await prisma.curriculumProgress.create({
|
||||
data: { childId, topic, stage: 1 },
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
// Child may not exist yet in FK constraint — proceed gracefully
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCurrentStage(childId: string, topic: string) {
|
||||
await ensureInitialStage(childId, topic)
|
||||
const progress = await prisma.curriculumProgress.findFirst({
|
||||
where: { childId, topic },
|
||||
orderBy: { stage: "asc" },
|
||||
})
|
||||
return progress
|
||||
}
|
||||
|
||||
export async function getNextExercise(
|
||||
childId: string,
|
||||
topic: "lectura" | "numeros",
|
||||
): Promise<Exercise | null> {
|
||||
await initCurriculum()
|
||||
await ensureInitialStage(childId, topic)
|
||||
|
||||
const progress = await prisma.curriculumProgress.findFirst({
|
||||
where: { childId, topic, completedAt: null },
|
||||
orderBy: { stage: "asc" },
|
||||
})
|
||||
|
||||
if (!progress) return null
|
||||
|
||||
const stages = topic === "lectura" ? getLecturaStages() : getNumerosStages()
|
||||
const stage = stages[progress.stage]
|
||||
if (!stage || !stage.exercises.length) return null
|
||||
|
||||
const allAttemps = await prisma.skillAttempt.findMany({
|
||||
where: { exerciseId: { not: null }, sessionLog: { childId } },
|
||||
select: { exerciseId: true, correct: true },
|
||||
})
|
||||
|
||||
const mastered = new Set(
|
||||
allAttemps
|
||||
.filter((a) => a.correct)
|
||||
.map((a) => a.exerciseId),
|
||||
)
|
||||
|
||||
const attempted = new Set(allAttemps.map((a) => a.exerciseId))
|
||||
|
||||
const available = stage.exercises.filter((ex) => {
|
||||
if (mastered.has(ex.id)) return false
|
||||
const prereqsMet = ex.prerequisites.every((p) => mastered.has(p))
|
||||
return prereqsMet
|
||||
})
|
||||
|
||||
if (available.length === 0) {
|
||||
if (attempted.size > 0 && stage.exercises.length > 0) {
|
||||
const masteryRatio = mastered.size / stage.exercises.length
|
||||
if (masteryRatio >= STAGE_THRESHOLD) {
|
||||
await advanceStage(childId, topic, progress.stage)
|
||||
return getNextExercise(childId, topic)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const fsrsCards = await prisma.fsrsCard.findMany({
|
||||
where: { childId, skillCode: { in: available.map((e) => e.skillCode) } },
|
||||
})
|
||||
|
||||
const dueMap = new Map(fsrsCards.map((c) => [c.skillCode, c.dueAt.getTime()]))
|
||||
const now = Date.now()
|
||||
|
||||
const unsorted = [...available]
|
||||
unsorted.sort((a, b) => {
|
||||
const dueA = dueMap.get(a.skillCode) ?? 0
|
||||
const dueB = dueMap.get(b.skillCode) ?? 0
|
||||
const isDueA = dueA <= now ? 0 : 1
|
||||
const isDueB = dueB <= now ? 0 : 1
|
||||
if (isDueA !== isDueB) return isDueA - isDueB
|
||||
if (!attempted.has(a.id) && !attempted.has(b.id)) return 0
|
||||
if (!attempted.has(a.id)) return -1
|
||||
if (!attempted.has(b.id)) return 1
|
||||
return dueA - dueB
|
||||
})
|
||||
|
||||
return unsorted[0] || null
|
||||
}
|
||||
|
||||
async function advanceStage(childId: string, topic: string, currentStage: number) {
|
||||
await prisma.curriculumProgress.updateMany({
|
||||
where: { childId, topic, stage: currentStage },
|
||||
data: { completedAt: new Date() },
|
||||
})
|
||||
const nextStage = currentStage + 1
|
||||
const exists = await prisma.curriculumProgress.findUnique({
|
||||
where: { childId_topic_stage: { childId, topic, stage: nextStage } },
|
||||
})
|
||||
if (!exists) {
|
||||
await prisma.curriculumProgress.create({
|
||||
data: { childId, topic, stage: nextStage },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export function getErrorTypeForExercise(
|
||||
exercise: Exercise,
|
||||
correct: boolean,
|
||||
): string | null {
|
||||
if (correct) return null
|
||||
return exercise.errorType || "discriminacion-auditiva"
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { PrismaClient } from "@prisma/client"
|
||||
|
||||
const globalForPrisma = globalThis as unknown as {
|
||||
prisma: PrismaClient | undefined
|
||||
}
|
||||
|
||||
export const prisma = globalForPrisma.prisma ?? new PrismaClient()
|
||||
|
||||
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
import { fsrs as createFSRS, createEmptyCard, Rating, type Card, type Grade } from "ts-fsrs"
|
||||
|
||||
const scheduler = createFSRS({ maximum_interval: 365, request_retention: 0.9 })
|
||||
|
||||
export type FsrsGrade = "again" | "hard" | "good" | "easy"
|
||||
|
||||
const gradeMap: Record<FsrsGrade, Grade> = {
|
||||
again: Rating.Again,
|
||||
hard: Rating.Hard,
|
||||
good: Rating.Good,
|
||||
easy: Rating.Easy,
|
||||
}
|
||||
|
||||
export function buildFsrsCard(params: {
|
||||
stability?: number
|
||||
difficulty?: number
|
||||
reps?: number
|
||||
lapses?: number
|
||||
due?: Date
|
||||
}): Card {
|
||||
const card = createEmptyCard(params.due ?? new Date())
|
||||
return {
|
||||
...card,
|
||||
stability: params.stability ?? card.stability,
|
||||
difficulty: params.difficulty ?? card.difficulty,
|
||||
reps: params.reps ?? 0,
|
||||
lapses: params.lapses ?? 0,
|
||||
due: params.due ?? card.due,
|
||||
}
|
||||
}
|
||||
|
||||
export function gradeCard(card: Card, grade: FsrsGrade, now: Date = new Date()) {
|
||||
const result = scheduler.next(card, now, gradeMap[grade])
|
||||
return {
|
||||
card: result.card,
|
||||
log: result.log,
|
||||
}
|
||||
}
|
||||
|
||||
export function getRetrievability(card: Card, now: Date = new Date()): number {
|
||||
const r = scheduler.get_retrievability(card, now, false)
|
||||
return typeof r === "number" ? r : parseFloat(r)
|
||||
}
|
||||
|
||||
export function skillCodeFromGrade(correct: boolean, responseMs: number): FsrsGrade {
|
||||
if (!correct) return "again"
|
||||
if (responseMs > 8000) return "hard"
|
||||
if (responseMs > 4000) return "good"
|
||||
return "easy"
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
export interface Fonema {
|
||||
letra: string
|
||||
fonema: string
|
||||
palabra: string
|
||||
imageEmoji: string
|
||||
gesto: string // description of Borel-Maisonny gesture
|
||||
color: string
|
||||
orden: number
|
||||
}
|
||||
|
||||
// Vocales first (Jolly Phonics order adapted for Spanish)
|
||||
export const vocales: Fonema[] = [
|
||||
{
|
||||
letra: "a",
|
||||
fonema: "a",
|
||||
palabra: "avión",
|
||||
imageEmoji: "✈️",
|
||||
gesto: "Abrir la mano como si saludaras, con los dedos juntos.",
|
||||
color: "#E74C3C",
|
||||
orden: 1,
|
||||
},
|
||||
{
|
||||
letra: "e",
|
||||
fonema: "e",
|
||||
palabra: "elefante",
|
||||
imageEmoji: "🐘",
|
||||
gesto: "Señalar hacia la derecha con la mano abierta.",
|
||||
color: "#3498DB",
|
||||
orden: 2,
|
||||
},
|
||||
{
|
||||
letra: "i",
|
||||
fonema: "i",
|
||||
palabra: "iglú",
|
||||
imageEmoji: "🏔️",
|
||||
gesto: "Señalar hacia arriba con el dedo índice.",
|
||||
color: "#9B59B6",
|
||||
orden: 3,
|
||||
},
|
||||
{
|
||||
letra: "o",
|
||||
fonema: "o",
|
||||
palabra: "oso",
|
||||
imageEmoji: "🐻",
|
||||
gesto: "Hacer un círculo con la mano frente a la boca.",
|
||||
color: "#F39C12",
|
||||
orden: 4,
|
||||
},
|
||||
{
|
||||
letra: "u",
|
||||
fonema: "u",
|
||||
palabra: "uva",
|
||||
imageEmoji: "🍇",
|
||||
gesto: "Estirar los dos brazos hacia adelante como un tubo.",
|
||||
color: "#1ABC9C",
|
||||
orden: 5,
|
||||
},
|
||||
]
|
||||
|
||||
export function getFonema(letra: string): Fonema | undefined {
|
||||
return vocales.find((v) => v.letra === letra)
|
||||
}
|
||||
|
||||
export function getGestoDescription(letra: string): string {
|
||||
const f = getFonema(letra)
|
||||
return f?.gesto || ""
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
export type CPAStage = "concrete" | "pictoric" | "abstract"
|
||||
|
||||
export type GalperinStage = "hand" | "external_speech" | "inner_speech" | "mental" | "mastered"
|
||||
|
||||
export interface CPAState {
|
||||
stage: CPAStage
|
||||
currentValue: number
|
||||
attempts: number
|
||||
mastered: boolean
|
||||
galperinStage: GalperinStage
|
||||
}
|
||||
|
||||
export function getCPAProgression(skillValue: number): CPAStage[] {
|
||||
return ["concrete", "pictoric", "abstract"]
|
||||
}
|
||||
|
||||
export function createCPAState(value: number): CPAState {
|
||||
return {
|
||||
stage: "concrete",
|
||||
currentValue: value,
|
||||
attempts: 0,
|
||||
mastered: false,
|
||||
galperinStage: "hand",
|
||||
}
|
||||
}
|
||||
|
||||
export function advanceStage(state: CPAState): CPAState {
|
||||
const stages: CPAStage[] = ["concrete", "pictoric", "abstract"]
|
||||
const currentIdx = stages.indexOf(state.stage)
|
||||
if (currentIdx < stages.length - 1) {
|
||||
return { ...state, stage: stages[currentIdx + 1], galperinStage: "hand" }
|
||||
}
|
||||
return { ...state, mastered: true }
|
||||
}
|
||||
|
||||
export function advanceGalperin(stage: GalperinStage): GalperinStage {
|
||||
const order: GalperinStage[] = ["hand", "external_speech", "inner_speech", "mental", "mastered"]
|
||||
const idx = order.indexOf(stage)
|
||||
if (idx < order.length - 1) return order[idx + 1]
|
||||
return "mastered"
|
||||
}
|
||||
|
||||
export function getGalperinInstruction(stage: GalperinStage, value: number): string {
|
||||
switch (stage) {
|
||||
case "hand":
|
||||
return `Tocá ${value} cosas con tu dedo`
|
||||
case "external_speech":
|
||||
return "Contá en voz alta mientras tocás"
|
||||
case "inner_speech":
|
||||
return "Ahora contá susurrando"
|
||||
case "mental":
|
||||
return "Pensá el número en tu cabeza y señalalo"
|
||||
case "mastered":
|
||||
return "¡Ya sabés este número!"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
export interface Rod {
|
||||
value: number
|
||||
color: string
|
||||
lengthPx: number
|
||||
label: string
|
||||
}
|
||||
|
||||
export const rodColors: Record<number, string> = {
|
||||
1: "#FFFFFF",
|
||||
2: "#E74C3C",
|
||||
3: "#2ECC71",
|
||||
4: "#E91E63",
|
||||
5: "#F1C40F",
|
||||
6: "#1ABC9C",
|
||||
7: "#2C3E50",
|
||||
8: "#3498DB",
|
||||
9: "#9B59B6",
|
||||
10: "#E67E22",
|
||||
}
|
||||
|
||||
const rodLengths: Record<number, number> = {
|
||||
1: 24,
|
||||
2: 44,
|
||||
3: 64,
|
||||
4: 84,
|
||||
5: 104,
|
||||
6: 124,
|
||||
7: 144,
|
||||
8: 164,
|
||||
9: 184,
|
||||
10: 204,
|
||||
}
|
||||
|
||||
export function getRods(maxValue: number = 10): Rod[] {
|
||||
return Array.from({ length: maxValue }, (_, i) => ({
|
||||
value: i + 1,
|
||||
color: rodColors[i + 1],
|
||||
lengthPx: rodLengths[i + 1],
|
||||
label: `${i + 1}`,
|
||||
}))
|
||||
}
|
||||
|
||||
export function compareRods(a: number, b: number): "mayor" | "menor" | "igual" {
|
||||
if (a > b) return "mayor"
|
||||
if (a < b) return "menor"
|
||||
return "igual"
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
export type PromptLevel = 0 | 1 | 2 | 3 | 4
|
||||
|
||||
// Galperin stages mapped to prompt levels
|
||||
export const GALPERIN_STAGES = {
|
||||
HAND: 0, // Concrete: full model, show the answer
|
||||
EXTERNAL_SPEECH: 1, // Prompts the child to say out loud
|
||||
INNER_SPEECH: 2, // Partial prompt, whisper
|
||||
MENTAL: 3, // Verbal cue only
|
||||
MASTERED: 4, // No prompt
|
||||
} as const
|
||||
|
||||
export interface PromptState {
|
||||
level: PromptLevel
|
||||
consecutiveCorrect: number
|
||||
consecutiveIncorrect: number
|
||||
}
|
||||
|
||||
export function getInitialState(): PromptState {
|
||||
return {
|
||||
level: 0,
|
||||
consecutiveCorrect: 0,
|
||||
consecutiveIncorrect: 0,
|
||||
}
|
||||
}
|
||||
|
||||
export function evaluateResponse(
|
||||
state: PromptState,
|
||||
correct: boolean,
|
||||
masterThreshold: number = 3,
|
||||
regressThreshold: number = 2,
|
||||
): PromptState {
|
||||
if (correct) {
|
||||
const newCorrect = state.consecutiveCorrect + 1
|
||||
const newLevel =
|
||||
newCorrect >= masterThreshold
|
||||
? (Math.min(state.level + 1, 4) as PromptLevel)
|
||||
: state.level
|
||||
return {
|
||||
level: newLevel,
|
||||
consecutiveCorrect: newCorrect,
|
||||
consecutiveIncorrect: 0,
|
||||
}
|
||||
}
|
||||
|
||||
const newIncorrect = state.consecutiveIncorrect + 1
|
||||
const newLevel =
|
||||
newIncorrect >= regressThreshold
|
||||
? (Math.max(state.level - 1, 0) as PromptLevel)
|
||||
: state.level
|
||||
return {
|
||||
level: newLevel,
|
||||
consecutiveCorrect: 0,
|
||||
consecutiveIncorrect: newIncorrect,
|
||||
}
|
||||
}
|
||||
|
||||
export function shouldShowPrompt(level: PromptLevel): boolean {
|
||||
return level < 3
|
||||
}
|
||||
|
||||
export function isMastered(level: PromptLevel): boolean {
|
||||
return level >= 4
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
"use client"
|
||||
|
||||
export interface SkillAttemptPayload {
|
||||
skillCode: string
|
||||
promptLevel: number
|
||||
correct: boolean
|
||||
responseMs: number
|
||||
}
|
||||
|
||||
export interface SessionPayload {
|
||||
childId: string
|
||||
startedAt: string
|
||||
endedAt: string
|
||||
durationSec: number
|
||||
skillsPracticed: string
|
||||
attempts: SkillAttemptPayload[]
|
||||
}
|
||||
|
||||
export async function syncSession(payload: SessionPayload): Promise<string | null> {
|
||||
try {
|
||||
const res = await fetch("/api/sessions/sync", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
childId: payload.childId,
|
||||
session: {
|
||||
startedAt: payload.startedAt,
|
||||
endedAt: payload.endedAt,
|
||||
durationSec: payload.durationSec,
|
||||
skillsPracticed: payload.skillsPracticed,
|
||||
},
|
||||
attempts: payload.attempts,
|
||||
}),
|
||||
})
|
||||
const data = await res.json()
|
||||
return data.sessionId ?? null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function createMilestone(childId: string, skillCode: string): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch("/api/milestones", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ childId, skillCode }),
|
||||
})
|
||||
return res.ok
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
"use client"
|
||||
|
||||
let utterance: SpeechSynthesisUtterance | null = null
|
||||
|
||||
export async function speak(
|
||||
text: string,
|
||||
_rate = 0.85,
|
||||
): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
stopSpeaking()
|
||||
|
||||
const u = new SpeechSynthesisUtterance(text)
|
||||
u.lang = "es-AR"
|
||||
u.rate = _rate
|
||||
|
||||
u.onend = () => resolve()
|
||||
u.onerror = () => resolve()
|
||||
|
||||
utterance = u
|
||||
speechSynthesis.speak(u)
|
||||
})
|
||||
}
|
||||
|
||||
export function stopSpeaking(): void {
|
||||
if (utterance) {
|
||||
speechSynthesis.cancel()
|
||||
utterance = null
|
||||
}
|
||||
}
|
||||
@@ -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