Hub

personal

aprende-ingles

active low personal
Creado
2026-05-08
Actualizado
2026-05-29
Directorios
  • /home/sergio/code/aprende-ingles

Pendientes abiertos (5)

Ver todos →

🎯 Top de ataque (5)

  • #405 📅 2026-06-02 ⏱ 4-6h Fase 3 del AUDIT (#205): streak con holgura + dashboard /progreso del papá. Streak freeze (1-2 congeladores/semana) + celebración fuerte de los primeros 7 días + récord personal (NO liga/leaderboard). Pantalla /progreso: palabras dominadas vs en repaso, racha, minutos esta semana vs meta, nivel CEFR estimado (Pre-A1/A1, usando datasets CEFR/NGSL locales). Los datos ya están en BD; falta la pantalla que los agregue. Resuelve eje f (métricas). Detalle en AUDIT.md §8.
  • #406 📅 2026-06-03 ⏱ 3-4h Fase 4 del AUDIT (#205): repaso priorizado por error real + anclaje de vocabulario. Pasar al LessonPlannerService "qué domina / qué falló" estructurado y repasar prerrequisitos antes de subir dificultad (mastery learning estilo Khan: +2.7% corrección del siguiente ítem). Anclar el vocab nuevo a NGSL/NDL + filtro CEFR A1/A2 (datasets locales). Mejora el motor IA existente sin rehacerlo. Detalle en AUDIT.md §8.
  • #407 📅 2026-06-04 ⏱ 1-2d Fase 5 del AUDIT (#205, stretch): producción/conversación libre. Micro-historias i+1 (la IA genera párrafos de 3-5 frases, 95-98% vocab conocido + 1-2 nuevas, con imagen) → comprensión lectora real. Mini-roleplay con IA sobre lo recién estudiado (empezar por texto, luego voz con la eval de pronunciación ya existente) + tutor socrático en errores (pista que lleva a la respuesta, no la da). El gap diferenciador que casi ninguna app casera tiene; ya hay ladrillos (IA + voz). Detalle en AUDIT.md §8.
  • #206 📅 2026-05-31 Integración aprende-ingles ↔ tareas-hijo — marcar lección diaria automáticamente. Cuando el hijo complete una LessonCompletion en aprende-ingles (Postgres en val-soft), una tarea correspondiente en tareas-hijo (Postgres en Fly.io) debería marcarse hecha automáticamente. Mecanismo posible: webhook desde aprende-ingles → endpoint Phoenix en tareas-hijo. Requiere: (a) tareas-hijo tener un endpoint público autenticado para "marcar tarea hecha por slug" (NO existe hoy — Fase 1 #184 está en bloque 1/4); (b) aprende-ingles tener un job/queue que dispatch al completar lección; (c) modelo de mapeo: en tareas-hijo, una tareas_definicion con slug='leccion-ingles-diaria' se vincula. Bloqueado por #184 Fase 1 completa de tareas-hijo (que tenga su esquema LiveView + auth). Cuando se desbloquee, decidir si la integración es push (webhook) o pull (cron en tareas-hijo consulta aprende-ingles). Estimación: chica una vez desbloqueado (1 endpoint + 1 job).
  • 📅 2026-06-01 Después (no bloqueante): rate limit del lado app en generatePlan (padre puede tirar N llamadas a OpenAI con clicks repetidos) + retry/backoff en LessonPlannerService.

Actividad en bitácora 12 días

jun
jul
ago
sep
oct
nov
dic
ene
feb
mar
abr
may
L
X
V
Menos
Más

Aprende Inglés

Contexto

Proyecto personal de Sergio: plataforma para ayudar a aprender inglés a su hijo de 10 años. Pensada con UI premium amigable para niño (colores vibrantes, micro-animaciones), interfaz en español de México, mientras que el contenido enseña inglés.

Recién creado, aún no se ha testeado con el usuario real (el hijo).

Tareas pendientes

  • (2026-05-27) #202 OpenAI TTS-HD implementado y deployado — commit 1df6d6a pusheado a main. Voz default nova (configurable por OPENAI_TTS_VOICE en .env). Cache por sha1(text|voice) en storage/app/public/tts/. Backend: OpenAiTtsService + GenerateTtsForExerciseJob (async, idempotente, 2 retries) + comando aprende:generate-tts-backfill [--dry-run]. Migration add_audio_url_to_exercises_table. Frontend Student/Exercise.vue: playAudio() usa new Audio(url) si audio_url existe, fallback Web Speech API si null. Hook dispatch en los 2 bloques de Exercise::create de ParentController. Tests 8 nuevos. Suite 106/106 verde. Backfill aprende:generate-tts-backfill ya ejecutado en prod (Sergio 2026-05-27). Pendiente operativo: prueba con Leonardo si nova le late; si no, cambiar a OPENAI_TTS_VOICE=shimmer.
  • Resuelto 2026-05-28 [#203]: Decisión de tipos nuevos + tanda A (fill_blank) implementada. Presenté a Sergio 7 ideas semilla evaluadas por valor pedagógico × esfuerzo (vía AskUserQuestion). Decidió agregar A+B+C, arrancar por A. A = fill_blank (completa el hueco / cloze): opción múltiple para practicar GRAMÁTICA en contexto (conjugación, preposiciones, artículos) — oración EN con hueco ___ + 4 opciones EN. Reusa casi todo el infra de reading: submit cae en el path genérico (answer===correct_answer), validación igual (options≥2 + correct entre options) + chequeo de ___ en content. Sin migration (type es string sin enum). ApprovePlanRequest (enum + branch), LessonPlannerService (regla + ejemplo en prompt IA + instrucción de 1-2 por lección desde día 2), StudentController::applicableHintTypes (discard_option aplica), Exercise.vue (branch de render con oración partida en el hueco + computed fillBlankParts + chip “completar”). +8 tests (4 validación ApprovePlanValidationTest + 4 submit/hint FillBlankTest). Suite 122/122 (era 114). Commit b13243f, push a main, GHA 26607398895 deploy auto a val-soft. Pendiente operativo: Leonardo prueba un fill_blank cuando la IA genere planes nuevos (los planes ya existentes no tienen fill_blank). B y C quedan como #389/#390.
  • Resuelto 2026-05-28 [#389]: Tanda B de #203: tipo word_order (ordenar palabras). Decisión de UX: en vez de drag&drop literal implementé tap-to-build estilo Duolingo (mejor en touch — Leonardo usa Android): banco de palabras revueltas, toca una para mandarla a la línea de respuesta en orden, toca una en la respuesta para devolverla. Diseño: options = palabras de la oración EN EL ORDEN CORRECTO (el frontend baraja), content = oración completa de referencia, correct_answer = la oración (las palabras unidas en orden deben reproducirla con la misma normalización del submit). Sin migration. ApprovePlanRequest (enum + branch: options≥2 + implode(' ',options) normalizado === correct_answer normalizado). LessonPlannerService (regla + ejemplo + instrucción de 1-2 por lección desde día 2). Exercise.vue (refs wordBank/wordAnswer, watch(activeExercise, …, {immediate}) que baraja al entrar con Fisher-Yates + reintento si quedó en orden, pickWord/unpickWord, branch de render línea-respuesta + banco, submitAnswer une las palabras, chip “ordenar”). submitExercise SIN cambios (path genérico; WordMasteryService ignora la oración por tener espacios). NO tiene fichas de pista (no hay opción incorrecta que descartar). +7 tests (4 validación ApprovePlanValidationTest + 3 submit WordOrderTest). Suite 129/129 (era 122). Commit 825ff75, push a main, GHA 26607977146 deploy auto a val-soft.
  • Resuelto 2026-05-29 [#390]: Tanda C de #203: tipo translation_long (traducción de texto con validación en tiempo real). Cierra #203 completo (A+B+C). Muestra 1-2 frases EN INGLÉS (content), el niño escribe la traducción al ESPAÑOL en un textarea; debajo, validación palabra-por-palabra EN VIVO: chips verde (bien) / rojo (revisa). options=null, translation=null, correct_answer=traducción esperada en español. Decisiones con Sergio (AskUserQuestion): umbral aprobar = ≥80% de las palabras, comparación posicional (palabra i vs palabra i), texto = 1-2 frases (8-16 palabras). Algoritmo (idéntico front y back): normaliza (minúsculas, sin puntuación, sin acentos de vocales — los niños los omiten), tokeniza por espacios, compara posicional tolerando dedazos con Levenshtein ≤ min(2, len-1) (más estricto entre más corta la palabra, evita que “un”/“el” matcheen cualquier cosa). Backend usa levenshtein() nativo (por bytes → se trabaja en ASCII tras quitar acentos para que coincida con el JS). Sin migration. Tocado: ApprovePlanRequest (enum + options=null), LessonPlannerService (tipo 7 + ejemplo JSON + instrucción de 1 por lección desde día 3), StudentController::submitExercise (branch de calificación ≥80%), Exercise.vue (helpers levenshtein/tokenize + computeds translationFeedback/translationStats + textarea + panel de chips en vivo + chip “traducir”; submitAnswer ya enruta por el else→studentAnswer). NO tiene fichas de pista (no hay opción que descartar). +8 tests (2 validación ApprovePlanValidationTest + 6 TranslationLongTest: exacta, dedazos, ignora acentos/may/punt, umbral 80% justo=4/5, <80% reprueba, desorden reprueba). Suite 137/137 (era 129). npm run build OK (Exercise.vue 31.13 KB). Pendiente: commit + push + deploy (ver bitácora). Igual que A/B: los planes ya generados no traen translation_long — Sergio genera un plan nuevo como padre para que Leonardo lo vea.
  • Resuelto 2026-05-27 [#204]: Spaced repetition (Leitner simple). Tabla word_mastery (user + palabra única, unique([user_id, word])). WordMasteryService: recordAttempt actualiza correct/wrong_count y calcula next_review_at via box Leitner (box = max(0,min(5,correct-wrong)), intervalos 0/1/2/4/8/16 días al inicio del día en TZ Juárez). dueWords y countDue para el dashboard. Hook silencioso en submitExercise (try/catch + Log::warning). Solo palabras sueltas: oraciones con espacios o length<2 devuelven null. GET /student/reviewReviewWords.vue con hasta 5 flashcards EN→ES; redirige al dashboard si vacío. POST /student/review/{wordMastery}/answer califica, actualiza mastery, devuelve JSON {is_correct, correct_translation, next_review_at}. Student/Dashboard con card amber ”📚 Tienes N palabras por repasar” (solo si count>0). +8 tests (suite 114/114). SHA 9ad49c1, push a main, GHA deploy auto a val-soft. Decisión técnica: $table='word_mastery' en el modelo (Eloquent pluraliza a word_masteries por default, la tabla usa nombre singular por claridad de dominio).
  • Resuelto 2026-05-29 [#205]: Auditoría integral + investigación de herramientas externas. Salida en projects/aprende-ingles/AUDIT.md. Cubre los 6 ejes pedidos (UX, calidad IA en los 4 skills, gamification, métricas, gaps vs apps comerciales, herramientas externas). Hallazgo central: la app ya cubre lo de mayor impacto (4 skills, eval pronunciación Azure, Leitner, IA); los gaps reales NO son ligas/leaderboards/vidas (esos serían contraproducentes para un niño solo) sino: (1) sin input visual (imagen por palabra — gap universal en apps de niños), (2) celebración del éxito pobre (sin animación de cierre/sonido/barra que avance con error), (3) repaso solo por intervalo Leitner, no por error/prerrequisito (mastery learning estilo Khan), (4) sin dashboard de progreso longitudinal ni nivel CEFR estimado, (5) sin producción/conversación libre (ya hay ladrillos: IA + voz). Herramientas gratis recomendadas: Datamuse (sin key, 100k/día), Free Dictionary API (IPA+audio, con caché+fallback), datasets CEFR/NGSL locales, Pexels (imágenes), Iconify (iconos). Conjugación API de pago: NO (usar GPT o mlconjug3 local). Plan de 5 fases propuesto en el AUDIT (top: imagen+image_match → celebración+manejo de error → streak con holgura+dashboard /progreso+CEFR → repaso priorizado por error → stretch: micro-historias i+1 + mini-roleplay IA). Pendiente operativo: Sergio decide cuáles fases convertir en pendientes #NNN y con qué fechas (ver entrada de bitácora 2026-05-29). Sin código tocado esta sesión.
  • Resuelto 2026-05-29 [#403]: Fase 1 del AUDIT: tipo image_match con apoyo visual (icono Iconify). Ataca el gap #1 (input visual). Decisiones con Sergio (AskUserQuestion): fuente = Iconify (sin API key) — cero bloqueante, iconos limpios que el AUDIT prefiere sobre fotos de stock para un niño; mecánica = opción múltiple (ve el dibujo → elige entre 4 palabras EN); alcance v1 = solo image_match (imagen decorativa en otros tipos = iteración futura). Diseño: content=palabra EN (=correct_answer), options=4 palabras EN, correct_answer∈options; el icono se baja del correct_answer (siempre ilustra la respuesta). Replica el patrón TTS (#202): IconifyImageService (busca en api.iconify.design/search → descarga el SVG → cachea en storage/app/public/icons/{sha1}.svg; null si no hay icono o falla red) + GenerateImageForExerciseJob async (idempotente, se autodescarta si no es image_match) despachado al aprobar el plan, junto al de TTS. Nueva columna image_url (migration). Calificación cae en path genérico (selectedOption===correct_answer) → WordMasteryService SÍ trackea la palabra (refuerza vocab). discard_option aplica. Frontend: render con icono (<img dark:invert> para legibilidad en dark; placeholder 🖼️ si image_url null mientras el job no termina o no hubo icono) + grid 2×2 de opciones (patrón reading). +8 tests (3 validación ApprovePlanValidationTest + 5 ImageMatchTest: califica ok/mal, job guarda image_url con Iconify mockeado, job deja null sin icono, job se autodescarta para no-image_match). Suite 145/145 (era 137). Build OK (Exercise.vue 32.82 KB). Pendiente: commit+push+deploy. Como los demás tipos: la IA solo lo mete en planes nuevos (desde día 1, solo palabras concretas/dibujables) — Sergio genera plan nuevo para que Leonardo lo vea.
  • Resuelto 2026-05-29 [#404]: Fase 2 del AUDIT: celebración del éxito + manejo amable del error. 100% frontend (Exercise.vue) — los puntos son por lección, no por acierto, así que re-encolar no toca backend. Decisiones con Sergio (AskUserQuestion): re-encolar = al final, una vez (si lo vuelve a fallar ya no se re-encola; evita bucle); celebración = canvas-confetti (lib ~6KB instalada). Sonido lo decidí: Web Audio API (arpegio C-E-G al acertar, sin sonido de castigo al fallar). Implementado: (1) cola mutable queue (arranca con props.exercises; el fallado se re-encola al final con _review:true, una vez vía Set requeuedIds); (2) barra que nunca retrocede aunque crezca la cola — (i+1)/(L+1) ≥ i/L; (3) chime de acierto Web Audio (decorativo, try/catch); (4) overlay de cierre full-screen púrpura + confetti (3 ráfagas) + “+N puntos” (usa lesson.points_reward) por 2.6s antes de ir al dashboard; (5) feedback amable — panel ámbar 💪 (era rojo ❌), copy “¡Casi! 💪 La respuesta correcta era «X». La verás otra vez al final 🔁”; (6) chip ”🔁 repaso” en ejercicios re-encolados. Sin cambios de backend ni migration. Suite PHP 145/145 intacta (no hay tests JS en el proyecto; re-encolado verificado por razonamiento + build). Build OK (Exercise.vue 45.24 KB, +12 por confetti). Pendiente: commit+push+deploy. Verificar con Leonardo el feel de confetti/sonido/re-encolado.
  • #405 📅 2026-06-02 · ⏱ 4-6h — Fase 3 del AUDIT (#205): streak con holgura + dashboard /progreso del papá. Streak freeze (1-2 congeladores/semana) + celebración fuerte de los primeros 7 días + récord personal (NO liga/leaderboard). Pantalla /progreso: palabras dominadas vs en repaso, racha, minutos esta semana vs meta, nivel CEFR estimado (Pre-A1/A1, usando datasets CEFR/NGSL locales). Los datos ya están en BD; falta la pantalla que los agregue. Resuelve eje f (métricas). Detalle en AUDIT.md §8.
  • #406 📅 2026-06-03 · ⏱ 3-4h — Fase 4 del AUDIT (#205): repaso priorizado por error real + anclaje de vocabulario. Pasar al LessonPlannerService “qué domina / qué falló” estructurado y repasar prerrequisitos antes de subir dificultad (mastery learning estilo Khan: +2.7% corrección del siguiente ítem). Anclar el vocab nuevo a NGSL/NDL + filtro CEFR A1/A2 (datasets locales). Mejora el motor IA existente sin rehacerlo. Detalle en AUDIT.md §8.
  • #407 📅 2026-06-04 · ⏱ 1-2d — Fase 5 del AUDIT (#205, stretch): producción/conversación libre. Micro-historias i+1 (la IA genera párrafos de 3-5 frases, 95-98% vocab conocido + 1-2 nuevas, con imagen) → comprensión lectora real. Mini-roleplay con IA sobre lo recién estudiado (empezar por texto, luego voz con la eval de pronunciación ya existente) + tutor socrático en errores (pista que lleva a la respuesta, no la da). El gap diferenciador que casi ninguna app casera tiene; ya hay ladrillos (IA + voz). Detalle en AUDIT.md §8.
  • #206 📅 2026-05-31 — Integración aprende-ingles ↔ tareas-hijo — marcar lección diaria automáticamente. Cuando el hijo complete una LessonCompletion en aprende-ingles (Postgres en val-soft), una tarea correspondiente en tareas-hijo (Postgres en Fly.io) debería marcarse hecha automáticamente. Mecanismo posible: webhook desde aprende-ingles → endpoint Phoenix en tareas-hijo. Requiere: (a) tareas-hijo tener un endpoint público autenticado para “marcar tarea hecha por slug” (NO existe hoy — Fase 1 #184 está en bloque 1/4); (b) aprende-ingles tener un job/queue que dispatch al completar lección; (c) modelo de mapeo: en tareas-hijo, una tareas_definicion con slug='leccion-ingles-diaria' se vincula. Bloqueado por #184 Fase 1 completa de tareas-hijo (que tenga su esquema LiveView + auth). Cuando se desbloquee, decidir si la integración es push (webhook) o pull (cron en tareas-hijo consulta aprende-ingles). Estimación: chica una vez desbloqueado (1 endpoint + 1 job).
  • 📅 2026-06-01 — Después (no bloqueante): rate limit del lado app en generatePlan (padre puede tirar N llamadas a OpenAI con clicks repetidos) + retry/backoff en LessonPlannerService.
  • Resuelto 2026-05-26 madrugada [#207]: Fichas de pista diarias (propuesta de Leonardo). Cada día Leonardo recibe 3 fichas (config DAILY_HINT_TOKENS=3). Cada pista usada gasta 1 ficha. Fichas sobrantes al cerrar la lección = 5 pts extra (POINTS_PER_UNUSED_HINT=5) sumados al weeklyGoal.points_accumulated. 3 tipos: translation (revela traducción al español, solo hearing con translation), spelling (revela cómo se escribe la palabra correcta, solo hearing sin options), discard_option (tacha al azar 1 opción incorrecta, reading o hearing con options multiple-choice). Schema: tabla hint_uses append-only (user_id, exercise_id, hint_type, payload json para guardar la opción descartada en discard_option) + columna nueva exercise_attempts.used_hint (default false) que submitExercise marca true si hay HintUse del par user+exercise. Backend: RequestHintRequest valida tipo enum; StudentController::requestHint(RequestHintRequest, Exercise) valida tipo aplicable al ejercicio (422 si no), valida cupo (429 si 0 fichas), valida que queden opciones por descartar en discard_option (random.array_rand de las no descartadas e incorrectas), crea HintUse y devuelve {type, content, tokens_remaining}. completeLesson calcula fichas_sobrantes = N − COUNT(hint_uses today_jrz), bonus = fichas_sobrantes × pts/ficha, suma a LessonCompletion.points_awarded y weeklyGoal.points_accumulated, toast incluye ”🎫 Bonus por no usar X ficha(s): +Y pts.” si bonus > 0. lesson() pasa props nuevas a Inertia: hintTokensRemaining (cálculo), hintTokensTotal (config), hintsByExercise ({exId: [type, …]} tipos ya usados por ejercicio). Frontend (Exercise.vue): badge ”🎫 N/3” arriba derecha junto al chip de tipo (amarillo si quedan, gris si no). Botón ”💡 Necesito ayuda” en bottom-left con dropdown de tipos aplicables a este ejercicio (filtra los ya usados con usedTypesForCurrent que combina prop server + locales). Panel ”💡 Pistas que pediste” muestra translation y spelling reveladas persistentes mientras está en el ejercicio. Opciones reading/hearing tachadas con line-through ✂️ + disabled cuando se descartan. Estados deshabilitados: sin fichas hoy = “Sin fichas hoy”; todas las pistas usadas = “Sin pistas más”. 12 tests en HintTokensTest: 5 endpoint (translation devuelve traducción + descuenta ficha, spelling devuelve transcripción, discard_option devuelve opción no-correcta, translation en reading rechazado 422, sin fichas 429), 1 discard_option dos veces consecutivas distintas, 2 submitExercise marca used_hint (true cuando había hint, false cuando no), 3 completeLesson bonus (0 fichas usadas = 100+15=115, 2 usadas = 100+5=105, all usadas = 100), 1 props Inertia. Suite 92/92 (era 80). Commit 5d683ed, GHA 26436028821 verde en 32s, migration corre auto. Decisiones tomadas con Sergio antes de implementar (AskUserQuestion 3 preguntas): N=3 fichas/día, 5 pts/ficha sobrante, bonus aplicado por lección (no por día calendario) para refuerzo inmediato. Lo que NO se implementó en v1 por scope: card ”🎫 Fichas esta semana” en Parent/Dashboard (Sergio no lo pidió explícito y agregar el agregado por semana requiere más backend — abrir bullet nuevo si Sergio quiere visibilidad agregada); UI en Parent/Dashboard para ajustar N/pts (dejado vía env vars del .env).
  • Resuelto 2026-05-26 madrugada [#253]: EditPlan funcional con guía persistente. Reportado por Sergio al validar #251: el botón “Regenerar Plan” en Parent/EditPlan no generaba nada + pidió que se guardara el texto del padre y se vieran weaknesses al editar. Tres bugs entrelazados: (a) EditPlan.vue hacía axios.post(...).then(r => generatedPlan.value = r.data.plan) — roto desde el refactor async del 2026-05-18 que cambió generatePlan a devolver {generation_id, status} y dejar el plan en polling; (b) weekly_guides no guardaba parent_instruction, así que al volver a editar el textarea aparecía vacío y se perdía el contexto del plan original; (c) editPlan() quedó fuera del #251 (seguía con take(5) + filtro de semana actual). Fix: migration add_parent_instruction_to_weekly_guides (text nullable), WeeklyGuide fillable + ApprovePlanRequest valida nuevo campo, approvePlan + updatePlan lo persisten, editPlan lo devuelve en el guide + alinea take(3) + filtro ≤14d (consistente con planner) + incluye translation en el map de exercises (que faltaba). EditPlan.vue reescrito: precarga parentInstruction = props.guide.parent_instruction, migra a polling async (espejo de Planner.vue: setInterval 3s, stopPolling() en onBeforeUnmount), envía parent_instruction en updatePlan para actualizarlo en cada save. Copy del textarea actualizado a “Guía del Plan / Texto que escribiste al crear el plan original (puedes editarlo)” y de Áreas a Reforzar a “Errores de los últimos 14 días. La IA los distribuye 1 por lección (máximo 2 lecciones por palabra) y los excluye del examen” — refleja el comportamiento de #251 al usuario. 4 tests nuevos en ApprovePlanValidationTest: (1) approvePlan persiste parent_instruction en weekly_guides; (2) plan sin parent_instruction es válido (campo opcional); (3) updatePlan actualiza el campo cuando el padre lo edita; (4) editPlan devuelve el campo via Inertia (assertInertia(...->where('guide.parent_instruction', '...'))). Suite 80/80 (76→80). Commit a29bbfe, GHA 26434619227 deploy auto en val-soft (migration corre automáticamente). Nota de colisión de IDs: al pushear el commit no había visibilidad de que hub-web-viewer ya había asignado #252 en sesión paralela; el commit a29bbfe dice “#252” pero el ID definitivo del hub es #253 — IDs no se reciclan.
  • Resuelto 2026-05-26 madrugada [#251]: Fix estructural de la obsesión IA con weaknesses. buildUserPromptForDay distribuye 1 weakness por lección (días 2/3/4/5), día 1 (intro) + día 6 (examen) sin weaknesses; parent_instruction PRIMERO con bandera ”🎯 TEMA PRIORITARIO Y DOMINANTE”; ejemplo negativo en el prompt; día 6 con regla ”🚫 PROHIBIDO usar weaknesses en el examen”. take(5)→take(3) + filtro ≤14d en ParentController::planner. 8 tests nuevos PlannerPromptTest con Http::fake(). Suite 76/76. Commit 5e86fdd, GHA 26434054707 deploy auto. Detalle en bitácora 2026-05-26 abajo.
  • Resueltos 2026-05-26 madrugada [#199 + #200]: auto-avance inmediato post-éxito (toast superior persistente, sin setTimeout 2s) + panel rojo con botón “Siguiente”/“Terminar lección” tras error (decisión de Sergio: enseñar del error). #200: migration exercises.translation TEXT NULL + UI dual 🇺🇸+🇲🇽 en hearing sin options, normalización lowercase+trim+puntuación, ExerciseAttempt.answer se guarda como EN: X | ES: Y, ApprovePlanRequest exige translation en hearing libre, prompt IA actualizado. Hearing legacy sigue funcionando. Suite 68/68 (+9 nuevos: 6 HearingTranslationTest + 3 ApprovePlanValidationTest). Commit 914b09a, push directo a main, GHA 26433241616 deploy auto. Detalle completo en bitácora 2026-05-26 abajo.
  • Resuelto 2026-05-24 [#198]: fix de zona horaria UTC + 3 bugs derivados + navegación de semanas pasadas en /parent/weaknesses. Detalle completo en bitácora 2026-05-24 abajo. Commit c8cf220, deploy 35s, suite 59/59 verde.
  • Resuelto 2026-05-21 [#132]: testeo con el hijo cerrado por Sergio. Ya probó la plataforma end-to-end (flujo speaking confirmado funcionando en prod tras fix del 2026-05-20 del dir recordings/).
  • Resuelto 2026-05-21 [#131]: deploy a ingles.val-soft.com confirmado como cerrado por Sergio (la entrega original ya estaba en [#001] 2026-05-15 — el bullet en PENDIENTES.md era duplicado heredado de antes del sistema de IDs).
  • Resuelto 2026-05-20: catch de submitAudio en Exercise.vue ahora discrimina: 422 con errors.audio muestra el mensaje de Laravel (ya viene en español); 5xx pide avisar a papá/mamá; resto cae a err.response.data.message con fallback de conexión. console.error({status, data, err}) para diagnóstico desde DevTools. Commit 217332f desplegado (run 26182631455 ✓ 29s).
  • Resuelto 2026-05-20: speaking exercises tronaban con HTTP 500 en prod (Unable to write in "public/recordings") — PHP-FPM (www-data) no podía escribir en dir owned by electrosystems:electrosystems 775. Refactor a Storage::disk('public')->putFileAs('recordings', ...) (servido vía /storage/ symlink). Aplicado en StudentController::submitExercise (speaking) y submitFeedback (audio opcional del feedback diario). Parent/Dashboard.vue actualizado para servir desde /storage/. deploy.yml ahora corre php artisan storage:link. Suite Pest: 54/54 verde. Commit 6c56b24 desplegado vía CI/CD self-hosted (run 26182263855 ✓ 38s).
  • Resuelto 2026-05-15 [#001]: deploy a ingles.val-soft.com + login mágico + CI/CD self-hosted.
  • Resuelto 2026-05-18 sprint corto: 3 FormRequests + sanitización error OpenAI + i18n “You can do it!” + cleanup tests huérfanos Breeze. Commit aad2787 desplegado.
  • Resuelto 2026-05-18 sprint medio (dark mode): 28 archivos Vue con paleta dark consistente (darkMode: 'media', sin toggle). Commit d48d3e9 desplegado.
  • Resuelto 2026-05-18: validación SMTP en prod (Sergio confirmó), cuenta del hijo creada, magic-link end-to-end funciona.
  • Resuelto 2026-05-18 sprint medio (tests magic-link): 12 tests Pest en tests/Feature/Auth/MagicLinkTest.php cubren allowlist accept/reject + anti-enumeration + normalización de email + rate limit (5/600s) + TTL + single-use + allowlist-changed + reuso usuario + creación con role del allowlist. Suite total: 28/28 verde. Commit 7acacca desplegado.
  • Resuelto 2026-05-18 cleanup Breeze: -7 archivos / -895 líneas. Borrados Auth/{ConfirmPassword,ForgotPassword,Login,Register,ResetPassword,VerifyEmail}.vue, Profile/Partials/UpdatePasswordForm.vue, app/Http/Requests/Auth/LoginRequest.php. Recortado AuthenticatedSessionController a solo destroy(). Limpiado bloque mustVerifyEmail (con link a verification.send que habría explotado) en UpdateProfileInformationForm + props en Edit + ProfileController. Hallazgo nuevo: Auth/Login.vue era dead también — la ruta /login va a MagicLinkController::create que renderiza Auth/MagicLink. Suite 28/28 verde. Commit 09eed93 desplegado.
  • Resuelto 2026-05-18 flujo de retroalimentación: tabla daily_feedbacks + modelo + página Student/LessonFeedback.vue (3 caritas + audio opcional) + card “¿Cómo le va?” en Parent/Dashboard con últimos 7 días. completeLesson ahora redirige a la página de feedback antes del dashboard. 10 tests nuevos (suite 38/38 verde). Commit 8239345 desplegado. Decisiones de diseño: por lección (no por ejercicio) para no agregar fricción; mood en enum sad/neutral/happy; audio opcional reusando MediaRecorder + /public/recordings/ del flujo de speaking exercises.

Plan de deploy (decidido 2026-05-08)

Subdominio confirmado: ingles.val-soft.com. Acceso: público.

Mismo patrón que medicinas.val-soft.com — usar el mismo servidor de hosting que ya tiene medicinas, con redirect/sitio en la VM reverse-proxy de Electrosystems.

Pasos a ejecutar (cuando Sergio autorice cambios):

  1. Investigación previa (read-only, ya autorizado):
    • SSH a reverse-proxy: leer /etc/nginx/sites-available/medicinas (o el nombre que use) para identificar el backend (proxy_pass).
    • SSH al backend que sale de ahí: confirmar cómo está deployado medicinas (Sail / Docker Compose / nginx-php-fpm / Laravel Forge / otro), qué versión de PHP, dónde están los repos, cómo se sirven los assets.
    • Confirmar registro DNS de val-soft.com: ¿wildcard, o un A record puntual por subdominio?
  2. Levantar el código en el servidor backend — clonar repo, instalar deps, migraciones + seeders, build de assets.
  3. Crear el sitio en reverse-proxy siguiendo el patrón documentado en electrosystems/servers/reverse-proxy/README.md:
    • Stage HTTP-only stub para ACME challenge → nginx -t && reload
    • certbot certonly --webroot -w /var/www/html -d ingles.val-soft.com
    • Swap stub por config full HTTP+HTTPS apuntando al backend
    • Sin allow-list LAN/VPN (sitio público).
  4. DNS: registro A (o CNAME) para ingles.val-soft.com201.218.172.3 si no existe wildcard.
  5. Smoke test y aviso a Sergio para que lo pruebe con su hijo.

Acceso — decidido 2026-05-14

Login mágico por email, allowlist de 3 cuentas:

  1. Sergio (admin/padre).
  2. Esposa de Sergio (mamá del hijo).
  3. Hijo (usuario final).

Sin registro abierto. El admin (Sergio) puede invitar; cualquier otro intento de login se rechaza. Implementación mínima:

  • Tabla users con los 3 correos pre-seedeados.
  • Endpoint /login pide email → si está en la allowlist, manda link con token de 30 min; si no, “no autorizado”.
  • Sesión normal de Laravel después del click.

Esto es lo que hay que sumar al deploy de mañana 2026-05-15.

En progreso

(En espera de deploy.)

Notas técnicas

Stack

  • Laravel 13, PHP 8.3
  • Inertia v1 + Vue 3
  • Tailwind 3
  • Laravel Sail (Docker)
  • Pest 2

Reglas específicas del proyecto (del ANTIGRAVITY.md custom)

  • Idioma de UI: español de México — todo lo visible al usuario (niño/padre).
  • Variables/clases/modelos: inglés (isWeeklyGoalAchieved, VoiceRecording, DailyPractice).
  • UX: premium, vibrante, micro-animaciones, amigable para 10 años.
  • Estructura de pages Inertia: resources/js/Pages/Student/ y resources/js/Pages/Parent/.
  • Form Requests para validación. config('...') siempre, no env() fuera de config/.

Bitácora

2026-05-29 — #404 Fase 2 del AUDIT: celebración + manejo del error cerrada (commit 6cb933c)

  • Pidió Sergio: “seguimos con #404” (Fase 2 del AUDIT, justo tras cerrar #403).
  • Hallazgo que simplificó todo: los puntos se otorgan por lección completada (completeLesson da points_reward fijo + bonus fichas), NO por acierto individual. Por eso el re-encolar es 100% frontend — no toca backend ni el cálculo de puntos. #404 es enteramente Exercise.vue.
  • AskUserQuestion 2 preguntas. Decidió: re-encolar = al final, una vez; celebración = canvas-confetti. Sonido lo decidí yo: Web Audio (sin castigo al fallar).
  • Implementado en Exercise.vue:
    • Cola mutable queue (ref, arranca [...props.exercises]). activeExercise/isLastExercise/progressPercentage ahora indexan sobre queue, no sobre props. Un ejercicio fallado se re-encola al final con {...ex, _review:true} (mismo id → mismo ejercicio en backend), una sola vez controlado por Set requeuedIds. Si lo vuelve a fallar, ya no se re-encola (sin bucle/frustración).
    • Re-encolar en advanceToNext: si feedbackStatus==='error' y el id no está en requeuedIds, push a queue. Como es éxito→feedbackStatus null, los aciertos no re-encolan. Speaking nunca falla en submit, así que no aplica.
    • Barra que NUNCA retrocede aunque la cola crezca: al re-encolar, índice y longitud suben juntos → (i+1)/(L+1) ≥ i/L siempre. Avanza con acierto Y con error (al pulsar “Siguiente”).
    • Sonido de acierto playSuccessChime(): Web Audio API, arpegio C5-E5-G5 con oscillators (cero assets), todo en try/catch (decorativo, nunca rompe el flujo). Suena al avanzar tras acierto; en el último, lo hace la celebración (evita doble chime).
    • Celebración de cierre finishLesson(): setea lessonComplete=true → overlay full-screen (gradiente púrpura, ”🎉 ¡Lección completada! +N puntos”, N=lesson.points_reward) + fireConfetti() (canvas-confetti, 3 ráfagas: centro + dos laterales) + chime, espera 2.6s y va al dashboard. El POST a complete sigue igual (crea LessonCompletion, suma puntos); completeLesson devuelve redirect (no JSON), por eso el número sale de props, no del response.
    • Manejo amable del error: panel de feedback de rojo/❌ → ámbar/💪; copy nuevo “¡Casi! 💪 La respuesta correcta era: «X». La verás otra vez al final para practicarla. 🔁” (reemplaza el flash.error + ”❌ Era”). Chip ”🔁 repaso” ámbar en el header cuando activeExercise._review.
  • Dependencia nueva: canvas-confetti@^1.9.4 (instalada vía docker node; package.json + lock actualizados). public/build gitignored (deploy compila en server → corre npm ci allá, la dep queda en el lock).
  • Validación: npm run build OK — Exercise.vue 45.24 KB (era 32.82; +12 por confetti). Suite PHP 145/145 intacta (no toqué backend; no hay framework de tests JS en el proyecto). Re-encolado y no-retroceso de barra verificados por razonamiento.
  • Commit 6cb933c, push a main (Sergio autorizó). GHA 26660630624 (job 78582004204) deploy a val-soft ✓ en 30s (corrió npm ci → canvas-confetti instalado + build). Operativo: verificar con Leonardo el feel del confetti, el sonido y el re-encolado.
  • Siguientes del AUDIT: #405 (streak con holgura + dashboard /progreso + CEFR), #406 (repaso por error), #407 (producción libre, stretch).

2026-05-29 — #403 Fase 1 del AUDIT: tipo image_match (apoyo visual) cerrada (commit 54b68ca)

  • Pidió Sergio: “empecemos con #403” (Fase 1 del AUDIT #205: input visual + tipo image_match). Justo después de cerrar #390.
  • AskUserQuestion 3 preguntas (patrón del proyecto). Decidió:
    1. Fuente de imágenes: Iconify (sin API key) — vs Pexels (requería que Sergio creara cuenta+key → bloqueante) vs emoji. Iconify es REST público sin tarjeta, 275k iconos; el AUDIT mismo dice que un icono limpio comunica mejor que una foto de stock a un niño de 10. Cero bloqueante: deployable hoy.
    2. Mecánica: opción múltiple — ve el dibujo, elige entre 4 palabras EN (reusa infra de reading).
    3. Alcance v1: solo el tipo image_match — imagen decorativa en ejercicios de vocab existentes queda para otra iteración.
  • Diseño del tipo image_match:
    • content = palabra EN (= correct_answer), options = 4 palabras EN (1 buena + 3 distractores de la misma categoría), correct_answer ∈ options.
    • El icono se genera del correct_answer (no de content) para garantizar que la imagen siempre ilustra la respuesta que el niño debe elegir.
    • Solo sustantivos/verbos concretos y dibujables (animales, comida, objetos, acciones simples). Nunca abstractos ni frases (no hay icono claro).
  • Arquitectura (replica el patrón TTS #202):
    • Migration add_image_url_to_exercises_tableimage_url string nullable after audio_url.
    • IconifyImageService::generate(query) → busca en api.iconify.design/search?query=&limit=1, toma icons.0 (prefix:name), descarga /{prefix}/{name}.svg?height=240, cachea en storage/app/public/icons/{sha1(query)}.svg (mismo caché-por-hash que OpenAiTtsService). Devuelve URL pública o null si no hay icono / falla red (degrada a placeholder, las opciones siguen jugables).
    • GenerateImageForExerciseJob (timeout 30, tries 2): idempotente (no regenera si ya hay image_url), se autodescarta si type≠image_match. Despachado en los 2 bloques de Exercise::create de ParentController, junto al de TTS.
    • Exercise model: image_url al #[Fillable]. Sin $hidden → se serializa solo a Inertia (StudentController::lesson pasa los exercises completos).
    • ApprovePlanRequest: image_match al Rule::in + branch (options≥2 + correct∈options, igual que reading).
    • LessonPlannerService: tipo 8 con detalle (solo palabras concretas/dibujables, content==correct_answer), ejemplo JSON (apple/banana/orange/grape), instrucción de 1-2 por lección desde día 1, notas de options/idioma.
    • StudentController: applicableHintTypes incluye image_match en discard_option. Calificación SIN branch nuevo — cae en el path genérico (selectedOption===correct_answer); además WordMasteryService SÍ trackea la palabra (es palabra suelta EN) → refuerza vocab en el Leitner.
    • Exercise.vue: branch de render con tarjeta del icono (<img :src="image_url" class="dark:invert"> — el SVG Iconify viene monocromo currentColor→negro, invert lo hace legible en dark; placeholder 🖼️ si null) + grid 2×2 de opciones (patrón reading con selectedOption/discardedOptions/✂️/🎯). submitAnswer enruta image_match por selectedOption. typeChipLabel→“imagen”, hintTypesForExercise→discard_option.
    • Tests +8: ApprovePlanValidationTest (acepta+persiste, rechaza <2 opciones, rechaza correct fuera de options) + ImageMatchTest (califica ok/mal, job guarda image_url con Http::fake de Iconify + Storage::fake, job deja null sin icono, job se autodescarta para reading con Http::assertNothingSent).
  • Validación: Pest 145/145 (era 137). Migration aplicada en local (18ms). npm run build OK — Exercise.vue 32.82 KB (era 31.13). public/build gitignored (deploy compila en server); el deploy.yml corre migrate + storage:link auto.
  • Commit 54b68ca, push a main (Sergio autorizó en sesión). GHA 26659624028 (job 78578585221) deploy a val-soft ✓ en 30s (corrió migrate + storage:link). Operativo: la IA solo mete image_match en planes nuevos → Sergio genera un plan nuevo como padre para que Leonardo lo vea. El icono se baja al aprobar el plan (job async, mismo worker que TTS).
  • Siguientes del AUDIT: #404 (celebración + manejo del error), #405 (streak+dashboard/progreso+CEFR), #406 (repaso por error), #407 (producción libre, stretch).

2026-05-29 — #390 tanda C (translation_long) cerrada (commit e5777e6)

  • Pidió Sergio: “vamos a empezar con el pendiente #390” (tanda C de #203, traducción de texto largo con validación en tiempo real). Con esto se cierra #203 completo (A=fill_blank, B=word_order, C=translation_long).
  • Antes de codear, AskUserQuestion 3 preguntas (mismo patrón que A/B). Decidió:
    1. Umbral de aprobación: ≥80% de las palabras (con dedazos tolerados). El feedback visual verde/rojo es independiente; esto es la nota final que da puntos/avanza.
    2. Orden: posicional (palabra i del niño vs palabra i de la esperada). Más simple, predecible, enseña estructura.
    3. Longitud: 1-2 frases (8-16 palabras).
  • Diseño del tipo translation_long:
    • content = 1-2 frases en inglés; el niño traduce al español en un <textarea>. correct_answer = traducción natural al español. options=null, translation=null. Sin migration (type es string libre).
    • Algoritmo de calificación (replicado EXACTO en front JS y back PHP para que el verde/rojo coincida con la nota): normaliza (minúsculas → quita puntuación con la misma regex del proyecto → quita acentos de vocales á/é/í/ó/ú/ü, conserva ñ) → tokeniza por espacios (filtra vacíos) → compara posicional palabra-por-palabra con Levenshtein. Tolerancia = min(2, len_palabra_esperada - 1): ≤2 para palabras largas, exacto para 1 letra, ≤1 para 2 letras (evita que “un”/“el”/“y” matcheen casi cualquier cosa). Aprueba si aciertos/total ≥ 0.80.
    • Por qué quitar acentos: levenshtein() de PHP opera por bytes; “él”(3 bytes) vs “el”(2) daría distancia 2 y reprobaría a un niño que no pone tildes. Quitando acentos antes, ambos lados quedan ASCII y la distancia PHP coincide con la del JS (que opera por code units).
  • Implementación (los 5 lugares de siempre):
    • ApprovePlanRequest: translation_long al Rule::in + agregado al branch que exige options=null (junto con writing/speaking).
    • LessonPlannerService::reglasComunes: tipo 7 con detalle (1-2 frases 8-16 palabras, traducción natural sin opcionales), ejemplo JSON, e instrucción de incluir 1 por lección desde el día 3 (requiere más vocabulario acumulado; días 1-2 lo omiten) + nota de idioma.
    • StudentController::submitExercise: branch translation_long que recomputa $isCorrect con la lógica ≥80%. WordMasteryService la ignora (correct_answer tiene espacios → no es palabra suelta).
    • Exercise.vue: helpers stripVowelAccents/translationTokens/levenshtein/wordMatches + computeds translationFeedback (chips del niño marcados ok/mal) y translationStats (hits/total/passing). Render: textarea + panel “Tu traducción:” con chips verde/rojo en vivo + contador “X de Y palabras bien — ¡vas muy bien! 🌟” al pasar el 80%. typeChipLabel → “traducir”. submitAnswer NO se tocó (translation_long cae en el else y usa studentAnswer). Dark mode pareado en todos los chips/textarea.
    • Tests +8: ApprovePlanValidationTest (acepta+persiste options=null; rechaza con opciones) + TranslationLongTest (6: traducción exacta=correcto, dedazos Levenshtein≤2=correcto, ignora may/punt/acentos=correcto, umbral justo 4/5=0.80=correcto, 2/5<0.80=incorrecto, palabras correctas en desorden=incorrecto por posicional). Helper validExercise('translation_long') agregado.
  • Validación: Pest 137/137 verde (era 129). npm run build (docker node:20-alpine) OK — Exercise.vue 31.13 KB (era 28.19). public/build está gitignored (deploy compila en server).
  • Commit e5777e6, push a main (Sergio autorizó el commit en sesión). GHA 26657896269 (job 78572694809) deploy auto a val-soft ✓ en 31s.
  • Operativo: los planes ya generados no traen translation_long (la IA solo lo mete en planes nuevos desde día 3). Para que Leonardo lo vea, Sergio genera un plan nuevo como padre.

2026-05-29 — #205 auditoría integral cerrada (sin código)

  • Pidió Sergio: “quiero avanzar con #205” (sesión exploratoria: análisis integral de la app + investigar herramientas externas para enseñar inglés a un niño 9-11; output doc, no código).
  • Cómo lo hice: 3 investigaciones en paralelo — (1) auditoría fáctica del codebase (Explore agent, read-only, HEAD 825ff75), (2) investigación web de APIs/herramientas externas, (3) benchmark pedagógico/gamification vs apps comerciales (Duolingo, Khan/Khanmigo, Lingokids, Cambly Kids).
  • Salida: projects/aprende-ingles/AUDIT.md — 9 secciones (resumen ejecutivo, tipos+4 skills, calidad IA, UX, gamification, métricas, gaps pedagógicos, herramientas externas, plan de 5 fases + fuentes).
  • Estado del codebase confirmado: 6 tipos de ejercicio (reading/writing/hearing/speaking/fill_blank/word_order), 4 skills cubiertos, gpt-4o-mini para planes con distribución determinista de weaknesses, gamification = meta semanal + puntos + racha + fichas + Leitner (sin badges/leaderboard/animaciones elaboradas), métricas persistidas pero sin vista longitudinal ni CEFR. Integraciones vivas: OpenAI (chat+TTS-HD) + Azure Speech.
  • Hallazgo central: lo que más mueve la aguja ya está; los gaps reales son input visual, celebración del éxito, repaso por error/prerrequisito (no solo intervalo), dashboard de progreso + CEFR, y producción libre. NO construir ligas/leaderboards/vidas (contraproducentes para un niño solo — meta-análisis Springer + ACM).
  • Herramientas externas — veredicto: imprescindibles gratis = Datamuse (sin key, 100k/día), Free Dictionary API (IPA+audio, cachear+fallback), datasets CEFR/NGSL locales. Imágenes: Pexels (sustantivos) + Iconify (verbos/conceptos). Calidad opcional: Forvo (voz humana, 500/día). Conjugación API de pago: NO (GPT o mlconjug3 local). dictionaryapi.dev: uptime no garantizado → cachear.
  • Próximo paso (decisión de Sergio): elegir cuáles de las 5 fases del AUDIT convertir en pendientes #NNN y con qué fechas. _next-id=403 al cerrar esta sesión (NO se crearon pendientes nuevos todavía).
  • Fuentes (URLs completas):
    • Herramientas: api.forvo.com · wordsapi.com + rapidapi.com/dpventures/api/wordsapi · datamuse.com/api · oxfordlearnersdictionaries.com/about/wordlists/cefr + github.com/Maximax67/Words-CEFR-Dataset + pypi cefrpy · pexels.com/api/documentation · unsplash.com/documentation · pypi.org/project/mlconjug3 · dictionaryapi.dev + github.com/meetDeveloper/freeDictionaryAPI · newgeneralservicelist.org · iconify.design/docs/api
    • Pedagogía/gamification: blog.duolingo.com/how-duolingo-streak-builds-habit + blog.duolingo.com/duolingo-max + investors.duolingo.com (Video Call) · strivecloud.io/blog/gamification-examples-boost-user-retention-duolingo · blog.khanacademy.org/how-khan-academy-is-building-a-better-ai-tutor + khanmigo.ai/learners · cambly.com/kids/how-we-teach · lingokids.com/research · commonsensemedia.org (Endless Alphabet/Lingokids) · gianfrancoconti.com (comprehensible input 95-98%) + cambridge.org (Krashen) · link.springer.com/article/10.1007/s11423-023-10337-7 + dl.acm.org/doi/10.1145/2583008.2583017 + icenet.blog (SDT) · dinolingo.com/cefr-for-parents + learnlink.com (CEFR para padres)

2026-05-28 — #389 tanda B (word_order / ordenar palabras) cerrada (commit 825ff75)

  • Pidió Sergio: “sigue con #389” (tanda B de #203, drag & drop ordenar palabras).
  • Decisión de UX (le avisé antes de codear): en vez de drag&drop literal, tap-to-build estilo Duolingo — banco de palabras revueltas, el niño toca para colocar/devolver. Razón: drag&drop es mala experiencia en touch y Leonardo usa Android. Funcionalmente es “ordenar palabras”. Si prefiere drag literal, es fácil de cambiar.
  • Diseño del tipo word_order:
    • options = las palabras de la oración EN EL ORDEN CORRECTO (fuente de verdad); el frontend las baraja para mostrarlas.
    • content = la oración completa (referencia visual).
    • correct_answer = la oración; invariante validado en approval: implode(' ', options) normalizado === correct_answer normalizado (misma normalización que StudentController::submitExercise, sin colapsar espacios — alineé el $norm del validador para evitar inconsistencia validación↔calificación).
    • Sin migration (reusa columnas; type es string sin enum).
  • Implementación:
    • ApprovePlanRequest: word_order al Rule::in + branch (options≥2 + invariante join==correct_answer).
    • LessonPlannerService::reglasComunes: tipo agregado a lista/options-note, bloque de detalle (3-7 palabras, sin puntuación suelta, evitar 1-2 palabras), ejemplo JSON (["I","have","a","red","apple"]), e instrucción de incluir 1-2 word_order por lección desde el día 2.
    • Exercise.vue: refs wordBank/wordAnswer; watch(activeExercise, initWordOrder, {immediate:true}) que baraja (Fisher-Yates, reintento si quedó en orden) al entrar a cada ejercicio (incluye index 0); pickWord/unpickWord (tap para colocar/devolver, bloqueados en awaitingNext); wordOrderAnswer computed une los tokens; branch de render con línea de respuesta (chips tappables que se devuelven) + banco; submitAnswer usa wordOrderAnswer; typeChipLabel agrega “ordenar”.
    • submitExercise: SIN cambios — cae en el path genérico answer===correct_answer. WordMasteryService ignora la oración (tiene espacios → no es palabra suelta). NO hay fichas de pista para word_order (no hay opción incorrecta que descartar) — ni applicableHintTypes ni hintTypesForExercise lo incluyen.
    • Tests +7: 4 en ApprovePlanValidationTest (acepta+persiste options/correct, rechaza <2 palabras, rechaza join≠correct_answer, acepta ignorando mayúsc/puntuación) + 3 en WordOrderTest (califica correcto, califica desordenado=incorrecto, acepta ignorando mayúsc/puntuación).
  • Validación: Pest 129/129 verde (era 122). npm run build OK — Exercise.vue 28.19 KB. Commit 825ff75, push a main, GHA 26607977146 deploy auto a val-soft.
  • Nota operativa: igual que fill_blank, los planes ya generados no traen word_order — Sergio genera un plan nuevo como padre para que Leonardo lo vea. Falta #390 (tanda C: traducción texto largo validación tiempo real) para cerrar #203 completo.

2026-05-28 — #203 decisión + tanda A (fill_blank) cerrada (commit b13243f)

  • Pidió Sergio: “seguir con el pendiente #203” (más tipos de ejercicios — quería que yo propusiera cuáles).
  • Antes de proponer, mapeé la arquitectura de tipos con un agente Explore: cada tipo es un branch en 5 lugares (Exercise.vue render + submitAnswer, StudentController::submitExercise, ApprovePlanRequest, LessonPlannerService::reglasComunes) + a veces columna nueva. Los 4 tipos actuales: reading/writing/hearing/speaking. type es string sin enum en BD.
  • Propuse 7 ideas semilla en una tabla valor pedagógico (Leonardo 10 años, principiante) × esfuerzo, con mi recomendación de orden. Vía AskUserQuestion Sergio eligió agregar A+B+C y arrancar por A:
    • A = Fill-in-the-blank (cloze) — quick win de gramática, reusa infra de reading.
    • B = Drag & drop ordenar palabras → quedó como #389.
    • C = Traducción texto largo validación tiempo real (lo que pidió explícito) → quedó como #390.
    • Diferidos: D matching imagen (depende de #205, fuente de imágenes), E find-the-error y G conjugation drill (backlog), F conversación IA (épico aparte).
  • Implementé A (fill_blank) end-to-end:
    • Diseño: content = oración EN con hueco ___, options = 4 strings EN (1 buena + 3 distractores gramaticales), correct_answer ∈ options. Sin migration (reusa columnas existentes). El submit cae en el path genérico answer===correct_answer — cero cambios en submitExercise.
    • ApprovePlanRequest: fill_blank agregado al Rule::in + branch de validación (content debe traer ___, options≥2, correct entre options).
    • LessonPlannerService::reglasComunes: tipo agregado a la lista, bloque de detalle, ejemplo JSON ("She ___ to school every day." / goes/go/going/gone) e instrucción de incluir 1-2 fill_blank por lección desde el día 2 (día 1 exento por ser intro).
    • StudentController::applicableHintTypes: discard_option ahora aplica a fill_blank (igual que reading).
    • Exercise.vue: computed fillBlankParts (parte el content en ___), branch de render (oración con el hueco resaltado que se rellena con la opción elegida + grid de opciones reusando el patrón de reading), submitAnswer usa selectedOption para fill_blank, helper typeChipLabel para mostrar “completar” en el chip en vez de “fill_blank”.
    • Tests +8: 4 en ApprovePlanValidationTest (acepta válido + persiste, rechaza sin ___, rechaza <2 opciones, rechaza correct fuera de options) + 4 nuevos en FillBlankTest (califica correcto, califica incorrecto, discard_option aplica y tacha incorrecta, translation NO aplica → 422). Agregué el caso fill_blank al helper validExercise.
  • Validación: Pest 122/122 verde (era 114). npm run build (docker node:20-alpine) OK — Exercise.vue 25.78 KB. Commit b13243f, push a main, GHA 26607398895 deploy auto a val-soft.
  • Nota operativa: los planes ya generados NO tienen fill_blank (la IA solo los mete en planes nuevos). Para que Leonardo vea uno, Sergio genera un plan nuevo como padre. Próximo: #389 (drag&drop) → #390 (traducción tiempo real).

2026-05-27 tarde — #204 cerrado: spaced repetition Leitner simple (commit 9ad49c1)

  • Pidió Sergio en paralelo a holbox + tareas-hijo + jmeza: arrancar el #204 (repaso de palabras con falla recurrente).
  • Decisión de producto (vía AskUserQuestion): solo palabras sueltas entran a word_mastery. Si correct_answer normalizado tiene espacios o length<2, no se trackea — la primera versión ataja el 80% del valor (vocab) sin la complejidad de extraer “la palabra clave” de oraciones.
  • Implementación delegada a laravel-fixer agent (background) con plan técnico cerrado. Reportó:
    • 8 archivos (4 nuevos + 4 modificados): migration word_mastery, WordMastery model, WordMasteryService, ReviewWords.vue, WordMasteryTest.php; StudentController.php, Dashboard.vue, routes/web.php.
    • Algoritmo Leitner: box = max(0, min(5, correct_count - wrong_count)), intervalos 0/1/2/4/8/16 días al inicio del día en TZ Cd. Juárez. Una falla baja 1 box (no resetea destructivo) — Leitner clásico.
    • Hook silencioso en submitExercise (try/catch + Log::warning) — el feature de repaso NO bloquea el submit aunque rompa.
    • GET /student/review → 5 flashcards EN→ES con palabras due; redirige al dashboard si vacío. POST /student/review/{wm}/answer califica + devuelve {is_correct, correct_translation, next_review_at}.
    • Card amber ”📚 Tienes N palabras por repasar” en Student/Dashboard (visible solo si count>0). Mobile-first, dark mode pareado.
    • +8 tests. Suite 114/114 (era 106).
    • Decisión técnica del agent: $table = 'word_mastery' explícito en el modelo (Eloquent pluraliza por default a word_masteries; mantener nombre singular por dominio).
  • Commit 9ad49c1, push a origin/main autorizado por default (memoria feedback_aprende_ingles_push_authorized). Deploy auto en val-soft vía GHA.
  • Estado #204 = CERRADO. La card aparecerá en cuanto Leonardo falle ejercicios con palabras sueltas; las palabras “due” se generan dinámicamente. Próxima vez que Sergio entre como Leonardo y falle un ejercicio simple (ej. “dog”, “apple”), el sistema empieza a trackearle. Tras X días verá la card.

2026-05-27 madrugada — #201 cerrado: análisis pronunciación Azure end-to-end en prod

  • Sergio configuró Azure y ffmpeg en val-soft mientras yo cerraba la sesión del backend. Creó recurso Speech Service en Azure, pegó AZURE_SPEECH_KEY + AZURE_SPEECH_REGION + AZURE_SPEECH_DRIVER=azure en .env, e instaló ffmpeg (versión 6.1.1 ubuntu).
  • Validación end-to-end (autorizada por Sergio):
    • Verifiqué binding: app(PronunciationAnalyzer::class) resuelve a AzurePronunciationAnalyzer (no Fake). OK.
    • Reanalizé recording #45 (“This is my favorite toy”, 21s) → status=done pero scores en 0/0/0/0 aunque la transcripción coincidía con el reference. Sospechoso.
    • Dump del response raw de Azure (vía Http facade directo en tinker) reveló el bug: Azure devuelve los scores directamente en NBest[0] (AccuracyScore, FluencyScore, CompletenessScore, PronScore) y por palabra en NBest[0].Words[i].AccuracyScore + ErrorType. Mi parser original buscaba NBest[0].PronunciationAssessment.{...} con sub-objeto que NO existe en el response real. La docs de Azure (Speech SDK) y la REST API tienen estructura distinta — esto sólo se detecta con un dump real.
  • Fix (commit 3c3a8cd, push directo, GHA 26499415619 deploy auto en 38s): AzurePronunciationAnalyzer::parseResponse ahora lee $nbest['AccuracyScore'] etc directamente. ErrorType=“None” se mapea a null (semánticamente equivalente). Tests FakePronunciationAnalyzer no afectados (devuelven el shape ya correcto). Suite 98/98 sigue verde.
  • Validación post-fix: reanalizé 2 grabaciones reales con el parser arreglado:
    • Recording #20 “We have a big dog” → pron=95, acc=93, flu=98, comp=100 (excelente).
    • Recording #45 “This is my favorite toy” → pron=90, acc=85, flu=95, comp=100 (muy bien).
    • ai_feedback poblado correctamente con resumen humano (“Score global: 95/100 Sin palabras con dificultad notable.”).
  • Bulk reanalysis NO ejecutado: intenté reanalizar los 10 recordings históricos pero auto-mode bloqueó la mutación bulk porque la auth original de Sergio era solo para 1 recording. Le pasé el snippet para que él lo corra si quiere ver scores históricos en /parent/dashboard. Los nuevos speakings que haga Leonardo se analizan automáticamente vía el job al hacer submitExercise.
  • Costo real consumido en validación: ~3 calls a Azure (1 fallida + 2 exitosas + 2 re-tests + 1 dump raw) = ~$0.0015 USD. Free tier intacto.
  • Estado #201 = CERRADO. Backend, UI, Azure live, scores reales en prod. Próxima sesión Sergio: si quiere, correr el bulk reanalysis siguiente:
    ssh val-soft "cd /var/www/aprende-ingles && php artisan tinker --execute='App\Models\VoiceRecording::where(\"audio_path\",\"!=\",\"text_validated\")->whereNotNull(\"audio_path\")->each(fn(\$r)=>App\Jobs\AnalyzeRecordingJob::dispatch(\$r->id));'"
  • Observación de campo: algunos webm tienen duración header inflada (1809s para “I have a blue car”, 7153s para “I like pizza”). Probable bug de MediaRecorder en Chrome que no escribe el cluster duration correctamente. Para Azure no es problema (ffmpeg lee el contenido real al convertir), pero el <audio> de Parent/Dashboard puede mostrar timeline raro. No crítico — ignorar a menos que Sergio reporte que el playback es problemático; si pasa, un fix es post-procesar con ffmpeg -fflags +genpts -c copy al guardar.
  • Para Leonardo (próxima sesión Sergio): entrar como hijo, hacer speaking de cualquier frase. Banner arriba muestra “Analizando…” 3-5s, luego score real con palabras flojas en rojo si las hay. Sergio entra como padre → en cada audio pendiente ahora ve el panel ”🤖 Análisis IA” con 4 chips Precisión/Fluidez/Completo/Global + transcripción + breakdown por palabra coloreado.

2026-05-27 — #201 análisis de pronunciación Azure (backend + UI listos, bloqueado por creds)

  • Pidió Sergio: “quiero avanzar con el #201” en aprende-ingles. Como el #201 estaba documentado como “decidir con Sergio en próxima sesión”, arranqué con AskUserQuestion 3 preguntas para fijar las decisiones clave antes de tocar código.
  • Decisiones tomadas con Sergio (vía AskUserQuestion):
    1. Vendor: Azure Pronunciation Assessment (vs OpenAI Whisper API / Speechace). Razón: devuelve AccuracyScore + FluencyScore + CompletenessScore + PronunciationScore + score por palabra y por fonema — exactamente lo pedagógico que el caso necesita (“marca errores”), no solo transcripción. Costo estimado ~$0.25 MXN/mes para Leonardo (free tier F0 = 5h/mes, usamos ~15 min/mes).
    2. Aprobación: manual siempre. El análisis IA solo enriquece ai_feedback y panel del padre; Sergio sigue revisando en /parent/dashboard con más información para decidir. No auto-aprueba por score.
    3. Feedback al hijo: sí, score + palabras flojas en tiempo real, sin bloquear avance. Banner persistente arriba del ejercicio con “🎙️ Tu pronunciación: 85/100” + palabras a practicar en chips rojos. Hijo puede seguir al siguiente ejercicio mientras el banner queda visible; se cierra con X o cuando manda otro speaking.
  • Hice (rama main aprende-ingles, suite Pest 98/98 verde — era 92):
    • Migration 2026_05_27_000000_add_pronunciation_analysis_to_voice_recordings: agrega accuracy_score, fluency_score, completeness_score, pronunciation_score (unsignedTinyInteger nullable), transcript (text nullable), word_breakdown (json nullable), analysis_status (string 16 default ‘pending’ — enum lógico pending|analyzing|done|failed|skipped), analyzed_at (timestamp nullable). Todas after ai_feedback.
    • VoiceRecording model: nuevas constantes ANALYSIS_*, fillable + casts (word_breakdown=>array, analyzed_at=>datetime).
    • app/Contracts/PronunciationAnalyzer.php: interface con analyze(audioPath, referenceText): array que devuelve accuracy/fluency/completeness/pronunciation_score, transcript, words[{word,score,error_type}].
    • app/Services/PronunciationAnalyzer/AzurePronunciationAnalyzer.php: implementación REST. Convierte webm→wav 16kHz mono PCM con ffmpeg exec, POST al endpoint https://{region}.stt.speech.microsoft.com/speech/recognition/conversation/cognitiveservices/v1?language=en-US&format=detailed con headers Ocp-Apim-Subscription-Key, Pronunciation-Assessment (base64 JSON con ReferenceText + GradingSystem=HundredMark + Granularity=Phoneme + Dimension=Comprehensive + EnableMiscue=true). Parsea NBest[0].PronunciationAssessment.{AccuracyScore,FluencyScore,CompletenessScore,PronScore} y NBest[0].Words[]. WAV temp se borra en finally.
    • app/Services/PronunciationAnalyzer/FakePronunciationAnalyzer.php: para tests + dev sin creds. Devuelve scores 92/90/100/92 + 95 por palabra por default. ::fake([referenceText => fixture]) permite inyectar fixtures específicas en tests.
    • config/services.php: nuevo bloque azure_speech con driver (env AZURE_SPEECH_DRIVER default fake), key, region (default eastus), language (default en-US), ffmpeg_binary (default ffmpeg).
    • AppServiceProvider::register: binding PronunciationAnalyzer::class → switch por services.azure_speech.driver. azure instancia AzurePronunciationAnalyzer, fake (default) instancia FakePronunciationAnalyzer.
    • app/Jobs/AnalyzeRecordingJob: 60s timeout, 2 tries. handle(PronunciationAnalyzer) resuelve recording, salta text_validated (los hearing/reading auto-aprobados) marcando skipped, marca analyzing antes del call, persiste resultado completo + escribe ai_feedback con resumen humano “Score global: X/100. Palabras con dificultad (score <70): A, B, C.” (este ai_feedback ya lo lee ParentController::planner:220 para alimentar voiceWeaknesses del próximo plan IA — la integración es automática, no hubo que tocar nada más).
    • StudentController::submitExercise speaking: ahora resetea columnas de análisis + status=pending en updateOrCreate (importante para que reenvíos reanalicen), dispatch AnalyzeRecordingJob, retorna response()->json({recording_id, message}) en vez de redirect()->back(). Cambio rompió EnglishLearningTest::enviar grabacion de voz que esperaba 302; lo migré a assertStatus(200) + json('recording_id').
    • StudentController::recordingAnalysis(VoiceRecording) nuevo endpoint GET /student/recording/{recording}/analysis (route student.recording.analysis). Valida ownership (403 si no), devuelve {status, accuracy/fluency/completeness/pronunciation_score, transcript, weak_words[], words[]}. weak_words filtra word_breakdown por score <70.
    • resources/js/Pages/Student/Exercise.vue: estado nuevo pronunciationFlash (ref con {status, exerciseTitle, score, accuracy, fluency, completeness, transcript, weakWords}), polling con pronunciationPollHandle (setInterval 2s + hard timeout 60s) que llama recording.analysis. submitAudio ahora arranca polling con recording_id del JSON antes de showSuccessAndAdvance. Render del banner persistente al inicio del max-w-3xl, con 4 estados: analyzing (indigo + pulse 🎙️), done con score≥70 (emerald, emojis 🌟/🎉) o <70 (amber 💪) mostrando “Tu pronunciación: X/100” + transcript “Te entendí: …” + chips de palabras a practicar, failed/skipped (slate, mensaje neutro). Botón X dismiss + onBeforeUnmount(stopPronunciationPolling).
    • resources/js/Pages/Parent/Dashboard.vue: bajo el <audio> de cada pendingRecording, panel ”🤖 Análisis IA” con grid 4 chips (Precisión/Fluidez/Completo/Global) coloreados verde si ≥70 / rosa si <70, transcript “Te entendí: …” y breakdown de palabras como pills (verde >=70 / rosa <70) con su score individual. Estados pending/analyzing muestran “Analizando pronunciación…” en italic indigo; failed muestra “Análisis IA no disponible (revisa manualmente)” en rosa.
    • Tests nuevos en tests/Feature/PronunciationAnalysisTest.php (6): (1) submitExercise speaking despacha job + marca pending + retorna JSON 200 con recording_id; (2) job persiste scores, transcript, word_breakdown, ai_feedback con resumen incluyendo palabra mal pronunciada; (3) job marca skipped para audio_path=‘text_validated’; (4) endpoint analysis devuelve status + weak_words; (5) endpoint analysis prohibe 403 a recording ajeno; (6) job marca failed cuando analyzer revienta + rethrow excepción. Setup usa Bus::fake() selectivo, Storage::fake('public'), fixture inyectable por reference text vía FakePronunciationAnalyzer::fake([...]). Adapté EnglishLearningTest::estudiante puede enviar grabacion de voz para nuevo formato JSON.
  • Bug atrapado al desarrollar tests: primera corrida fallaba con “Call to a member function all() on array” en assertOk. withoutExceptionHandling() reveló ValidationException: audio field must be of type webm/mp3/wav/ogg/bin. Causa: UploadedFile::fake()->create('recording.webm', 50, 'audio/webm') con el tercer arg explícito hace que MIME guess no matchee — el patrón del proyecto en EnglishLearningTest es fake()->create('recording.webm', 100) sin mime y eso sí pasa. Fix: omitir el mime explícito.
  • Validación: Pest 98/98 verde (era 92 + 6 nuevos). npm run build via docker:node:20-alpine OK — Exercise.vue ahora 23.17 KB (era 19.90 KB tras #207), Dashboard.vue Parent 25.35 KB (era ~22 KB). Sin warnings.
  • NO commiteado ni pusheado todavía — el commit + push lo hago tras esta bitácora. Sergio aprobó commit auto en aprende-ingles (regla del hub) y push autorizado por defecto (memoria feedback_aprende_ingles_push_authorized).
  • Para enchufar Azure (próxima sesión Sergio):
    1. Crear cuenta Azure (gratis con tarjeta para verificación, F0 es gratuito).
    2. Portal → “Crear recurso” → “Speech Service” → tier F0 → región (sugiero eastus) → crear.
    3. Una vez creado: “Keys and Endpoint” → copiar Key 1 y Region.
    4. SSH a val-soft, editar /var/www/aprende-ingles/.env:
      AZURE_SPEECH_KEY=<key1>
      AZURE_SPEECH_REGION=eastus
      AZURE_SPEECH_DRIVER=azure
    5. sudo apt update && sudo apt install -y ffmpeg (necesario para webm→wav, descarto ~50 MB).
    6. php artisan config:cache && systemctl reload php8.3-fpm (o equivalent en val-soft).
    7. Validar: el hijo manda un speaking, el banner pasa de “Analizando…” a score concreto en ~3-5s. Si falla, ver storage/logs/laravel.log para mensaje de Azure (key inválida, region wrong, ffmpeg missing, etc.).
  • Decisiones que NO toqué (scope conscientemente reducido):
    • No procesé recordings ya existentes. Cuando se enchufe Azure, los recordings viejos (con analysis_status='pending' por default de la migration) NO se reanalizarán automáticamente. Si Sergio quiere reanalizar histórico, una sesión rápida: php artisan tinkerVoiceRecording::where('audio_path', '!=', 'text_validated')->each(fn ($r) => AnalyzeRecordingJob::dispatch($r->id)). Costo: ~$0.01 si hay 5 recordings viejos.
    • No instalé microsoft/cognitiveservices-speech SDK PHP: no existe SDK oficial PHP de Azure Speech. La REST API es suficiente y no agrega dependencia.
    • No agregué retry exponencial al jobtries=2 con el retry default de Laravel es suficiente para errores transitorios de red Azure. Si Azure devuelve 429 (rate limit), el segundo retry también fallará — agregar backoff sería sobre-ingeniería para este volumen (~3 calls/día).
    • No mostré el score histórico de pronunciación en Parent/Dashboard (gráfico/promedio semanal). Útil pero scope mayor — el #205 (auditoría general) puede revisar si vale la pena agregarlo después.
    • No agregué accuracy_score ni similar al schema de LessonCompletion para tracking longitudinal — el ai_feedback ya canaliza el insight al planner IA, suficiente para v1.
  • Bloqueante real para cerrar #201: Sergio necesita ejecutar los 7 pasos del “Para enchufar Azure” arriba. Hasta entonces el código corre con FakePronunciationAnalyzer que devuelve 92/100 fijo (no útil en prod pero no rompe nada).

2026-05-26 madrugada — #207 fichas de pista diarias (propuesta de Leonardo)

  • Pidió Sergio: arrancar #207 tras cerrar #253. El diseño tentativo ya estaba documentado en este mismo archivo desde el 2026-05-24 (cuando Leonardo lo propuso) con 10 decisiones y plan de implementación.
  • Decisiones tomadas con Sergio antes de tocar código (vía AskUserQuestion, 3 preguntas):
    1. N=3 fichas/día — balance entre desafío y suficiencia para 2-3 dudas reales por lección. Opciones presentadas: 2/3/5.
    2. 5 pts por ficha sobrante — bonus máximo de 15 pts sobre los 100 de la lección (15%), notable pero no domina la meta semanal. Opciones: 3/5/10.
    3. Bonus por lección, no por día calendario — refuerzo positivo inmediato al cerrar cada lección (hoy hay 1 lección/día así que en práctica es similar, pero el timing del feedback es mejor por lección).
  • Hice (commit 5d683ed, suite Pest 92/92 verde, GHA 26436028821 deploy auto):
    • Migration 2026_05_26_010000_create_hint_uses_tableuser_id FK cascade, exercise_id FK cascade, hint_type string (‘translation’ | ‘spelling’ | ‘discard_option’), payload json nullable (para {discarded_option: "..."}), timestamps, índice (user_id, created_at) para consulta rápida de “fichas usadas hoy”.
    • Migration 2026_05_26_010001_add_used_hint_to_exercise_attemptsboolean used_hint default false después de is_correct. Útil para #204 (spaced repetition futuro) para que pueda excluir o ponderar diferente aciertos donde se pidió pista.
    • App\Models\HintUse — Eloquent model con constantes TYPE_TRANSLATION / TYPE_SPELLING / TYPE_DISCARD_OPTION, cast payload => array, relaciones a User y Exercise.
    • ExerciseAttempt #[Fillable] — agregado used_hint.
    • config/app.php — agregadas claves daily_hint_tokens (env DAILY_HINT_TOKENS=3 fallback) y points_per_unused_hint (env POINTS_PER_UNUSED_HINT=5 fallback), bajo un bloque comentado dedicado al #207. Tuneable sin tocar código.
    • App\Http\Requests\RequestHintRequest — valida type ∈ enum de los 3 tipos.
    • StudentController helpers nuevos:
      • hintTokensRemaining(int $userId) — calcula daily_hint_tokens − COUNT(hint_uses WHERE user_id AND DATE(created_at, tz)=today_jrz), usa whereBetween([startOfDay->utc, endOfDay->utc]) para respetar la zona Cd. Juárez (#198 timezone fix).
      • applicableHintTypes(Exercise $exercise) — retorna los tipos posibles según el ejercicio: reading-con-options → [discard_option]; hearing-con-options → [discard_option]; hearing-sin-options-con-translation → [translation, spelling]; hearing-sin-options-sin-translation (legacy) → [spelling]; writing/speaking → [] (no aplica).
    • StudentController::lesson() — pasa props nuevas a Inertia::render: hintsByExercise ({exId: [type, …]}), hintTokensRemaining, hintTokensTotal.
    • StudentController::requestHint(RequestHintRequest, Exercise) (nuevo endpoint POST /student/exercise/{exercise}/hint) — validaciones en este orden: (1) tipo aplica al ejercicio (422 con error), (2) hay fichas disponibles (429), (3) en discard_option: hay candidatos sin tachar y que no sean la respuesta correcta (422 si no). Para cada tipo: translation devuelve exercise->translation, spelling devuelve exercise->correct_answer, discard_option filtra los options excluyendo correct_answer + los ya descartados previamente en payloads, devuelve uno random. Crea HintUse con payload {discarded_option: ...} solo para discard_option. Retorna JSON {type, content, tokens_remaining}.
    • StudentController::submitExercise() — antes de crear el ExerciseAttempt, consulta si existe algún HintUse para el par user+exercise (cualquier tipo, no solo hoy — si pidió ayuda alguna vez en este ejercicio, used_hint=true). Pasa el flag al create.
    • StudentController::completeLesson() — calcula hintsUsedToday, tokensRemaining = max(0, dailyTokens - hintsUsedToday), hintBonus = tokensRemaining * pointsPerUnusedHint. Suma hintBonus a LessonCompletion.points_awarded y a weeklyGoal.points_accumulated. Mensaje flash: “¡Felicidades! Ganaste X puntos. 🎉” + si hintBonus > 0: ” 🎫 Bonus por no usar Y ficha(s): +Z pts.”.
    • routes/web.php — agregada ruta POST /student/exercise/{exercise}/hint con nombre student.exercise.hint.
    • resources/js/Pages/Student/Exercise.vue — adiciones grandes pero quirúrgicas:
      • Props nuevas: hintsByExercise (Object), hintTokensRemaining (Number), hintTokensTotal (Number).
      • State: hintTokens = ref(props.hintTokensRemaining), hintMenuOpen = ref(false), revealedHints = ref({}) ({exId: {type: content}} acumulado en sesión), previouslyUsedHints = ref({...props.hintsByExercise}) (tipos ya usados según server al cargar).
      • Helpers: hintTypesForExercise(ex) (mismo mapeo que el backend pero del lado UI para no llamar al server por nada), usedTypesForCurrent computed (server + local), availableHintTypes computed (filtrados), canRequestHint computed (tokens > 0 && available.length > 0), revealedForCurrent y discardedOptions computeds. hintTypeLabel(type) para mostrar texto amigable en el menú.
      • requestHint(type) async function: POST al endpoint, on success agrega contenido a revealedHints[exId][type] (para discard_option acumula en string con | como separador), actualiza hintTokens.value = data.tokens_remaining, on error muestra el error del backend en toast.
      • Template: badge 🎫 N/total arriba derecha en el header del exercise (amarillo si quedan, gris si no, con tooltip explicando), reemplazando el bloque absolute top-4 right-4 por flex con el chip type. Botón ”💡 Necesito ayuda” en bottom-left de la barra de acciones con dropdown z-index 10 que muestra hintTypeLabel(type) para cada availableHintTypes. Panel 💡 Pistas que pediste arriba del feedback de error muestra translation/spelling con text-emerald-700. Botones reading + hearing-con-options ahora chequean discardedOptions.includes(option) para aplicar line-through opacity-50 ✂️ + disabled.
    • Tests nuevos en tests/Feature/HintTokensTest.php (12): pedir pista translation en hearing devuelve traducción y descuenta una ficha, pedir pista spelling en hearing devuelve la transcripción correcta, pedir pista discard_option en reading devuelve UNA opción incorrecta, pedir pista translation en reading se rechaza (tipo no aplicable), pedir pista sin fichas devuelve 429, discard_option dos veces consecutivas tacha opciones diferentes, submitExercise marca used_hint=true cuando se pidió pista en ese ejercicio, submitExercise marca used_hint=false cuando no se pidieron pistas, completeLesson sin fichas usadas suma bonus completo (3 × 5 = 15), completeLesson con 2 fichas usadas suma bonus parcial (1 × 5 = 5), completeLesson con TODAS las fichas usadas no da bonus, lesson() pasa hintTokensRemaining y hintsByExercise a Inertia. Todos con config(['app.daily_hint_tokens' => 3, 'app.points_per_unused_hint' => 5]) en beforeEach para aislar de los defaults.
  • Validación local: 92/92 tests Pest verde (era 80 antes + 12 nuevos). npm run build OK — Exercise.vue ahora 19.90 KB (era 14.88 KB tras #199/#200, esperable por la lógica nueva).
  • Deploy: push a a29bbfe..5d683ed, GHA 26436028821 verde en 32s. CI/CD self-hosted en val-soft hace pull + composer + migrate (las 2 nuevas se aplican automático) + npm build + restart Horizon.
  • Para Leonardo (próxima sesión Sergio): entrar como hijo, hacer una lección hearing-sin-options y probar:
    1. Badge ”🎫 3/3” arriba derecha debe estar visible y amarillo.
    2. Botón ”💡 Necesito ayuda” debe abrir dropdown con ”🌎 Ver traducción al español” y ”📝 Revelar cómo se escribe”.
    3. Al pedir traducción: badge baja a ”🎫 2/3”, panel ”💡 Pistas que pediste” muestra la traducción.
    4. Al pedir spelling: badge baja a ”🎫 1/3”, panel agrega la transcripción.
    5. Hacer un ejercicio reading-con-options y probar “✂️ Descartar 1 opción incorrecta”: opción se tacha + queda disabled, badge baja.
    6. Pedir discard_option de nuevo en el mismo reading: otra opción distinta se tacha.
    7. Sin gastar más fichas (en otra lección si quiere), completar la lección: en el toast de feedback debe aparecer ”🎫 Bonus por no usar N ficha(s): +X pts.” y la meta semanal debe reflejarlo.
  • Decisiones que NO toqué (scope conscientemente reducido):
    • No card ”🎫 Fichas esta semana” en Parent/Dashboard (era opcional en el diseño original). Sergio no lo pidió explícito en la sesión de decisiones; agregarla requiere calcular agregados por semana del weeklyGoal. Si Sergio quiere visibilidad agregada cuando lo use en práctica, abrimos pendiente nuevo.
    • No UI de ajuste en Parent/Dashboard para N y pts (era opcional). Los valores están en config + env vars. Si Sergio quiere subir/bajar N=3, edita .env y deploya — más simple que UI con su propio FormRequest.
    • No animación específica de “ficha desapareciendo” (era opcional). El badge se actualiza al instante con tokens_remaining del servidor — suficientemente visible para 10 años, no quiero meter complejidad de CSS animations en v1.
    • No restricción de “1 ficha máx por ejercicio por tipo” — el backend permite pedir múltiples discard_option del mismo ejercicio (cada una tacha otra opción incorrecta). Para translation/spelling el frontend ya las oculta del menú una vez reveladas (availableHintTypes filtra), así que en práctica es 1/tipo por ejercicio salvo discard_option que escala.
  • Nota lateral: el badge muestra N/total pero si Sergio sube DAILY_HINT_TOKENS a 5 luego, el badge mostrará 5/5 correctamente; el cálculo backend siempre es max(0, config - usadas).

2026-05-26 madrugada — #253 EditPlan funcional con guía persistente

  • Reportó Sergio mientras validaba #251 en prod editando un plan (no creando uno nuevo): “no genera el plan. Me gustaría que al editar se guardara el texto que escribí como guía del plan de semana y que se vieran los errores de la semana pasada para que también al editar los tome en cuenta, pero ya aplicando los cambios de #251”.
  • Diagnóstico (3 bugs entrelazados):
    1. EditPlan.vue línea 28-46 (original): llamaba axios.post(route('parent.planner.generate'), ...).then(response => generatedPlan.value = response.data.plan). Pero ese endpoint cambió en el refactor async del 2026-05-18 (commit df99dda del feature plan_generations) — ahora despacha GeneratePlanJob y devuelve {generation_id, status}, NO el plan. El plan llega vía polling al endpoint generationStatus. Planner.vue se actualizó en ese refactor, pero EditPlan.vue se quedó atrás. Resultado: cada click en “Regenerar Plan” hacía POST exitoso pero response.data.plan era undefined → el plan no cambiaba en la UI, sin error visible.
    2. weekly_guides no tiene columna parent_instruction desde el inicio del proyecto. approvePlan recibía el campo y lo enviaba al LessonPlannerService para generar el plan, pero NUNCA lo persistía en el WeeklyGuide. Al volver a entrar a EditPlan, el textarea aparecía vacío y se perdía el contexto del plan original.
    3. editPlan() quedó fuera de los cambios del #251. Esa función todavía usaba take(5) + filtro created_at >= startOfWeek (semana actual), mientras que planner() ya tenía take(3) + subDays(14) desde mis cambios del #251.
  • Approach: todo en un mismo PR porque los 3 problemas son del mismo flujo “editar plan” y Sergio los reportó juntos. El segundo es el más profundo (requiere migration), pero también el que más valor le da: el contexto del padre persistido permite regeneraciones consistentes a lo largo del tiempo.
  • Hice (commit a29bbfe, suite Pest 80/80 verde, GHA 26434619227 deploy auto):
    • Migration 2026_05_26_000000_add_parent_instruction_to_weekly_guidestext('parent_instruction')->nullable()->after('vocabulary').
    • WeeklyGuide model #[Fillable] — agregado parent_instruction.
    • ApprovePlanRequest::rules() — agregada regla 'parent_instruction' => ['nullable', 'string', 'max:1000'] (mismo cap que tiene generatePlan request).
    • ParentController::approvePlan — pasa parent_instruction al WeeklyGuide::create.
    • ParentController::updatePlan — valida parent_instruction y lo pasa al $guide->update(). Ahora el padre puede editar tanto las lecciones como la guía.
    • ParentController::editPlan — alineado con #251: take(5)→take(3), filtro subDays(14) en lugar de startOfWeek (matchea planner()). Mapeo de exercises ahora incluye translation (faltaba). El guide se pasa tal cual al frontend — Inertia serializa el modelo Eloquent con todos sus campos incluyendo el nuevo parent_instruction.
    • Parent/EditPlan.vue reescrito:
      • parentInstruction = ref(props.guide.parent_instruction || '') — precarga.
      • generatePlan migrado a flujo async espejando Planner.vue: POST devuelve generation_id, setInterval cada 3s consulta generationStatus, al completed asigna generatedPlan.value = status.plan, al failed muestra toast con status.error. onBeforeUnmount(stopPolling) para no fugar timers.
      • useForm ahora incluye parent_instruction; updatePlan lo envía al backend en cada save.
      • Copy actualizado del textarea: “Guía del Plan / Texto que escribiste al crear el plan original (puedes editarlo). Si regeneras, la IA usará esta guía + las áreas a reforzar de abajo.” Copy de “Áreas a Reforzar” actualizado para reflejar #251: “Errores de los últimos 14 días. La IA los distribuye 1 por lección (máximo 2 lecciones por palabra) y los excluye del examen.”
      • Estado de loading con mensaje “Esto puede tomar alrededor de 1 minuto” (matchea Planner).
      • onError handler en el updatePlan para mostrar el primer error de validación en toast.
    • Tests nuevos (4 en ApprovePlanValidationTest):
      • approvePlan persiste el parent_instruction en weekly_guides
      • approvePlan acepta plan sin parent_instruction (campo opcional)
      • updatePlan persiste el parent_instruction nuevo del padre
      • editPlan retorna el parent_instruction guardado al padre (assertInertia con where('guide.parent_instruction', '...'))
  • Validación local: 80/80 tests Pest verde (era 76 + 4 nuevos). npm run build verde. Diff: ParentController +5/-3, WeeklyGuide +1/-1, ApprovePlanRequest +2/-1, EditPlan.vue ~85% reescrito, migration nueva, 4 tests.
  • Deploy: push a 5e86fdd..a29bbfe, GHA 26434619227 disparada en 5:43 UTC. CI/CD self-hosted en val-soft hace pull + migrate (que aplica la nueva columna) + composer + npm build + restart Horizon. Próxima sesión Sergio: regenerar plan desde /parent/planner/{guide}/edit y verificar (a) que el textarea precarga la guía original que escribió antes (si el guide existente la tiene null, escribirla y guardar para que la próxima edición la precargue), (b) que el botón “Regenerar con IA” produce un plan nuevo después de ~60s, (c) que el plan respeta el parent_instruction por encima de las weaknesses (efecto #251).
  • Decisiones que NO toqué:
    • No backfilleé parent_instruction para guides ya existentes — la columna nace NULL y Sergio puede escribirla al editar+guardar cada guide existente. Cualquier backfill requeriría pedir a Sergio el texto original (que ya no está en ninguna parte del sistema), o leerlo de los logs (ruido).
    • No agregué tests E2E del flujo Vue de polling — el patrón del proyecto sigue sin Dusk; los tests Pest cubren backend, el flujo de polling ya está validado por GeneratePlanAsyncTest. Si #253 EditPlan tiene regresiones de polling Vue-side, las notaremos en validación manual.
    • No toqué el bug Lesson::create() en updatePlan (línea 521) que no pasa student_id — fuera de scope #253, queda como nota mental para sesión separada. Riesgo: lecciones creadas vía edit pueden quedar globales (student_id=NULL) si el guide tenía student_id válido y la lección hereda incorrectamente. Worth revisar pronto.
    • No agregué un campo de “comentario adicional de regeneración” (como tenía la versión original “Ej: Haz que los ejercicios sean más fáciles”). El textarea único de “Guía del Plan” cumple ambos roles ahora: contexto principal + ajustes en regeneración. Sergio puede editarlo libremente.
  • Notas y colisión de IDs: al hacer el commit yo no tenía visibilidad de que en sesión paralela hub-web-viewer ya había asignado #252 (vista log diario). El commit a29bbfe del repo aprende-ingles dice “#252” porque me basé en <!-- next-id: 252 --> que vi al cerrar #251; pero entre commit y commit del hub, hub-web-viewer reclamó el 252. El ID definitivo de este pendiente es #253 (regla de no reciclar IDs). Los commits del repo de código no se rebean; el del hub sí queda con la asignación correcta.

2026-05-26 madrugada — #251 fix estructural obsesión IA con weaknesses

  • Reportó Sergio: Leonardo escribió mal “duck” una vez y todas las lecciones de la semana — incluyendo el examen del día 6 — quedaron llenas de ejercicios de “duck”. Lo mismo con otras palabras falladas. El plan que Sergio escribió en el parent_instruction se ignoraba casi por completo.
  • Diagnóstico (ya hecho cuando se abrió el #251): buildUserPrompt original era SHARED entre los 6 días paralelos (Http::pool en generatePlan), ponía weaknesses ANTES del parent_instruction, y la única regla pedagógica era “intégralos como repaso disimulado” sin cap explícito. Cada día decidía independientemente y todos elegían las mismas top weaknesses porque el LLM priorizaba lo primero que veía. El día 6 (examen) tampoco tenía exclusión explícita.
  • Approach (decisión clave): distribución determinista EN CÓDIGO, no via LLM. Cada día recibe solo SU subset. El LLM no puede saturar lo que no ve. Esto es más robusto que pedirle al LLM que se autorregule (que es lo que el prompt original intentaba con “disimulado”).
  • Hice (commit 5e86fdd, suite Pest 76/76 verde, GHA 26434054707 deploy auto en val-soft):
    • LessonPlannerService::buildUserPromptForDay($day, ...) nuevo — reemplaza buildUserPrompt shared. Cada día construye su propio user prompt.
    • weaknessesForDay($day, $allWeaknesses) — distribución: día 1 = [], días 2/3/4 = [w0]/[w1]/[w2] rotando, día 5 = [w0] refuerzo, día 6 = []. Top 3 weaknesses ordenadas por fail_count desc.
    • Orden del prompt invertido: parent_instruction PRIMERO con header ”🎯 TEMA PRIORITARIO Y DOMINANTE DEL PADRE PARA TODA LA SEMANA” + “TODOS los ejercicios de esta lección deben girar en torno a este tema. No te desvíes a otros vocabularios”. Weaknesses (si las hay para el día) DESPUÉS como “REPASO DISIMULADO PARA ESTE DÍA” con cap explícito “máximo 1 ejercicio que use esta palabra/frase, integrada en una oración NUEVA del tema del padre — NO como ejercicio aislado ni como protagonista de la lección” + ejemplo negativo: ”❌ MAL: dedicar la lección a esta palabra. ✅ BIEN: 1 ejercicio donde aparezca naturalmente dentro de una frase del tema del padre”.
    • Día 6 (examen) doble candado: userPrompt incluye ”🚫 REGLA CRÍTICA DEL EXAMEN: PROHIBIDO incluir las palabras o frases que el niño falló en lecciones anteriores. El examen evalúa ÚNICAMENTE el vocabulario nuevo de ESTA semana”. dayDescriptor(6) (systemPrompt) ampliado con ”🚫 PROHIBIDO usar palabras/frases que el niño falló en lecciones anteriores — el examen evalúa ÚNICAMENTE el vocabulario nuevo del tema del padre de ESTA semana”.
    • Día 1 (intro) explícito: “Esta es la lección de introducción — vocabulario LIMPIO del tema del padre, sin repaso de errores previos.”
    • Días 2-5 sin weakness asignada (caso degenerado): “No hay weaknesses asignadas a esta lección — concéntrate 100% en el tema del padre.”
    • generatePlan refactor: Http::pool ahora arma las 6 requests con array_map([1..6]) para que cada día use su propio buildUserPromptForDay. Antes era hardcoded 6 calls con el mismo userPrompt.
    • ParentController::planner:200-213: take(5) → take(3) + filtro created_at >= now($tz)->subDays(14)->utc(). Razón: el LessonPlannerService ya distribuye 1/lección max y los días 1+6 no reciben — más de 3 weaknesses es desperdicio. Filtro 14d evita que un error de hace 2 meses siga apareciendo cuando ya está dominado.
    • Tests (8 nuevos en tests/Feature/PlannerPromptTest.php): todos usan Http::fake() con respuestas mock mínimas válidas y Http::recorded() para capturar lo enviado a OpenAI. Cubren: (1) parent_instruction aparece ANTES de weaknesses en los 6 días, (2) día 1 sin weaknesses + texto “introducción”, (3) día 6 sin weaknesses + “REGLA CRÍTICA” + “PROHIBIDO” en user+system, (4) días 2/3/4 cada uno con UNA weakness distinta de las 3 (no las otras), (5) día 5 cycla a w0, (6) cada palabra aparece máx 2 lecciones (substr_count en el join de los 6 prompts), (7) sin weaknesses + parent_instruction el día 1 declara intro limpia, (8) sin parent_instruction el prompt sigue siendo válido.
  • Validación local: 76/76 tests Pest verde (era 68 + 8 nuevos). npm run build OK. Diff: LessonPlannerService.php +68/-23, ParentController.php +6/-4, PlannerPromptTest.php nuevo (+233).
  • Deploy: push a 914b09a..5e86fdd, GHA disparada en 5:26 UTC, CI/CD self-hosted en val-soft. Próxima sesión: regenerar plan desde Planner con un parent_instruction simple (ej. “La familia y la casa”) y verificar que la palabra “duck” (la que detonó el bug) ya NO aparece en la mayoría de lecciones, y NO aparece en el examen del día 6.
  • Decisiones que NO toqué:
    • No agregué toggle “ignorar fallas esta vez” en Parent/Planner.vue (era opción 5 del plan tentativo). Si el fix del prompt es suficiente, no se necesita el escape hatch. Sergio puede pedirlo si en la práctica resulta que sí hace falta.
    • No toqué voice_weaknesses — siguen llegando a todos los días (afectan solo el ejercicio speaking, son menos frecuentes, riesgo bajo de saturar).
    • No mockeé respuestas con ejercicios reales en los tests — uso el mínimo válido para que extractJson no falle. Los tests validan el PROMPT enviado, no el plan resultante (eso ya lo cubre EnglishLearningTest + ApprovePlanValidationTest).

2026-05-26 madrugada — #199 + #200 deployados juntos

  • Pidió Sergio: “vamos a empezar en el orden sugerido” (de mi propuesta de orden de la sesión anterior: #199 + #200 mismo PR como warm-up).
  • Decisiones de diseño que tomé con Sergio antes de tocar código (vía AskUserQuestion, 3 preguntas):
    1. #199 timing del auto-avance tras éxito: “inmediato (0ms) + toast persistente arriba”. Sergio agregó “si es posible sin JS, mejor” — concluí que con Inertia/Vue no aplica recargar página por ejercicio (perdería estado de grabaciones); el JS actual es razonable. Bajé setTimeout 2s → 0 y moví el feedback verde del panel inline al toast superior reusable.
    2. #199 comportamiento tras error: “detener y mostrar botón ‘Siguiente’”. Sergio prefirió pedagogía sobre fluidez — el hijo lee feedback rojo persistente con “Era: X” y avanza manual al estar listo. Botón cambia a “Terminar lección 🏁” si es el último ejercicio.
    3. #200 schema para guardar traducción: “columna nueva translation TEXT NULL”. Más limpio que combo con correct_answer; semántica clara, extensible.
  • Hice (commit 914b09a, suite Pest 68/68 verde, push directo a main autorizado en aprende-ingles):
    • Migration nueva 2026_05_25_000000_add_translation_to_exercises_table — agrega columna translation TEXT NULL after correct_answer.
    • app/Models/Exercise.phptranslation agregado al #[Fillable].
    • app/Http/Requests/ApprovePlanRequest.php — regla translation: nullable string + reglas cruzadas: hearing sin options requiere translation no vacío (rechaza string vacío y solo-whitespace).
    • app/Services/LessonPlannerService.php::reglasComunes() — sección “CAMPOS OBLIGATORIOS” agrega translation; bloque de hearing actualizado con texto explícito “transcripción exacta” + “traducción al español OBLIGATORIO”; ejemplo completo de hearing añade "translation": "manzana".
    • app/Http/Controllers/ParentController.phpapprovePlan + updatePlan (2 sitios Exercise::create) ahora persisten 'translation' => $exData['translation'] ?? null.
    • app/Http/Requests/SubmitExerciseRequest.php — branch nuevo para hearing con translation guardada: requiere ambos answer y translation strings. Hearing legacy (translation null) sigue con solo answer. Speaking sin cambios.
    • app/Http/Controllers/StudentController.php::submitExercise$normalize closure unificado (lowercase + trim + remove .,/#!$%^&*;:{}=-_\~()?¿¡“), aplica a ambos campos en hearing-con-translation; isCorrect = (transcripción coincide) AND (traducción coincide); ExerciseAttempt.answerse guarda comoEN: %s | ES: %scuando aplica (legible en weaknesses); hearing legacy sigue conanswer` plana.
    • resources/js/Pages/Student/Exercise.vue — refactor completo (280 → ~280 líneas pero código consolidado). awaitingNext ref nueva bloquea reenvíos durante feedback de error. advanceToNext() reusable, finishLesson() separado. submitAnswer y submitAudio ambos usan showSuccessAndAdvance(msg) (toast superior + advance inmediato) y showErrorAndWait(msg) (panel rojo persistente + bloqueo de inputs). needsTranslation computed. UI hearing-sin-options reorganizada: 2 inputs apilados con labels ”🇺🇸 Lo que escuchaste (en inglés)” + ”🇲🇽 ¿Qué significa en español?” (paleta indigo + emerald respectivamente, dark mode pareado en ambas). Botón principal cambia a “Siguiente ➡️”/“Terminar lección 🏁” cuando awaitingNext. Hearing-con-options (multiple choice) intacto.
    • Tests nuevos:
      • tests/Feature/HearingTranslationTest.php (6 tests) — setup con WeeklyGoal + lesson + 2 hearings (uno con translation manzana, otro legacy con translation null). Cubre: ambos correctos → success + answer compuesta EN: Apple | ES: Manzana; transcripción mal → error; traducción mal → error; falta campo translation en request → 422; hearing legacy con solo answer → sigue funcionando; normalización (uppercase + espacios + ¡! + ?¿) → ambos correctos.
      • tests/Feature/ApprovePlanValidationTest.php (+3 tests)validExercise('hearing') ahora incluye 'translation' => 'manzana' por default (asegura compatibilidad de tests existentes). Nuevos: rechaza hearing sin translation (unset), rechaza hearing con translation solo-whitespace, valida persistencia del campo tras approve.
    • Bug atrapado al correr suite la primera vez: is_correct se guarda como integer 0/1 en sqlite; mis asserts usaban toBeTrue()/toBeFalse() que son estrictos y fallan con 0/1. Cambié a toBeTruthy()/toBeFalsy() (patrón del proyecto, ej. línea 95 de EnglishLearningTest.php). 6/6 verde en segunda corrida.
  • Validación local: 68/68 tests Pest verde (vs 59 baseline post-#198) en sqlite in-memory vía docker run php:8.3-cli vendor/bin/pest (sin instalar PHP en host — regla feedback-php-mysql-via-docker). npm run build via docker run node:20-alpine → 18 chunks compilados sin warnings, Exercise.vue ahora 14.88 kB (vs ~13 kB pre-refactor).
  • Deploy: push a c8cf220..914b09a, GHA 26433241616 Deploy aprende-ingles disparada en 5:00 UTC. CI/CD self-hosted en val-soft hace pull + migrations + composer + npm + restart Horizon worker. Migration corre automáticamente — no requiere intervención manual.
  • Para próxima sesión: validar el deploy en prod (regenerar un plan desde Planner para que la IA produzca hearing con translation y Leonardo pueda probar el flujo nuevo). Si la IA omite el campo translation, el ApprovePlanRequest lo rechazará con error claro en el toast (patrón ya probado en #198/Fix B). Próximo pendiente en orden sugerido es #207 (fichas de pista) — ya complementario con #200 que entró hoy.
  • Decisiones que NO toqué:
    • No tests E2E del flujo Vue — el patrón del proyecto no usa Dusk; los tests Pest cubren backend, el frontend se valida visualmente. Sergio puede probar el avance inmediato y el botón Siguiente cuando entre como hijo.
    • No reentry rate limit — los ejercicios siguen sin throttling per-user (es un hijo, no abuso esperado).
    • No prerendering de audios TTS — eso es #202, otra decisión pendiente.

2026-05-24 (cierre tardío — Leonardo propuso fichas de pista, abierto #207)

  • Pidió Sergio (a petición de Leonardo, su hijo de 10 años): sistema de fichas de pista. Cada día N fichas; gastas 1 por pista (revelar significado o cómo se escribe una palabra que escuchó); fichas que sobran al final del día = puntos extras. Sergio pidió que le sugiera la mejor manera de implementarlo.
  • Hice: apunté #207 con propuesta de diseño completa: 10 decisiones de diseño con mi recomendación tentativa para cada una (N=3 inicial, 5 pts/ficha sobrante, esquema con hint_uses append-only, endpoint dedicado, UI con badge persistente, bonus al cerrar lección — no al cierre de día calendario — por refuerzo inmediato, marcar ExerciseAttempt.used_hint=true sin penalizar correctitud, configurabilidad eventual desde panel padre, métrica narrativa para Sergio en su dashboard). Dependencias claras: complementa fuerte con #200 (la pista “revelar significado” necesita el campo translation), debe coordinarse con #199 (auto-avance) para que la UI reserve botón ”💡 ayuda” antes del envío, y con #204 (repaso) para que el flag used_hint permita ponderar mejor las palabras dominadas. Decisión clave que faltó preguntar y queda para próxima sesión: ¿el bonus se aplica al completar cada lección o solo si llega al fin del día calendario sin gastar fichas? — sugerí “por lección” por refuerzo inmediato, pero Sergio decide.
  • Pedagógicamente: el diseño respeta el principio “no penalizar pedir ayuda” (el ejercicio sigue contando como correcto si usó pista), pero crea incentivo intrínseco a esforzarse sin ayuda vía el bonus de fichas sobrantes. Coincide bien con la mentalidad de un niño de 10 que entiende reciprocidad concreta: “no usé = puntos extra”.

2026-05-24 (cierre — Sergio dictó 8 pendientes nuevos #199-#206)

  • Pidió Sergio (al cerrar la sesión del #198): apuntar pendientes sin ejecutar. Conversación literal en el log; resumen ordenado por prioridad implícita:
    1. Auto-avance al siguiente ejercicio tras enviar (UX rápido) — #199
    2. Listening exercises que también pidan traducción (hoy solo pide transcripción) — #200
    3. Speech-to-text del hijo + detección automática de errores (capturar para Sergio + nota IA); está dispuesto a instalar libs o pagar servicio — #201
    4. TTS más claro y natural para los audios que escucha el hijo — #202
    5. Nuevos tipos de ejercicios — Sergio pidió que YO sugiera; ejemplo explícito que él dio: traducción de texto largo con validación visual letra-por-letra en tiempo real — #203
    6. Repaso de palabras en las que falla recurrentemente — #204
    7. Análisis integral de la app + investigación de herramientas externas; meta-foco en los 4 skills (escuchar/escribir/leer/pronunciar) con el goal semanal como anclaje — #205
    8. Integración con tareas-hijo: cuando el hijo termine su lección diaria de inglés, marcar la tarea automáticamente en tareas-hijo — #206
  • Hice (sin ejecutar nada del código aún): redacté los 8 pendientes en este archivo con detalle suficiente para que la próxima sesión arranque sin re-diagnosticar; para #201 y #202 anoté candidatos a evaluar (servicios + costos estimados + mi recomendación tentativa) para acelerar la decisión; para #203 listé 7 ideas semilla; para #206 anoté la dependencia (bloqueado por #184 Fase 1 de tareas-hijo). Actualizado PENDIENTES.md aprende-ingles section + next-id 199→207 + header.
  • Notas para la próxima sesión:
    • #199/#200/#204 son chicos, se pueden abordar primero como warm-up.
    • #201/#202 requieren decisión de servicio externo (escribir email-style con Sergio: opciones + tradeoffs + costos + mi recomendación + esperar pick).
    • #205 es la sesión exploratoria — output doc AUDIT.md, no código. Útil para reordenar prioridades del resto antes de pegarle a #203 (que es grande).
    • #206 esperar; tareas-hijo está en Fase 1 bloque 1/4 (#184).
  • No tocado: ningún archivo de código. Las 2 grabaciones pendientes #27 y #29 quedan listas para aprobación cuando Sergio entre como padre (el fix del #198 ya las desbloqueó).

2026-05-24 (noche, fix zona horaria + 3 bugs + navegación de semanas — [#198])

  • Reportó Sergio: desde Juárez a las 9:50 PM (= lunes 03:50 UTC) la app no le dejaba aprobar audios (toast verde mentiroso, sin sumar puntos, grabaciones seguían en el dashboard), no veía las fallas de la semana, y “lo de la semana que acaba de pasar” había desaparecido del Planner. Sospechó zona horaria de entrada. Pidió además poder ver las fallas de semanas pasadas. Repo no estaba clonado; SSH a val-soft agregado por él durante la sesión.
  • Diagnóstico en prod (read-only ya autorizado):
    • config/app.php con timezone => 'UTC', .env sin APP_TIMEZONE. Server PHP en UTC; PHP tinker confirma now() = 2026-05-25 03:51:23, startOfWeek() = 2026-05-25 00:00:00, format('Y-m-d') = '2026-05-25'.
    • WeeklyGoal #2 (user=3, 2026-05-18 a 2026-05-24, 580/1000 pts) activa. Lookup en approveRecording filtra end_date >= now()->format('Y-m-d') = '2026-05-25' → no la encuentra → controller devuelve redirect()->back()->with('error', 'sin meta semanal activa') → 302.
    • Frontend Parent/Dashboard.vue:82-92 solo tenía onSuccess handler en el approveForm.post(), sin lectura de flash.error → toast verde aunque la grabación quede sin aprobar (Inertia considera 302 como éxito).
    • weaknesses()/planner()/editPlan()/dashboard() filtran por created_at >= now()->startOfWeek() (= lunes 00 UTC = domingo 18 Juárez), perdiendo todo lo de la semana hecho antes de esa hora.
    • Las 2 grabaciones pendientes #27 (creada 2026-05-24 17:55 UTC = 11:55 Juárez domingo) y #29 (17:58 UTC) caían exactamente en este bucket vacío.
  • Decisión de approach (Sergio aprobó vía AskUserQuestion): quirúrgico con now('America/Ciudad_Juarez') en cada query problemática, NO cambiar APP_TIMEZONE global para no shiftear timestamps existentes (~6 días de uso real del hijo). Tz America/Ciudad_Juarez (no America/Ojinaga) por petición explícita de Sergio — esa zone se agregó hace años y refleja MDT/MST.
  • Hice (commit c8cf220, suite Pest 59/59 verde, deploy GHA 26382872324 35s):
    • config/app.php nueva clave 'user_timezone' => env('USER_TIMEZONE', 'America/Ciudad_Juarez') con fallback en código (no requirió tocar .env de prod, el deploy refrescó config:cache con el default).
    • ParentController (8 métodos): cambio now()now($tz) donde corresponde, .utc() cuando se compara contra created_at (timestamp UTC), format('Y-m-d') para columnas DATE (start_date/end_date), reemplazo whereDate('created_at', toDateString()) por whereBetween([startOfDay->utc, endOfDay->utc]) para racha.
    • StudentController (3 métodos): mismo patrón para firstOrCreate WeeklyGoal, lookup en lesson view, lookup en completeLesson, racha diaria.
    • HandleInertiaRequests::share: agregado 'flash' => ['success' => ..., 'error' => ...] para que Vue lo lea.
    • Parent/Dashboard.vue approveRecording: lee usePage().props.flash?.error en onSuccess; si existe, muestra toast rojo en vez del verde; agregado onError handler para 4xx/5xx.
    • Nueva feature ?week_offset=N en /parent/weaknesses: backend computa now($tz)->startOfWeek()->subWeeks($offset) + endOfWeek, whereBetween([start->utc(), end->utc()]), pasa al frontend weekOffset + weekLabel (“Esta semana”/“Semana pasada”/“Hace N semanas”) + weekRange (“18 al 24 de mayo”) + canGoForward. Frontend reescrito con barra de navegación arriba (botones prev/next con icons + label centrado), texto vacío genérico (ya no dice “esta semana”). Offsets negativos se truncan a 0.
    • TimezoneWeekTest nuevo (5 tests): Carbon::setTestNow(DOMINGO_NOCHE_JUAREZ_UTC = '2026-05-25 03:50:00') y verifica: aprobación encuentra WeeklyGoal aunque UTC esté en lunes; weaknesses sin offset incluye fallas del domingo Juárez (creadas a '2026-05-25 02:00:00' UTC); offset=1 muestra semana anterior con canGoForward=true; offset=-5 se trunca a 0; aprobación sin meta vigente devuelve flash.error (no success). Gotcha resuelto: created_at no está en #[Fillable(...)] así que Model::create(['created_at' => ...]) lo ignora → muté Carbon::setTestNow per insert para que Eloquent escriba el timestamp deseado.
    • EnglishLearningTest::beforeEach actualizado para crear WeeklyGoal con tz Mexico (1 línea cambiada — pre-fix usaba now()->startOfWeek() UTC).
  • Validación post-deploy via tinker en val-soft:
    • user_timezone = America/Ciudad_Juarez, now(tz) = 2026-05-24 22:16:27 (sábado noche Juárez 🤔 — luego de mi sesión).
    • WeeklyGoal #2 (start=2026-05-18 end=2026-05-24) encontrada con today = 2026-05-24. Sergio ya puede aprobar grabaciones #27 y #29.
    • Total fallas de la semana actual: 12 (antes del fix: 0 desde las 18:00 del domingo Juárez).
  • Bootstrap del repo local (no estaba clonado):
    • cd ~/code && git clone git@github.com:sevaor/aprende-ingles.git (SSH key personal de Sergio).
    • cp .env.example .env, docker run --rm composer:2 install (sin instalar PHP en host — regla feedback-php-mysql-via-docker).
    • docker run --rm node:20-alpine npm ci && npm run build (manifest Vite necesario para tests de Inertia render).
    • docker run --rm php:8.3-cli vendor/bin/pest con sqlite in-memory (no necesitó sail up).
  • Decisiones que NO toqué (y por qué):
    • No cambié APP_TIMEZONE global: hubiera sido el fix idiomático Laravel pero implica que todos los created_at viejos (guardados como UTC literal) se mostrarían 6h más tarde en la UI tras la migración. Sergio prefirió no contaminar el display histórico.
    • No toqué .env de prod: el fallback en config/app.php ya devuelve America/Ciudad_Juarez, así que la env var es 100% opcional. Le pasé snippet con ssh -t val-soft 'printf ... >> .env' por si quiere explicitarla; el classifier bloqueó hacerlo yo (mutación de prod sin auth per-sesión).
    • No “migré” timestamps viejos (correr UPDATE para restarles 6h y dejar todo en hora Mexico): cosmético; el hijo lleva solo días de uso y los timestamps “errados” en el dashboard son cuestión de mostrar la hora del registro, no afectan bucketing porque las queries ahora usan tz Mexico correctamente.
  • Aprendizajes para próximos proyectos Laravel:
    • Cuando APP_TIMEZONE=UTC y el público es de una sola zona horaria, no migrar; usar now($tz)->...->utc() en queries. Compromiso aceptable entre pureza y compatibilidad de datos viejos.
    • Cualquier useForm().post() en Inertia que retorne with('error', ...) necesita onError O lectura explícita de flash.error en onSuccess — 302 con flash es éxito Inertia, no fallo.
    • Models con #[Fillable] ignoran created_at en create(). Para tests con timestamps custom: mockear Carbon::setTestNow antes del insert o usar forceFill + timestamps = false.

2026-05-21 (cierre de #131 y #132)

  • Pidió Sergio: “#131 y #132 ya estan hechos”.
  • Hice: cerré ambos en PENDIENTES.md + bullet de “Testear con el hijo” en este archivo.
  • Notas:
    • #131 (deploy): ya estaba marcado como [#001] resuelto el 2026-05-15 dentro de este mismo archivo. El bullet en PENDIENTES.md era duplicado pre-IDs; lo cerramos formalmente.
    • #132 (testear con el hijo): el hijo ya probó (logs de prod del 2026-05-20 lo confirman). El speaking exercise tronaba con 500 (fix del dir recordings/ ya desplegado en commit 6c56b24), por lo que el “pendiente re-test” se cierra implícitamente — si reaparece algo, abrir bullet nuevo.

2026-05-18 (noche, Fix B del flujo IA + limpieza de planes)

  • Pidió Sergio: seguir con Fix B y regenerar plan completo, con examen al final + dificultad creciente Lun→Vie + mínimo 8 ejercicios por lección + los 4 tipos.
  • Hice (commit 573309d, suite 49/49 verde, deploy 28s):
    • Prompt nuevo (LessonPlannerService): estructura fija de 6 lecciones (días 1-5 + examen día 6), dificultad progresiva (día 1 simple → día 5 difícil → examen recapitula), mínimo 8 ejercicios por lección (16 en el examen), los 4 tipos obligatorios en cada lección. Reglas duras por tipo: reading siempre con 4 options y correct_answer ∈ options; writing/speaking sin options; correct_answer SIN notas tipo “(o cualquier adjetivo)” porque rompe el match exacto. Log info al generar OK.
    • Validación (ApprovePlanRequest nuevo): reglas anidadas y withValidator after() para reglas cruzadas. Rechaza: reading sin options, options < 2, correct_answer fuera de options, writing con options, notas explicativas en correct_answer, type inválido, student_id null/inexistente, day_number fuera de 1..6, days duplicados.
    • Defensa frontend (Student/Exercise.vue): si llega un reading con options inválidas, en vez de UI vacía bloqueante muestra “Este ejercicio tiene un problema, dile a tus papás”. Saltable.
    • Mejor manejo de error (Parent/Planner.vue): onError ahora muestra el primer error de validación en un toast con sugerencia “regenéralo”. Y guarda al verificar que student_id existe antes de POST.
    • Telemetría (approvePlan): Log::info con parent_id + student_id + week_number + lessons_count para capturar el bug WeeklyGuide.student_id=NULL si reaparece. El bug en sí queda pendiente — bug latente sin reproducción clara.
    • Tests (11 nuevos): ApprovePlanValidationTest cubre cada regla del validator.
  • Limpieza de planes viejos (en prod, vía tinker): Sergio autorizó borrar todo y empezar limpio. Verificación previa: 2 WeeklyGuides + 11 Lessons + 127 Exercises, sin ningún progreso del hijo (0 LessonCompletions, 0 ExerciseAttempts, 0 VoiceRecordings, 0 DailyFeedbacks). Borrado en transacción: solo Lesson + WeeklyGuide; el resto cayó por CASCADE FK. Resultado: 0/0/0.
  • Caveat sobre la decisión de scope del DELETE: el classifier bloqueó el primer intento porque incluía whereIn() en LessonCompletion/ExerciseAttempt/VoiceRecording/DailyFeedback (aunque estaban en 0). Verifiqué las migraciones (todas las FK tienen onDelete('cascade') / cascadeOnDelete()) y reduje a borrar solo Lessons + WeeklyGuides. La cascada se encargó del resto. Pattern útil para futuros cleanups.
  • Pendiente: Sergio genera el plan nuevo desde Parent/Planner con las instrucciones que quiera. Si la IA no cumple las reglas duras, el validator lo rechazará y Sergio verá el error claro en el toast.

2026-05-18 (noche, fix timeout OpenAI)

  • Reportó Sergio: 4 intentos de regenerar el plan, los 4 con error.
  • Diagnóstico vía logs prod: los 4 fallaron con cURL error 28: Operation timed out after 30002 milliseconds antes de que llegara el plan. El validator nunca se ejecutó — el problema no era validación de la IA, era que la API de OpenAI no respondía dentro del timeout default de 30s. El prompt nuevo pide ~48 ejercicios estructurados (6 lecciones × 8 ej, examen × 16); gpt-4o-mini necesita más de 30s para generarlo.
  • Fix (commit 74e4825): subo OPENAI_REQUEST_TIMEOUT default en config/openai.php de 30 a 90s. Actualizo banner del Planner (“Esto puede tomar entre 30 y 90 segundos — está generando ~48+ ejercicios”) para que no parezca colgado.
  • Plan B si 90s no alcanza: dividir la generación en 2 llamadas (días 1-3 y 4-6) y combinar; o cambiar a streaming. Por ahora la apuesta es 90s suficiente porque gpt-4o-mini genera ~80 tokens/s y el output esperado es ~6-7k tokens (≈87s).
  • Lección de observabilidad: el Log::error que metí en Fix B fue clave aquí — sin él, no habríamos sabido que era timeout y no validación. Mantener este patrón para integraciones con APIs externas.

2026-05-19 (madrugada, fix prompt — IA omitía “instruction”)

  • Reportó Sergio: “La IA generó un plan inválido: The plan.lessons.0.exercises.0.instruction field is required.” en el toast tras aprobar.
  • Buena noticia: la arquitectura async funcionó perfectamente — el worker corrió, generó el plan en ~60s, el frontend lo recibió, el padre lo vió, y el validator hizo su trabajo rechazando al aprobar.
  • Diagnóstico vía PlanGeneration BD: la IA generó plan con TODOS los ejercicios SIN el campo instruction. Solo type/content/options/correct_answer.
  • Causa raíz: el prompt en reglasComunes() mencionaba “instruction en español de México” pero el ejemplo JSON del prompt tenía "exercises": [ ... ] como placeholder. La IA al ver el ejemplo solo veía las reglas duras por tipo (que listan type/content/options/correct_answer) y no incluyó instruction.
  • Fix (commit 239a763): reescribí reglasComunes() con:
    • Sección “CAMPOS OBLIGATORIOS EN CADA EJERCICIO” enumerando los 5 campos explícitamente con énfasis “SIEMPRE presente” en instruction.
    • Ejemplos de instrucción típica por tipo.
    • 4 ejemplos COMPLETOS (uno por type) que la IA puede copiar de forma.
  • Resultado: Sergio confirmó “Ya jaló, mostró el plan.” El draft se ve y queda pendiente que él lo apruebe; si el ApprovePlanRequest lo deja pasar, el hijo ya puede usar las lecciones.
  • Aprendizaje: cuando el LLM “ignora” una regla, casi siempre es porque el ejemplo del prompt no muestra esa regla aplicada. Las reglas textuales son débiles vs. ejemplos concretos. Patrón para futuros prompts: incluir un ejemplo completo de la forma esperada.

2026-05-18 (noche, tercer 504 → arquitectura async con job + polling)

  • Reportó Sergio: después de subir a 6 llamadas paralelas (b9e8940), volvió a recibir 504.
  • Diagnóstico vía logs prod: LessonPlannerService::generatePlan ok {"elapsed_s":66.61,"lessons_count":6}. Las 6 paralelas SÍ completaron en server, pero a los 66.61s — todavía por encima del 60s default de nginx. Las llamadas individuales a gpt-4o-mini con response_format=json_object y 8+ ejercicios estructurados tardan 30-65s cada una, no 10-15s como había estimado. Conclusión: cualquier solución síncrona toca el techo de nginx.
  • Decisión (Sergio aprobó): ir por la solución correcta — procesamiento async con jobs + polling. Sin importar cuánto tarde la IA, el HTTP request no la espera.
  • Implementación (commit df99dda):
    • Tabla nueva plan_generations (parent_id FK, status pending/processing/completed/failed, week_number, inputs json, plan json nullable, error nullable, started_at, finished_at).
    • Modelo PlanGeneration con constantes de status.
    • Job GeneratePlanJob (ShouldQueue, timeout 180s, tries 1) llama a LessonPlannerService::generatePlan (que sigue siendo 6 paralelas internas) y actualiza el registro a completed/failed. Hook failed() captura excepciones fatales.
    • ParentController::generatePlan reescrito: crea PlanGeneration con status=pending, dispatchea job, devuelve {generation_id, status} inmediatamente.
    • ParentController::generationStatus nuevo: route model binding con check de autorización (parent_id === auth()->id() → 403). Devuelve {status, plan?, error?}.
    • Ruta nueva GET /parent/planner/generation/{generation} para el polling.
    • Planner.vue reescrito: generatePlan() ahora dispatchea y arranca setInterval cada 3s; stopPolling() en onBeforeUnmount; toast con error si falla. Generated plan se setea cuando llega status=completed.
  • Tests (5 nuevos en GeneratePlanAsyncTest, suite 54/54 verde):
    • generatePlan dispatchea job con Queue::fake() + devuelve generation_id.
    • generationStatus en cada uno de los 4 estados.
    • generationStatus devuelve 403 cuando un padre consulta una generación de otro padre.
  • Worker systemd en val-soft (operativo, vía SSH+sudo manual de Sergio): unit aprende-ingles-worker.service corriendo php artisan queue:work --sleep=3 --tries=1 --max-time=3600 como www-data. Logs en /var/log/aprende-ingles-worker.log. Modelado del unit de medicinas-worker que ya existía en el server. Driver: database (no redis), tabla jobs ya existía. Verificado systemctl is-active = active.
  • UX timing: banner del Planner actualizado de “30-90s” a “~1 minuto” (commit 7ba61df). El polling cada 3s muestra el draft cuando termina.
  • Solución es escalable: aunque la IA tarde 2-3 minutos un día con un prompt grande, el HTTP request ya no la espera; el polling sigue funcionando.

2026-05-18 (noche, segundo 504 — split paralelo)

  • Reportó Sergio: después del fix de timeout 90s, vio en DevTools AxiosError: Request failed with status code 504. Confirma que aún con PHP timeout=90s, nginx corta antes (default fastcgi_read_timeout 60s en val-soft + proxy_read_timeout en reverse-proxy).
  • Decisión: ir por Plan B en lugar de tocar infra. Split en 2 llamadas paralelas a OpenAI con Http::pool de Laravel. Cada llamada termina en ~30-40s, browser espera MAX(A,B), entra cómodamente en 60s nginx.
  • Implementación (commit 39f3982):
    • LessonPlannerService ahora hace 2 llamadas paralelas con Http::pool directo a api.openai.com/v1/chat/completions (en vez del facade OpenAI que no expone pool).
    • Llamada A: system prompt “días 1-3” + metadata (title, explanation, vocabulary). ~3000 tokens out.
    • Llamada B: system prompt “días 4-6+examen” (sin metadata, solo lessons). ~4000 tokens out.
    • Merge en el service: title/explanation/vocabulary de A, lessons de A + B concatenadas. El validator ApprovePlanRequest valida el output combinado sin saber que vino de 2 llamadas.
    • Reglas duras (tipos, options, correct_answer sin notas) centralizadas en reglasComunes() y embebidas en ambos prompts → no se duplica si cambia alguna regla.
    • Manejo granular de errores en extractJson(): 5 escenarios (null/throwable/HTTP no-200/sin content/JSON inválido) con log diferenciado y RuntimeException con label A o B para diagnóstico futuro.
    • withToken($apiKey) autentica directo sin tocar el facade OpenAI. Timeout 80s por llamada.
  • Compromiso de calidad: porque B no ve lo que generó A, puede haber leve discontinuidad temática entre día 3 y día 4. Mitigación: ambos prompts reciben el mismo parent_instruction (tema sugerido), así la coherencia viene del input compartido, no del estado.
  • Riesgo aceptado: si una de las 2 llamadas falla y la otra no, todo el plan falla (no hay merge parcial). Mensaje claro al padre vía toast. Padre regenera. No reintento automático en este sprint — eso era parte de Fix C.

2026-05-18 (noche, debug del flujo IA — fix inmediato A)

  • Reportó Sergio: “el fin de semana le pedí a la IA que me ayudara con el plan semanal y solo me creó lecciones de 3 ejercicios, y el primer ejercicio era de lectura pero no me da manera de resolverlo.”
  • Diagnóstico vía SSH + tinker a prod (read-only):
    • El plan de week=1 (lessons 13, 14, 15, 17) tiene exactamente 3 ejercicios por lección, y el primero de cada uno es type=reading con options=null. El frontend hace v-for="option in activeExercise.options" sobre null → no renderiza botones → bloqueo total. Sergio NO confundió; eran 3 y sí estaba roto.
    • Las instrucciones de los 4 ejercicios rotos son cosas como “Lee y traduce la frase”, “Completa la frase con tu propia palabra” — son ejercicios de escritura libre que la IA marcó incorrectamente como reading (que en esta app significa multiple choice).
    • Causa raíz: prompt ambiguo en LessonPlannerService::generatePlan (“options solo requerido si es tipo multiple choice”), validación insuficiente en ParentController::approvePlan, y defaults peligrosos en Exercise::create (type ?? 'reading', options ?? null).
  • Fix A aplicado en prod (DB, no código): dentro de una transacción, cambié type de readingwriting para los 4 ejercicios (ex#225, 230, 233, 239). Ahora muestran input de texto en lugar de UI vacía; el niño ya puede al menos intentar y avanzar. Verificado: 0 reading-sin-options restantes.
  • Caveat descubierto: los correct_answer de la IA son problemáticos para writing strict-match — especialmente ex#230 correct_answer="tall (o cualquier adjetivo apropiado)" (la IA puso una nota como respuesta). El match exacto nunca dará “correcto”, pero el código avanza al siguiente ejercicio aún con error, así la lección se puede completar. Calidad de IA = problema separado (Fix B / prompt).
  • Pendientes:
    • Fix B (próximo): reforzar prompt + validar estructura en approvePlan + defensa frontend (mensaje útil si llega reading-sin-options).
    • Fix C: logging del prompt/response IA + reintentos con backoff + bug WeeklyGuide.student_id=NULL.
    • Hallazgo sin abordar: la IA inventó un “Día 6 - Examen” con 32 ejercicios que no estaba pedido (prompt solo pide 5 días).

2026-05-18 (noche, flujo de retroalimentación)

  • Pidió Sergio: seguir con el flujo de retroalimentación tras el cleanup de Breeze.
  • Decisiones de diseño (vía AskUserQuestion): preguntar al completar la lección diaria (1 vez/día, mínima fricción); 3 caritas 😞 😐 😊; audio opcional reusando infra de VoiceRecording; vista de padre como card nueva en Parent/Dashboard.
  • Hice (commit 8239345, suite 38/38 verde, deploy 29s):
    • Schema: migration daily_feedbacks con FK a users + lessons, enum mood, audio_path nullable, is_seen_by_parent + unique compuesto. Modelo DailyFeedback con $table explícito porque Laravel pluralizaría “feedback” mal (descubierto durante tests — los devolvía “no such table daily_feedback”).
    • Backend: rutas GET y POST /student/lesson/{lesson}/feedback. StudentController::feedback protege contra acceso directo (redirect si no hay LessonCompletion o si ya hay DailyFeedback). submitFeedback es idempotente. completeLesson ahora redirige a la página de feedback en lugar de directo al dashboard. SubmitFeedbackRequest valida mood requerido + audio opcional. ParentController::dashboard pasa recentFeedbacks (últimos 7 días con user:id,name + lesson:id,title eager-loaded).
    • Frontend: Student/LessonFeedback.vue con 3 botones grandes táctiles, paleta literal por mood (rose/amber/emerald) porque Tailwind JIT no detecta interpolación dinámica, MediaRecorder reusado, botón deshabilitado hasta selección. Parent/Dashboard.vue card nueva ”💜 ¿Cómo le va?” antes del bloque de progreso, lista cada feedback con emoji + nombre + lesson title + fecha es-MX + reproductor de audio si existe.
    • Tests: 10 nuevos en FeedbackTest.php. Bug encontrado durante tests: Inertia::render con archivo Vue nuevo falla en test env porque el manifest local no lo tiene. Solución: $this->withoutVite()->assertInertia(...). También: UploadedFile::fake()->create() sin tercer arg (mime) — el audio/webm explícito causaba validation fail por mime sniffing.
  • Gotchas para próximos proyectos Laravel:
    • Modelos con nombre incontable (feedback, news, sheep) requieren $table explícito.
    • Páginas Inertia nuevas necesitan withoutVite() en tests hasta que el manifest se buildee.
    • Clases dinámicas de Tailwind (bg-${color}-100) no funcionan con JIT — usar paletas literales o safelist.

2026-05-18 (noche, cleanup Breeze)

  • Pidió Sergio: limpieza de dead-code antes de pasar a diseñar el flujo de retroalimentación.
  • Hallazgo nuevo durante la verificación: además de los 6 archivos Vue Breeze que ya teníamos identificados, Auth/Login.vue también era dead — la ruta GET /login va a MagicLinkController::create que renderiza Auth/MagicLink.vue, no Auth/Login.vue. El AuthenticatedSessionController::create (que renderizaba Login.vue) y ::store (que usaba LoginRequest) tampoco tenían ruta; solo ::destroy (logout) seguía activo. Pedí autorización explícita a Sergio para extender el scope; aprobó.
  • Hice (commit 09eed93, -895 líneas):
    • Borré 7 archivos Vue + app/Http/Requests/Auth/LoginRequest.php (carpeta Auth/ quedó vacía y se removió).
    • Recorté AuthenticatedSessionController a un solo método (destroy) + sus imports correspondientes.
    • Limpieza del flujo Profile (relacionada con verify-email dead):
      • Profile/Edit.vue: quité import+render de UpdatePasswordForm y props mustVerifyEmail/status.
      • Profile/Partials/UpdateProfileInformationForm.vue: quité bloque “your email is unverified” (apuntaba a route('verification.send') inexistente — habría explotado si se activaba), import Link, props.
      • ProfileController::edit: quitó render-props mustVerifyEmail/status + import del interface MustVerifyEmail. User.php nunca implementó MustVerifyEmail, así que el bloque era invisible en runtime de todas formas.
  • Validación: suite Pest 28/28 verde (ningún test asertaba sobre props removidas). GHA verde en 28s. /login HTTP 200 en prod.
  • Decisiones que NO toqué:
    • ProfileController::destroy sigue pidiendo password actual para borrar cuenta. Como los usuarios magic-link tienen password aleatorio que nunca conocen, el botón “delete account” es inutilizable en prácticamente. Pero borrar cuenta tampoco aplica (3 cuentas hardcoded, la allowlist es la fuente de verdad). El test correct password must be provided to delete account sigue verde porque usa el UserFactory que setea password=‘password’. Lo dejo así para no romper ProfileTest; cleanup separado si se decide quitar el botón “delete account”.
    • ProfileController::update sigue seteando email_verified_at = null cuando cambia email. No daña nada; defensa en profundidad si algún día se reactiva verify-email.
  • Pidió Sergio: seguir con los tests del magic-link tras confirmar que el dark mode quedó OK visualmente.
  • Hice: archivo tests/Feature/Auth/MagicLinkTest.php con 12 tests Pest cubriendo el ciclo completo. beforeEach setea config('auth.allowlist') con emails fake (padre@example.com/hijo@example.com) para aislar de los env vars reales. Mail::fake() por test. Aproveché CACHE_STORE=array de phpunit.xml para que el rate limiter aísle entre tests.
  • Cobertura:
    • POST /login: allowlist acepta + manda mail; non-allowlist redirige idéntico sin mandar nada (anti-enumeration); normaliza email (lowercase+trim); rechaza email malformado; rate limit (5 intentos OK, 6º rebota con error de sesión, sólo 5 mails enviados).
    • GET /login/{token}: válido autentica y redirige a dashboard + marca used; ya usado rechaza (single-use); expirado rechaza (TTL); allowlist removida después de emitir rechaza Y marca used (defensa-en-profundidad); token jamás emitido rechaza; usuario DB existente se reusa sin sobrescribir; usuario nuevo se crea con role del allowlist.
  • Validación: 12/12 verde al primer intento (62 aserciones, 698ms). Suite total: 28/28 verde, 108 aserciones, 882ms. GHA verde en 28s.
  • Commit + deploy: 7acacca pusheado a origin/main; GHA Deploy aprende-ingles (run 26063269915) verde en 28s.
  • Sobre cómo descubrí el comportamiento exacto: leí MagicLinkController::store y consume, más MagicLink::issue/findValidByToken/markUsed. El controller responde 302 a /login/sent siempre que el rate limit no se haya disparado, sin importar si el email está en allowlist o no — eso es lo que hace el anti-enumeration. La defensa de “allowlist cambió después de emitir el link” la valida consume releyendo config('auth.allowlist') cada vez.

2026-05-20

  • Pidió Sergio: “le marco error a mi hijo al intentar subir un ejercicio de audio, me ayudas a revisarlo”.
  • Diagnóstico: logs nginx en reverse-proxy mostraron al hijo (Android Chrome Mobile, IP 202.5.96.124) POSTeando 10 veces seguidas a /student/exercise/243/submit con respuesta 500 (33 bytes = {"message":"Server Error"}). Ejercicios 240-242 dieron 302 normales — eran reading/writing/hearing y sólo tocaron DB. 243 fue el primer speaking real en prod, por eso falló por primera vez.
  • Causa raíz (Laravel log en val-soft): Symfony\Component\HttpFoundation\File\Exception\FileException: Unable to write in the "/var/www/aprende-ingles/public/recordings" directory. en StudentController.php:111. El directorio en prod era electrosystems:electrosystems 775; PHP-FPM corre como www-data (no pertenece al grupo electrosystems) y se quedaba con el bit “other” r-x → sin permiso de escritura.
  • Por qué se nos pasó: local con Sail corre como otro user (todo es escribible dentro del contenedor) y el dir en prod estaba vacío porque hasta ayer nadie había disparado un speaking real — el hijo fue literalmente el primero.
  • Sergio pidió refactor limpio (no parche de chown): mover ambos uploads de public/recordings/ a storage/app/public/recordings/ servido vía symlink public/storage (patrón Laravel canónico, sobrevive a redeploys que recreen public/).
  • Hice:
    • StudentController::submitExercise (speaking) y submitFeedback (audio opcional del feedback diario) ahora usan Storage::disk('public')->putFileAs('recordings', $file, $filename). Mismo path relativo en BD (recordings/audio_X.webm), distinto root.
    • resources/js/Pages/Parent/Dashboard.vue: ambos <audio :src> cambiados a /storage/${audio_path}.
    • tests/Feature/FeedbackTest.php: cleanup public_path()storage_path('app/public/' . ...).
    • .github/workflows/deploy.yml: agregado php artisan storage:link después de migrate --force (idempotente).
    • Local: sail artisan storage:link + migrados 44 recordings de pruebas de public/recordings/ a storage/app/public/recordings/.
  • Validación: Pest 54/54 verde, 194 aserciones, 2.1s.
  • Commit + deploy: 6c56b24 pusheado a origin/main; GHA Deploy aprende-ingles (run 26182263855) verde en 38s. Symlink public/storage -> storage/app/public creado en val-soft por el deploy. El dir storage/app/public/recordings/ lo creará Storage facade en el primer upload (PHP-FPM tiene rwx en storage/app/public/ por grupo www-data).
  • Pendiente UX (no bloqueante): el catch de submitAudio en Exercise.vue muestra “Error al subir tu grabación. Inténtalo de nuevo.” sin más info, por eso el hijo le picó 10 veces seguidas. Mostrar err.response?.data?.message mejoraría debugging futuro.

2026-05-18 (tarde, sprint medio dark mode)

  • Pidió Sergio: seguir con el sprint medio tras confirmar que SMTP + magic-link funcionan en prod y la cuenta del hijo está creada. Eligió estrategia darkMode: 'media' (default Tailwind, sin toggle) — cumple su regla permanente sin agregar JS extra.
  • Hice: pase completo de dark mode delegado a subagente (general-purpose) con una paleta canónica estricta. 28 archivos Vue tocados, 390 líneas añadidas / 390 quitadas (≈780 reemplazos de clase). Cobertura: 2 Layouts + 13 Components reutilizables (Toast, Modal, Dropdown, *Button, TextInput, etc.) + 3 páginas Auth activas + 4 Parent + 2 Student + Profile (Edit + 2 partials) + Dashboard helper.
  • Paleta canónica usada: bg-whitedark:bg-slate-800; bg-{color}-50dark:bg-{color}-900/30; text-{color}-900dark:text-{color}-100; text-gray-600dark:text-slate-400; border-{color}-100/200dark:border-{color}-800/40. Botones vibrantes (bg-{color}-500/600 + text-white) y gradientes de hero quedaron intactos porque funcionan idénticos en ambos modos.
  • Skip consciente (dead-code): Auth/{ConfirmPassword,ForgotPassword,Register,ResetPassword,VerifyEmail}.vue y Profile/Partials/UpdatePasswordForm.vue — rutas borradas en sprint corto pero archivos siguen en el repo; los borraremos en una limpieza aparte. Welcome.vue ya tenía dark mode (referencia inicial del agente para el estilo).
  • Validación: los 28 SFC parsean OK con @vue/compiler-sfc. Build local de vite falla por incompat Node 18 vs vite 8 (config preexistente, no introducido por este cambio); GHA self-hosted en val-soft con Node 20+ deploya en 29s sin issues. Smoke prod /login HTTP 200.
  • Commit + deploy: d48d3e9 pusheado a origin/main; GHA Deploy aprende-ingles (run 26058194668) verde en 29s.
  • Sobre la decisión darkMode: 'media' vs class: elegimos media porque cumple la regla permanente sin necesidad de UI nueva. El niño verá la app en light a menos que el OS de la laptop esté en dark — en cuyo caso ahora se ve consistente en lugar de roto.

2026-05-18 (mañana, sprint corto post-auditoría)

  • Pidió Sergio: “avanzar” en aprende-ingles. Tras descartar 3 opciones, eligió revisar código + sugerir mejoras.
  • Hice:
    1. Auditoría completa del repo vía subagente. Resultado: 2 falsos positivos (.env “leakeado” — no, nunca commiteado; APP_DEBUG=true en prod — no, el agente vio el .env local; verifiqué con SSH a val-soft que prod tiene APP_ENV=production + APP_DEBUG=false). 6 hallazgos reales priorizados.
    2. Sprint corto aplicado (commit aad2787 en main, local):
      • Creé app/Http/Requests/{CreateStudentRequest,UpdateWeeklyGoalRequest,SubmitExerciseRequest}.php para mover validate() inline de 3 controllers a FormRequests (regla ANTIGRAVITY.md del proyecto). SubmitExerciseRequest resuelve las reglas dinámicamente según $exercise->type (speaking vs texto) leyendo $this->route('exercise').
      • ParentController::generatePlan ya no expone $e->getMessage() al frontend. Ahora logguea con contexto (week_number, parent_id, exception) y devuelve “Hubo un problema generando el plan. Intenta de nuevo en unos momentos.”
      • Student/Dashboard.vue:108: “You can do it!” → “¡Vamos, tú puedes!” (regla ANTIGRAVITY.md del proyecto: UI 100% español de México).
      • Borré 5 tests huérfanos de Breeze (PasswordReset/Confirm/Update, EmailVerification, Registration) + 2 tests password-auth en AuthenticationTest.php. Todos apuntaban a rutas que ya no existen tras el reemplazo de Breeze por magic-link (c9e662d). Suite Pest pasó de 17/30 (con 13 rojos pre-existentes) a 16/16 verde.
  • Falta para cerrar la sesión:
    • Push a origin/main para que GHA self-hosted deploye a ingles.val-soft.com (classifier bloqueó el push automático; espera autorización de Sergio).
    • Sprint medio: dark mode en 15+ archivos .vue + tests reales del magic-link. Lo movemos a un commit/PR separado.
  • Aprendizaje para próximas auditorías con subagente: el agente leyó el .env local y lo confundió con el de prod — siempre verificar hallazgos de “secretos/config en prod” con SSH antes de alarmar a Sergio.

2026-05-15

  • Hecho: primer deploy completo a producción + CI/CD funcionando.
    • Código (commits en main): dded769 login mágico (config allowlist + tabla magic_links + controller con rate limit + anti-enumeration + 2 vues Vue + mailable). 0db0145 workflow GHA self-hosted.
    • Server (val-soft, ssh alias renombrado de paginas-web):
      • Repo GitHub sevaor/aprende-ingles (privado) + deploy key dedicada id_ed25519_aprende_ingles (no reusada con medicinas porque GitHub lo bloquea).
      • Self-hosted runner registrado (val-soft-aprende-ingles, labels [self-hosted, val-soft, aprende-ingles]) bajo systemd.
      • Postgres: DB aprende_ingles + user aprende_ingles_user.
      • /var/www/aprende-ingles con clone vía GIT_SSH_COMMAND apuntando a la deploy key dedicada. git config core.sshCommand persistido en el repo local del server para que git fetch del runner use la key correcta.
      • .env con APP_ENV=production, APP_KEY generada, DB + AUTH_* allowlist (3 emails: padre/madre/estudiante) + MAIL_* reusando el SMTP de medicinas.
      • Permisos: storage + bootstrap/cache con grupo www-data 775 (PHP-FPM corre como www-data).
      • nginx site /etc/nginx/sites-enabled/aprende-ingles (copia del de medicinas con server_name y root ajustados; PHP-FPM 8.3, sin SSL local — el TLS lo termina reverse-proxy).
    • reverse-proxy: DNS A ingles.val-soft.com → 201.218.172.3 (Porkbun) + certbot LetsEncrypt + server block apuntando a val-soft. Sergio ejecutó esta parte.
    • Validación: GHA verde en 29s en el primer run automático tras push. Site responde 200 en /login. Cert correcto (SAN ingles.val-soft.com).
  • Aprendizajes para próximos deploys con este patrón:
    • .env.example de Laravel modern viene con DB_* comentadas (# DB_HOST=...). Cualquier sed -i 's|^DB_HOST=.*|...|' falla silenciosamente. Usar append (cat >>) en lugar de sed para configuración inicial.
    • GitHub bloquea reusar deploy keys entre repos. Cuando un mismo server hostea varios repos, hay que generar 1 key por repo y persistir core.sshCommand en cada working tree.
    • El svc.sh install del runner crea el unit; el svc.sh start falla con “Unit not found” si saltas el install. Documentado en script.
    • Para verificar que un site TLS está bien configurado, openssl s_client -servername y revisar subjectAltName — si responde con cert de otro site (e.g. amadeus.electrosystemsnet.com), falta el server block apropiado en reverse-proxy.

2026-05-27

  • Pidió Sergio: implementar #204 — repaso de palabras con falla recurrente, algoritmo Leitner simple.
  • Hice: migration word_mastery (user_id, word, correct_count, wrong_count, last_seen_at, next_review_at, unique [user_id, word]). Modelo WordMastery con $table='word_mastery' (Eloquent pluralizaría a word_masteries). WordMasteryService con recordAttempt, dueWords, countDue. Hook en StudentController::submitExercise (try/catch silencioso, no bloquea submit). Endpoints GET /student/review y POST /student/review/{wordMastery}/answer. Vue page Student/ReviewWords.vue con flashcards progresivas y dark mode. Card amber en Student/Dashboard.vue si count > 0. +8 tests en tests/Feature/Student/WordMasteryTest.php. Suite 114/114. Build limpio. Commit 9ad49c1, push a main.
  • Fix técnico encontrado durante la implementación: los tests de recordAttempt fallaban porque firstOrNew devuelve el modelo con correct_count=null; la firma box(int, int) de PHP rechazaba null. Fix: castear a (int) antes de incrementar.
  • Decisión del algoritmo: box = max(0, min(5, correct_count - wrong_count)). Esto significa que una falla tras 3 aciertos NO va directamente a box 0 — necesita 3 fallos para volver a 0. El test 3 lo documenta con comentario explicativo. Es comportamiento correcto del Leitner acumulativo.
  • Falta: prueba con Leonardo en prod.

2026-05-14

  • Pidió Sergio: apuntar pendiente para mañana 2026-05-15: hacer deploy de aprende-ingles.
  • Decisión de acceso (cerrada en sesión): login mágico, solo 3 cuentas autorizadas (Sergio + esposa + hijo). Sin registro abierto.
  • Hice: marcado el deploy y el login mágico con fecha 2026-05-15 + entrada en PENDIENTES.md (Próximas fechas).
  • Listo para ejecutar mañana: plan detallado en sección “Plan de deploy” + nueva sección “Acceso”. Pasos en orden:
    1. Implementar login mágico (migración users, seed con 3 correos, controller/middleware, mailable). Sergio dicta los correos antes de seedear.
    2. Investigar config de medicinas en reverse-proxy.
    3. Replicar para ingles.val-soft.com (backend + nginx + certbot + DNS).
    4. Smoke test con los 3 correos.

2026-05-08

  • Pidió Sergio: registrar el proyecto.
  • Hice: creé este archivo. Confirmado vía ANTIGRAVITY.md custom que el target es niño de 10 años con UI español/contenido inglés.
  • Falta: deploy + test con el hijo.