Aquí registramos cada actualización de lutrAI.app — qué hemos añadido, mejorado o corregido. La versión actual es v1.32.0.
Estrenamos el Centro de ayuda: preguntas frecuentes y, si no encuentras tu respuesta, un formulario para escribirnos. Y tu insignia PRO ahora te acompaña por toda la app, no solo en el perfil. 🦦
Centro de ayuda en «Ayuda»: preguntas frecuentes + formulario de contacto si no encuentras tu respuesta.
Tu insignia PRO/VIP ahora aparece también en tu avatar del menú, esté donde esté.
Recuperados los testimonios de usuarios en la portada.
Cuando registras el día entero de una tacada, Lutra ahora te da su análisis de la ingesta —igual que al añadir una comida suelta—. Y hemos pulido cómo se ve el resumen en pantallas estrechas. 🦦
Tras «Mi día de una tacada», Lutra te comenta la ingesta del día completo (Premium).
El resumen de «Mi día de una tacada» ya se ve bien en móviles estrechos, sin desbordarse.
En el plan Free, al registrar a mano, Lutra te deja una nota simpática (sin gastar IA).
Ahora en el plan Free puedes apuntar tus comidas a mano —con sus calorías y macros— sin coste. Y si eres PRO, lo luces: una insignia sobre tu foto de perfil te identifica de un vistazo. 🦦
Registro manual de comidas en el plan Free: teclea cada alimento con sus calorías y macros, sin IA.
Insignia PRO (o VIP) sobre tu foto de perfil en cuanto eres miembro Premium.
Precios y funciones más claros: el plan mensual y el anual incluyen exactamente lo mismo (el anual solo sale más barato).
Estrenamos pasarela de pago con Creem: suscríbete a Premium (mensual o anual) en un par de toques y gestiona o cancela tu plan cuando quieras desde el portal. Pago seguro, recibo por email y tus 15 días de prueba como siempre. 🦦
Checkout de Premium con Creem: elige mensual (9,99 €) o anual (99 €) y paga en una página segura.
Portal de cliente: cambia de plan, actualiza el método de pago, cancela o reanuda — todo desde «Gestionar suscripción».
Retirada la pasarela anterior; el acceso Premium y la prueba de 15 días funcionan exactamente igual.
Corregida la regresión de ayer: en dictados largos sin pausas el vigilante del micro cortaba la frase a mitad y solo llegaban las últimas palabras. Ahora distingue cuándo estás hablando y nunca tira audio: todo lo que dices, se queda. 🦦
El vigilante del micrófono ya no interrumpe mientras hablas: escucha las señales de voz del navegador y solo actúa en silencios reales.
Si tiene que reiniciar el micro, ahora lo hace con «vaciado» — el audio pendiente se transcribe antes de reiniciar, sin perder nada.
En «Mi día de una tacada», pulsar parar también vacía el último tramo hablado antes de cerrar el micro (antes podía perderse la cola de la frase).
La grabación de voz ahora escucha TODO el rato hasta que tú pares — aunque hagas una pausa larga para pensar. Y marcar suplementos ya no da errores de timeout en frío. 🦦
Dictado por voz (comidas y «Mi día de una tacada»): si el navegador corta el micrófono en una pausa larga, un vigilante lo relanza solo — la grabación sigue hasta que TÚ pulses el botón de parar.
Marcar suplementos: el primer toque tras un rato inactivo daba un error rojo de timeout. Ahora espera más y reintenta solo — sin errores fantasma.
«Mi día de una tacada» ya está donde lo buscas: en el botón flotante ➕ y destacado bajo «Registrar comida». Y si a las 21:00 no has apuntado nada, Lutra te avisa — es el momento perfecto para contarle el día entero 🦦
El One-Shot aparece ahora en el botón flotante ➕ («Mi día de una tacada») y con botón propio bien visible bajo «Registrar comida».
Aviso de las 21:00: si no has registrado nada en todo el día, te llega un push para registrarlo todo de una tacada (se puede apagar en Perfil → Recordatorios).
El push te abre directamente el One-Shot al tocarlo.
La página de inicio pública ahora presenta las dos funciones estrella: el modo «Tu Nutricionista» y «Mi día de una tacada».
¿Día ajetreado sin registrar nada? Nuevo botón «Mi día de una tacada»: cuéntale a Lutra TODO tu día en un solo audio — desayuno, comida, cena y ejercicio — y te lo estructura entero para validar y registrar de golpe. 🦦
ONE-SHOT del día: narra todo lo que comiste y entrenaste en un audio o texto; Lutra lo separa en comidas por momento + actividades con kcal estimadas, tú validas y se registra todo de una vez.
La IA estima también las calorías de tu ejercicio (usa tu peso para afinar) y puedes quitar cualquier cosa de la propuesta antes de registrar.
Los modos nutricionales ahora se aplican AL TOCARLOS en tu perfil, con confirmación al instante — ya no hace falta bajar a «Guardar cambios».
El badge del modo activo vuelve a verse siempre: en «Tu Nutricionista» ahora dice «Siguiendo tu plan 📋» aunque tu plan sea un menú sin cifras (antes desaparecía).
La card de tu plan se reinventa: el menú de hoy es un checklist que se tacha solo al registrar comidas, con «ahora toca» destacado. Y Lutra ahora conoce TODO tu plan en cada análisis: la toma que tocaba, tus alergias, las indicaciones de tu nutri y cómo llevas el día. 🦦
El menú del día es un checklist vivo: cada toma se marca sola cuando registras una comida de ese momento, y la siguiente aparece como «ahora toca».
Al analizar cada comida, Lutra ahora compara con la toma EXACTA que tu nutri tenía para ese momento, vigila tus alergias/restricciones del plan y tiene en cuenta sus indicaciones y el objetivo del plan.
Una sola card del plan en el inicio (antes salía duplicado). La gestión del plan (documentos, composición corporal, actualizar) vive en tu Perfil.
El % de cumplimiento del plan ahora es EL MISMO en la card, los consejos de Lutra y la postal de cierre (antes podían diferir). Además cuenta el margen extra de tus entrenos.
Los consejos del inicio te dicen exactamente qué tomas del menú te quedan, y por la mañana te recuerdan con qué arranca tu plan.
Dos arreglos que pediste: ya puedes describir comidas muy largas sin que el análisis dé error, y los pop-ups de Lutra ahora se desplazan cuando no caben en pantalla — el botón para continuar siempre está a mano. 🦦
Describir una comida con mucho texto daba timeout: ahora la IA tiene mucho más tiempo y admite hasta 4000 caracteres (con contador cuando te acercas al límite).
Los pop-ups largos (como el análisis de Lutra o la postal del día) se quedaban cortados sin poder hacer scroll en pantallas pequeñas. Ahora todos los diálogos se desplazan y los botones siempre son alcanzables.
En dictados de voz muy largos ya no se «pule» el texto con IA si eso pudiera recortarlo: tu descripción se conserva entera.
Dos novedades con «Tu Nutricionista»: por la mañana puedes recibir un recordatorio con lo que tu nutri tenía para hoy (opcional, en Perfil → Recordatorios), y tu postal compartible de fin de día ahora luce tu % de cumplimiento del plan. 🦦
Recordatorio diario del menú: si sigues el plan de tu nutricionista, cada mañana Lutra te avisa de lo que toca hoy. Es opcional — actívalo en Perfil → Recordatorios → «Menú de tu nutri por la mañana».
Tu postal de «El resumen de Lutra» muestra el % de tu plan que has cumplido cuando estás en modo Tu Nutricionista.
Con «Tu Nutricionista» activo, los consejos del día de Lutra ahora se basan en tu plan (si vas corto de proteína, si te pasas de calorías, si te queda menú por hacer) y, al cerrar el día, te resume cuánto de tu plan has cumplido. 🦦
Los consejos de Lutra en el inicio citan tu plan: te avisa si vas corto de la proteína que marca tu nutri, si te pasas de sus calorías o si te queda menú por completar.
Resumen de cierre del día: por la noche, Lutra te dice el % de tu plan que has cumplido y qué ajustar mañana.
Con «Tu Nutricionista» activo, ahora ves un score diario de cumplimiento de tu plan (cuánto llevas de cada objetivo) y Lutra compara cada comida que añades con lo que tu nutri tenía para hoy. Inteligencia coordinada con tu plan en cada paso. 🦦
Score diario «Tu día según tu nutri»: un % global de cumplimiento + barras de calorías y macros (lo que llevas vs lo que marca tu plan) + el menú que tu nutri tenía para hoy. Es el corazón de la integración.
Cada comida que añades, Lutra la compara con tu plan: te dice si encaja con lo que tu nutri tenía para hoy y con lo que prioriza o evita. Como un nutricionista mirándote el plato.
Tu actividad cuenta como margen extra de calorías sobre el plan del día.
Tu plan del nutricionista pasa a ser un modo más — y el recomendado. Lo eliges en «Modo nutricional» (en tu perfil), subes el informe y la dieta, y Lutra ejecuta el plan: tus objetivos los marca tu profesional, y las etiquetas «ajustado por modo» de los widgets pasan a decir «Tu Nutricionista». 🦦
«Tu Nutricionista» es ahora un modo nutricional, destacado como recomendado arriba del todo en «Modo nutricional». Al subir el plan de tu nutri se activa solo, y tus objetivos pasan a salir de su plan (si trae cifras) en vez del cálculo automático.
Las etiquetas «ajustado por modo» de los widgets ahora reflejan «Tu Nutricionista» cuando tienes un plan activo, en lugar de un modo predefinido como Sport.
Si ya habías subido un plan, tu modo pasa automáticamente a «Tu Nutricionista» — sin que tengas que hacer nada.
¿Tienes un plan de tu nutricionista? Ahora puedes subirlo a la app — el informe de composición, la dieta, o ambos, en PDF o foto — y Lutra lo lee, lo entiende y lo ejecuta contigo. Si tu plan trae objetivos, mandan ellos; si es un menú, te lo guardo para seguirlo día a día. Y sí, también guardo tu cintura y demás medidas. 🦦
«Tu plan, con Lutra»: sube uno o varios documentos de tu nutri (informe + dieta, en PDF o foto). La IA los lee, los clasifica y los fusiona en un único plan, respetando al pie de la letra lo que pone — nunca se inventa cifras.
Si tu plan marca objetivos (kcal/macros), pasan a mandar sobre el cálculo automático de la app, porque tu profesional te conoce mejor. Si es un menú sin números, mantengo tus objetivos y te ayudo a seguir el menú.
Composición corporal: guardo y muestro peso, % graso, masa muscular, IMC y el perímetro de cintura (un KPI clave de salud), entre otros, si vienen en tu informe.
El análisis por foto ahora usa un motor de IA más ágil: reconoce el plato en segundos y se acabaron los cortes por tiempo de espera, incluso en platos con muchos alimentos (tablas de embutidos, picoteos, buffets). 🦦
Análisis por foto cambiado a un modelo más rápido y fiable. Antes, en platos con varios alimentos, podía agotar el tiempo de espera y fallar («IA falló»); ahora responde de forma consistente en segundos.
Arreglado un fallo por el que, al subir una foto con bastante comida, el análisis se quedaba sin terminar y caía a una estimación genérica (o directamente fallaba). Ahora la IA reconoce el plato completo sin quedarse sin tiempo. 🦦
Foto de comida: corregido el «timeout» que aparecía sobre todo en platos con muchos alimentos — el análisis se quedaba sin tiempo a mitad de identificar la comida y devolvía un error o una estimación genérica. Ahora reconoce el plato completo de forma fiable.
Pulido del escritorio: los widgets del dashboard ya no se montan unos sobre otros (mosaico reconstruido a prueba de balas), el detalle de un día del calendario es ahora un modal centrado y limpio, y Perfil y Planes aprovechan el ancho de la pantalla. En móvil, igual de bien. 🦦
Dashboard escritorio: arreglado el solape de widgets (el mosaico anterior se rompía con las animaciones). Ahora se reparten en columnas sin pisarse.
Detalle de un día (calendario): replanteado como tarjeta/modal centrado y proporcionado, en vez de un panel inferior a pantalla completa.
Perfil y Planes: ancho de escritorio en condiciones (antes quedaban en una columna estrecha).
Ya puedes poner (y cambiar) tu foto de perfil — toca tu foto en «Perfil», también desde el móvil. Y el dashboard en escritorio es ahora un mosaico que se reordena solo y rellena todo el ancho sin huecos (antes, de las comidas hacia abajo quedaba descuadrado). En móvil, todo igual que siempre. 🦦
Foto de perfil: ponla o cámbiala tocando tu avatar en «Perfil» (móvil y PWA incluidos). Se recorta y optimiza sola y aparece en toda la app.
Dashboard de escritorio: mosaico responsive de verdad — los widgets se reequilibran en 2-3 columnas y rellenan el ancho sin huecos ni descuadres.
Calendario «Tu diario»: al abrir el detalle de un día en escritorio, el botón «Añadir» ya no se estiraba a toda la pantalla; el panel queda centrado y proporcionado.
Perfil con cabecera renovada (tu foto + datos) y mejor aprovechamiento del espacio.
Si entras desde ordenador, la app ahora se siente como una app de escritorio: barra de navegación lateral fija, y cada pantalla aprovecha el ancho. El dashboard es un bento de tiles (ya no una columna larga de Comidas hacia abajo), e Histórico, Logros y el panel de Admin dejan de verse como un móvil estirado. En el móvil todo sigue exactamente igual. 🦦
Escritorio (≥1024px): rail de navegación lateral fijo (Hoy/Historial/Logros/Perfil/Planes + tu cuenta) en lugar del menú hamburguesa.
Dashboard: bento real de tiles a 2 columnas en los widgets de análisis/progreso (antes se desplomaba a una sola columna a partir de las comidas).
Histórico: calendario y Progreso lado a lado; Logros con rejilla más densa; panel de Admin a todo el ancho con la tabla de clientes sin recortes.
Móvil y plegables: sin cambios, idénticos que antes.
Ahora el ejercicio cuenta en todo: al añadir una actividad puedes elegir la HORA a la que la hiciste, la app te lleva con un scroll suave a tu zona de actividad y lo celebra con confeti 🎉. Y lo mejor — Lutra ya tiene en cuenta lo que has quemado hoy: su opinión tras cada comida, los consejos del día y hasta las preguntas sugeridas del chat se adaptan a tu margen real. 🦦
Al añadir una actividad puedes elegir la hora a la que la realizaste (en el dashboard y en el calendario «Tu diario»). La hora se muestra en cada registro.
Al registrar una actividad, la app hace scroll automático a tu zona de actividad del dashboard y lo celebra con confeti 🎉.
La opinión de Lutra tras añadir una comida ahora considera las calorías que has quemado hoy con ejercicio, para darte mejores indicaciones.
Los consejos del día y las preguntas sugeridas del chat de Lutra también tienen en cuenta tu actividad: cuando te has movido, te orienta sobre cómo aprovechar ese margen y recuperar.
En pantallas grandes la home dejaba de parecer un móvil estirado: ahora es un panel a dos columnas — tu anillo de calorías + «Registrar» + tips fijos a la izquierda (siempre a la vista), y el resto de widgets (modos, comidas, macros, actividad, peso, logros…) en el feed de la derecha. En móvil y plegables, exactamente igual que antes. 🦦
Dashboard adaptado a escritorio: lienzo ancho + 2 columnas (rail de foco con el anillo a la izquierda fija, feed de widgets a la derecha). En móvil/plegables se apila idéntico.
Arreglado un fallo molesto: al registrar platos que se desglosan en ingredientes (sushi, pizza, hamburguesa…), a veces aparecían líneas repetidas (arroz sushi ×2, alga nori ×3…). Ahora se funden siempre en UNA sola línea por alimento, sumando cantidades, calorías y macros. Da igual cómo la IA trocee el plato: el resultado sale limpio. 🦦
Ingredientes duplicados al desglosar platos compuestos: ahora se consolidan en una sola línea por alimento (cantidad, kcal, macros y micros sumados). Determinista — pase lo que pase con la IA, no se repiten.
El detalle de cualquier día del calendario «Tu diario» ya es completo también con la actividad: puedes AÑADIR (con presets de correr/bici/gym… o a mano), EDITAR o BORRAR las actividades de ese día, igual que ya hacías con las comidas. 🦦
Actividad editable desde el detalle del día: añadir (presets o manual con kcal/minutos/descripción), editar y borrar actividades de cualquier día.
El detalle del día ahora muestra bien la descripción de cada actividad (antes leía un campo que no existía y siempre ponía «Actividad»).
He ajustado la versión de escritorio para que no se vea todo estirado: el contenido ahora vive en una columna centrada y cómoda, en lugar de ensancharse a lo largo de toda la pantalla. En móvil y plegables no cambia absolutamente nada. 🦦
Escritorio: el contenido se centra en una columna cómoda (en vez de estirarse a todo el ancho). En móvil y plegables, idéntico que antes.
El calendario «Tu diario» se completa: ahora puedes EDITAR o BORRAR cualquier comida de cualquier día, y RELLENAR los días que se te olvidaron (con voz, foto o texto) — no solo el de hoy. Y abajo tienes tu progreso: el déficit del mes con su equivalente en kilos, el balance diario en barras, y cómo se traduce en tu peso real, con Lutra interpretándolo. 🗓️
Editar y borrar comidas de cualquier día desde el detalle del calendario (lápiz para editar · papelera para borrar).
«Añadir a este día»: registra comidas en días pasados (backfill) por voz, foto o texto, no solo en el de hoy.
Bloque de progreso: déficit acumulado del mes (≈ kg), balance diario en barras (verde déficit · rojo superávit) y comparación déficit-esperado vs peso real, con la lectura de Lutra.
He reinventado el histórico como un calendario mensual donde cada día cuenta tu historia de un vistazo: su color te dice si comiste en objetivo/déficit (verde), te pasaste un poco (ámbar) o bastante (rojo), con marcas de comidas, ejercicio y pesaje. Toca cualquier día para ver el detalle completo — anillo de calorías, macros, todo lo que comiste por momentos, actividad, suplementos y peso — con Lutra comentando. 🦦
Histórico rediseñado como calendario «Tu diario»: rejilla mensual con color de adherencia por día (objetivo/déficit · algo pasado · muy pasado · sin datos), micro-indicadores (comidas, ejercicio, pesaje) y navegación de meses.
Hoja de detalle al tocar un día: anillo de calorías (consumido/objetivo/quemado), macros, comidas agrupadas por momento, actividad, suplementos y peso, con la nota de Lutra del día.
Diseño creado en Claude Design sobre el design system de Lutra: cálido, redondeado y coherente con el resto de la app.
Mejoras internas para que la app aguante como una campeona cuando lleguen muchos usuarios nuevos. He añadido un sistema de uso justo de la IA — límites diarios muy holgados, pensados solo para frenar abusos — que mantiene el servicio rápido y disponible para todos. 🦦
Uso justo de la IA: topes diarios muy por encima del uso normal en el análisis de comidas (voz, texto, foto) y suplementos, para proteger el rendimiento y la disponibilidad del servicio de cara al lanzamiento.
Condiciones de contratación actualizadas: reflejan el uso justo y aclaran el periodo de prueba (15 días con acceso completo, sin tarjeta, ampliable dejando una reseña).
La tarjeta que se genera al compartir un logro pasa a formato vertical 9:16 (1080×1920), perfecto para stories de Instagram y TikTok, con el nuevo sistema de diseño de Lutra: fondo cálido con ráfaga de rareza, medallón de rareza (brote → chispa → llama → corona), categoría + dato en pastilla, título grande, y tu foto + nombre firmando abajo. 🦦
Insignia compartible rediseñada a 9:16 (sistema creado en Claude Design con el design system de Lutra): ráfaga radial por rareza, medallón circular con icono de rareza, categoría + dato, y pie con foto+nombre y wordmark.
De momento usa las poses dibujadas actuales como héroe; el set de ilustraciones únicas por logro llegará en una próxima tanda.
He rehecho la tarjeta que se genera al compartir un logro: adiós al borde biselado sobre fondo blanco y a los huecos vacíos. Ahora es una postal cálida a sangre, con su marco elegante, textura sutil de fondo, emblema de rareza, tu dato en una pastilla con relieve y tu foto + nombre firmando abajo. Y de paso he eliminado del todo el sistema de logros ANTIGUO que aún hacía saltar tarjetas con el diseño viejo. 🦦
Tarjeta compartible nueva: fondo crema→melocotón a sangre (sin recortes blancos), marco cálido, textura geométrica, emblema de rareza (escudo + gema), pastilla del dato con relieve 3D y tu foto + nombre como firma.
Eliminado por completo el sistema de logros antiguo (badges fríos): ya no saltan tarjetas con el diseño viejo. Borrados sus componentes, eventos y lógica; los logros viven solo en la pantalla de Logros.
La imagen que se genera al compartir un logro ahora lleva tu foto de perfil y tu nombre como firma abajo — así queda claro que ese logro es TUYO. Mucho más personal para presumir en stories. 🦦
La insignia compartible incluye tu foto de perfil (circular) y tu nombre como firma. Si tu foto no se puede usar, cae con elegancia a tu inicial.
Antes, al compartir un logro solo salía una frase y un enlace. Ahora lutrAI genera una insignia vertical preciosa —con tu Lutra, el título, la frase y el dato— lista para subir a stories de Instagram, WhatsApp o donde quieras. Toca un logro conseguido, dale a Compartir y se genera la imagen al momento. 🦦✨
Compartir logros ahora genera una imagen vertical (1080×1350) con el diseño de la insignia: Lutra, rareza, título, frase y stat. Se comparte como archivo de imagen (con descarga de respaldo si tu navegador no lo soporta).
La imagen se prepara nada más abrir el logro, así al pulsar Compartir sale al instante. Las rachas se dibujan con su llama y su moneda; el resto, con su pose dibujada a mano.
Dos cosas. Primero, arreglado el descuadre de Logros en pantallas muy estrechas (como la portada del Galaxy Z Fold): ya no se pega todo a los bordes, cada tarjeta respira. Y segundo, ¡he terminado de conectar TODOS los triggers! Los logros que antes se quedaban bloqueados a la espera de datos —primera voz/foto, triplete, rachas de semana/mes (en balance, de micros, limpias), días activos por semana y mes, báscula fiel, suplementos constantes, vuelta al ruedo, finde redondo, trotamundos, fiel a tu plan, charlatana/mano derecha del coach y la reseña— ahora se desbloquean solos a medida que usas la app. 🏆
Responsive de Logros en pantallas estrechas (Z Fold 6 cover, ~376px): el margen a pantalla completa ya respeta el padding real del layout, así que el contenido deja de recortarse contra los bordes.
Triggers completados: voz/foto/triplete, hola Lutra, charlatana y mano derecha (preguntas al coach), explorador/trotamundos/fiel a tu plan (modos) y la reseña — rastreados en el dispositivo.
Rachas históricas reales: semana en balance, semana de micros, semana/mes limpios, días activos por semana y mes, báscula fiel (4 semanas), suplementos 7 días seguidos, vuelta al ruedo y finde redondo — calculados desde tu historial.
La sección de Logros estrena cara nueva. Ahora son ~50 logros repartidos en 12 categorías (primeros pasos, racha, constancia, macros, micros, comida real, actividad, peso, suplementos, modos, coach y especiales), cada uno con su Lutra dibujada a mano o su escenita, su rareza (común → raro → épico → legendario) y una barra de progreso REAL: se desbloquean solos según lo que vas haciendo en la app (tu racha, comidas, macros en rango, micros, días de comida real, kilos bajados, actividad…). Arriba ves tu último logro destacado y listo para compartir, un resumen por rareza y filtros por categoría. Toca cualquier logro conseguido y tienes una insignia bonita para presumir. 🦦
Nueva pantalla de Logros: destacado del último logro, resumen por rareza, filtros por categoría y tarjetas con la pose de Lutra (o su escena de props para las rachas).
~50 logros en 12 categorías con lógica de triggers conectada a tus datos reales — se desbloquean automáticamente y muestran tu progreso (% y x/y) en los que aún te faltan.
Al pulsar un logro conseguido aparece una insignia compartible (estilo sticker) con su frase de Lutra; los pendientes muestran un anillo de progreso.
Lutra empieza a aparecer con más protagonismo y personalidad por la interfaz. He preparado un set de 16 poses suyas (saludando, pensando, corriendo, con corazones, dando una idea…) y las estoy repartiendo por modales, ventanas y estados vacíos para que la app se sienta más viva y con su carácter de siempre. En este primer lote la verás en: el popup de novedades (esta misma ventana 👋), al añadir actividad (corriendo), al añadir peso (mirando tu progreso), en la invitación a Premium, y cuando aún no has registrado actividad del día. Iré sumándola al resto poco a poco. (Las tarjetas de logros las dejo intactas a propósito — hay algo especial en camino para ellas.)
Nuevo set de 16 poses expresivas de Lutra + componente reusable para colocarlas en cualquier parte de la interfaz con coherencia.
Primer lote con personalidad: Novedades (Lutra con una idea), Añadir actividad (corriendo), Añadir peso (mirando tu progreso), Premium (encantada) y el estado vacío de actividad.
Las poses se cargan solo cuando se ven (~20 KB cada una) y pegan con el crema de marca. Más rincones con Lutra llegarán en próximas versiones.
Dos arreglos de sensaciones. 1) El splash del sistema (lo que pinta Android al abrir la app instalada) no casaba con la web: usaba un crema ligeramente distinto al del fondo real y, en Android moderno, metía un círculo blanco detrás de la mascota. Ahora el color de fondo es EXACTAMENTE el crema de la web (#F8F4E3, sin saltos al cargar) y la mascota va en un icono 'maskable' sobre crema, así se ve centrada y limpia. 2) Los preloaders: antes la barra llegaba al 92% en ~6 segundos y se quedaba ahí clavada (sobre todo en fotos, que tardan más) — daba sensación de falso/atascado. Ahora el progreso va por tiempo real y CALIBRADO a lo que de verdad tarda cada cosa (texto rápido, foto más despacio), y los mensajes son más amenos y cuentan lo que está pasando de verdad (cazar ingredientes, pesar raciones a ojillo de nutria, nota de comida real NOVA…).
Splash del sistema (Android PWA): `background_color` del manifest pasa de #F8F0DB a #F8F4E3 — el crema EXACTO del fondo de la app, así no hay salto al cargar. Añadido icono `maskable` (mascota sobre crema) para que Android 12+ la muestre centrada y nítida en vez de con un círculo blanco detrás.
Preloaders calibrados por tiempo real: la barra ya no pega el tirón al 92% en 6s. Sube a un ritmo asintótico ajustado a la duración típica de cada acción (análisis de texto/voz ágil, foto más pausada porque usa visión a máxima precisión).
Mensajes de los preloaders reescritos para ser divertiinformativos y fieles a la realidad: reflejan los pasos que de verdad ocurren (detectar ingredientes, estimar ración, kcal/macros, vitaminas y minerales, clasificación NOVA, razonamiento) con el tono cercano de Lutra.
Algunos registros por texto daban error de timeout, sobre todo en conexiones móviles. La causa: el análisis se había vuelto pesado (Lutra escribía una nota por cada ingrediente Y además un párrafo de razonamiento largo que la app ni siquiera usaba) y rozaba el límite de tiempo. Lo he arreglado y blindado: el razonamiento interno ahora es de una sola frase (la explicación que TÚ lees por ingrediente sigue intacta), puse un tope de tokens para acotar la latencia, y amplié los márgenes de tiempo del cliente (55s) y del servidor (45s) para que una respuesta válida nunca se corte. Si alguna vez vuelve a fallar, el botón te invita a reintentar al instante.
Análisis de comida por texto: eliminado el timeout intermitente. El razonamiento interno (que la app descartaba) ya no se genera largo → respuesta más rápida.
Tope de tokens de salida en el análisis de texto (acota el peor caso de latencia) y márgenes de tiempo coherentes: cliente 55s > servidor 45s.
Las notas por ingrediente que lees (el criterio real de Lutra) y la clasificación NOVA se mantienen igual de detalladas.
Novedad bonita para rematar: ahora puedes convertir tu día en una postal preciosa y compartirla donde quieras. Pulsa "Mi día" arriba en el panel (aparece en cuanto registras algo hoy) y Lutra te genera al instante una tarjeta con tu anillo de calorías, tus macros, tu Real Food Score, tu racha y una frase suya personalizada según cómo te ha ido. Compártela por WhatsApp, historias de Instagram o guárdala como imagen. Cada postal lleva la marca lutrAI.app — comparte tu progreso y presume de constancia.
"El resumen de Lutra": botón "Mi día" en el panel que genera una postal vertical (9:16) lista para compartir. En móvil abre el menú nativo de compartir; en escritorio la descarga como imagen.
La postal usa la paleta crema de la marca y reúne en una sola imagen tu anillo de calorías (consumido vs objetivo, coloreado por estado), las 3 barras de macros, el Real Food Score (NOVA), tu racha y una burbuja con la frase de Lutra.
La frase de Lutra se adapta a lo más destacable de tu día (racha, % de comida real, dar en el objetivo, proteína cubierta…). Es siempre factual y celebrativa — nunca consejo médico.
Arreglo de maquetación que viste: en el widget de calorías había DOS iconos de "Fuente" sueltos y ambiguos colgando del título "Presupuesto de hoy", y aparte otra fila "Referencias" en los macros — dos patrones distintos para lo mismo. Ahora hay UNA sola fila de fuentes al pie del widget, centrada y con etiqueta por chip (Base · Actividad · Macros · Proteína), así sabes qué cita cada una de un vistazo. Y los tooltips del anillo (base, ejercicio, hoy, y cada macro) se han reescrito a un tono uniforme y claro: qué es y para qué, sin jerga ni citas sueltas (esas viven ahora en la fila de fuentes).
Evidencias científicas del widget de calorías: eliminados los iconos "Fuente" sueltos del encabezado; todas las fuentes (Base, Actividad, Macros, Proteína) en UNA fila etiquetada al pie. En móvil hace wrap limpio.
`ScienceRef` admite ahora una etiqueta corta opcional, para que varias fuentes en fila se distingan (antes eran iconos idénticos).
Tooltips del anillo de calorías unificados a un formato consistente y cercano (base, ejercicio, total de hoy, proteína, carbos, grasa).
Continuación de v1.21.28. Las fuentes científicas de los widgets generales (el botón "Fuente" en calorías, IMC, macros, micros, NOVA, fibra…) abrían su ventanita con el título del paper EN INGLÉS de primero. Ahora abren con una frase clara en español de QUÉ dice (p. ej. "Cómo se calcula tu metabolismo basal", "Cuánta proteína al día si haces ejercicio", "Qué significan los rangos de IMC"), con el título original del estudio debajo en pequeño y el enlace al paper intacto. Mismo trato que dimos a los modos: se entiende sin esfuerzo y sin perder rigor.
`science-refs.ts`: las 10 referencias (NOVA, Mifflin-St Jeor, FAO/OMS, IMC OMS, AMDR/IOM, ISSN proteína, EFSA micros, EFSA hidratación, OMS actividad, IOM fibra) tienen ahora un `resumen` en español que es lo primero que se lee.
`ScienceRef` (popover "Respaldo científico"): titular = resumen en español; el título original del paper queda como línea pequeña y el enlace lleva a la fuente oficial.
Las fuentes científicas de cada modo (Sport, Keto, Diabetes, Vegano, Mediterráneo, Cardio, Sin-azúcar) salían con el título original del paper EN INGLÉS de primero — distraía y confundía. Ahora cada evidencia ABRE con una frase clara en español de QUÉ DICE (p. ej. "Cuánta proteína necesitas para construir músculo", "La dieta mediterránea reduce el riesgo cardiovascular — estudio PREDIMED"), con el organismo en español (OMS, ADA, AHA, ESC, ISSN…) y el año debajo. El paper original sigue a un toque (el enlace no cambia), así que se entiende a la perfección sin perder un gramo de rigor.
Las ~30 referencias científicas de los 7 modos ahora lideran con un resumen en español (qué demuestra) en vez del título del paper en inglés. Organismos traducidos (OMS, Asociación Americana de Diabetes, Sociedad Europea de Cardiología…).
`ScienceRef` gana un campo `resumen`; `ModeScienceCard` muestra el resumen como titular y el organismo + año como fuente. El enlace lleva al paper original (rigor intacto).
Dos cosas. (1) PROMPT CACHING activado en el análisis de comida: el prompt de instrucciones (grande y fijo) ahora se cachea, así que cuando hay varios análisis seguidos, Anthropic lo cobra a ~0,1× en vez de 1×. A medida que sube el uso, recorta el coste de IA por foto/texto notablemente (a bajo volumen es neutro). (2) Repaso de TODA la personalidad de Lutra: confirmado que sus consejos del día y los de cada modo (Sport, Keto, Diabetes…) son recomendaciones sólidas y contextuales (reaccionan a tu ingesta, objetivos, hora y modo) — sin pifias tipo la de la gula, que era un bug de clasificación, no de consejo. Y cerrado un hueco: el saludo de Lutra ya tenía tu modo a mano pero no lo usaba; ahora también te saluda teniendo en cuenta tu modo activo.
Prompt caching (Anthropic ephemeral) en `analizar-comida`: el system prompt + tools se cachean. Ahorro de coste por llamada que crece con el tráfico. Requiere deploy del edge.
Saludo de Lutra: ahora personaliza también por el MODO activo (antes el dato estaba disponible pero no se usaba). Aparece ~la mitad de las veces para no hacerse repetitivo, y nunca pisa un hito de racha o progreso de peso.
Auditoría de personalidad de Lutra: consejos del día (coach.ts) y reglas por modo (nutrition-modes) verificados — heurística pero contextual y con respaldo científico, sin afirmaciones inventadas. La inteligencia real (analizar comida, coach, razonamiento) sigue siendo IA.
Cierre del único hueco real de la promesa "IA al mando". Hasta ahora el panel "Lo que he pensado" lo generaba una heurística client-side (plantillas + regex) — por eso pudo decir que la gula es una bebida. Lo absurdo: la IA YA escribía un razonamiento que tirábamos a la basura. Ahora la IA devuelve una `nota` por cada alimento (qué es realmente + qué asumió) y Lutra la muestra tal cual. La IA SÍ sabe que la gula del norte es surimi, que las gambas son marisco, etc. La estructura rica (kcal, micros, desglose) se mantiene; lo que cambia es que el texto de cada ítem ya es criterio real de la IA, no una plantilla. Si por lo que sea no llega nota (ítems antiguos o sub-ítems de un plato descompuesto), cae a la versión heurística honesta de v1.21.25. Coste extra: ~0 (un par de frases más por análisis).
`analizar-comida`: la herramienta `registrar_comida` ahora pide a la IA una `nota` por ítem (frase corta, en primera persona, sin citas inventadas, diciendo qué es y qué asumió). Vale para texto, voz y foto.
`buildLutraReasoning`: usa la `nota` de la IA como razonamiento de cada alimento; solo cae a la plantilla heurística cuando no hay nota. Así el panel "Lo que he pensado" es de verdad la IA pensando.
Coherencia con la promesa de producto: lo que se presenta como inteligencia de Lutra ahora lo produce la IA, no reglas. (Requiere deploy del edge `analizar-comida`.)
Iñaki cazó un fallo feo: en "lo que he pensado" salía 'Gula del norte — 60 g de bebida, según el aporte por mililitro' (¡la gula es surimi, un sólido!) y 'Gambas — de pescado' (son marisco). Dos causas: (1) un bug de regex que cazaba el 'te' final de 'nor-te' y la clasificaba como BEBIDA, y (2) el marisco metido en el cajón de 'pescado'. Pero el problema de fondo era peor: cada línea inventaba una cita de fuente ('según composición media BEDCA', 'USDA: ~143 kcal/100g', 'por mililitro habitual') que la app NUNCA consultó — y al clasificar mal soltaba datos absurdos con total seguridad. Arreglado de raíz: clasificación corregida (+ categoría 'marisco') y textos HONESTOS que solo dicen lo que de verdad sabemos (ración asumida + que es estimación), sin citas inventadas. Datos generales ciertos (9 kcal/g del aceite, ~58g del huevo) se mantienen.
Bug de clasificación: 'gula del norte' ya no se toma por bebida (un \b mal puesto cazaba el 'te' de 'norte') y las gambas/calamares/mejillones ahora son 'marisco', no 'pescado'.
Razonamiento HONESTO: se eliminan las citas de fuente inventadas por ítem ('según composición media BEDCA', 'por mililitro habitual del tipo', 'USDA: ~143 kcal/100g'). Ahora se dice solo lo verificable: la ración asumida y que las kcal son una estimación. La referencia a tablas (BEDCA/USDA) queda una sola vez en el pie.
Aunque un alimento raro se clasifique mal, ya nunca soltará un dato absurdo con seguridad fingida — el peor caso es una etiqueta de categoría algo genérica, no 'X g de bebida por mililitro'.
Revisión global de protección legal/médica con nuevas capas para que nadie pueda confundir lutrAI con consejo médico. (1) Nota PERSISTENTE al pie de toda pantalla y en la landing: 'herramienta educativa, no consejo médico'. (2) Lutra (coach IA) lleva ahora un aviso fijo visible en el chat Y su prompt está reforzado: nunca se presenta como médico, no diagnostica ni da pautas para tratar enfermedades, y redirige cualquier tema clínico a un profesional. (3) El aviso bloqueante se ha reforzado ('finalidad educativa', 'no diagnostica, trata, cura ni previene ninguna enfermedad') y la aceptación SE REGISTRA EN SERVIDOR (no solo en el navegador) como constancia real de consentimiento informado. Como el texto cambió, todos vuelven a aceptarlo una vez.
Nota legal/médica PERSISTENTE al pie de todas las pantallas de la app y en la landing: 'herramienta educativa · no es consejo médico', con enlace al Aviso médico.
Coach IA: aviso fijo en la cabecera del chat ('Lutra orienta de forma educativa; no da consejo médico') + prompt del edge reforzado (no se presenta como sanitario, no diagnostica/prescribe, redirige lo clínico a profesional, maneja con cuidado señales de conductas de riesgo).
Aviso médico bloqueante reforzado: 'finalidad educativa e informativa', 'no diagnostica, trata, cura ni previene ninguna enfermedad'. Versión subida a 2 → todos lo re-aceptan una vez.
Consentimiento registrado EN SERVIDOR (tabla `legal_consents`, append-only: user_id, versión, fecha y user-agent) además de localStorage — constancia real de consentimiento informado ante reclamaciones.
Diagnóstico gordo del "error timeout" al subir foto: hasta ahora la visión NUNCA llegaba a usar Opus 4.7 — agotaba su tiempo (60s), caía en silencio a Sonnet, y encima el reloj de seguridad (90s) saltaba antes de que llegara la respuesta legítima. Iñaki eligió apostar por la MÁXIMA precisión de Opus, así que: (1) le hemos dado margen real para terminar (90s, dentro del límite de 150s del servidor), y (2) hemos hecho coherentes TODOS los relojes (subida 15s, firma 8s, invoke 145s, safety 165s) para que una foto que va a responder no se corte jamás. Resultado: Opus 4.7 analiza la foto de verdad y al máximo detalle. Tarda más (~1-1,5 min con su animación de progreso), pero ya no falla.
Timeout de foto resuelto de raíz. El "safety" de 90s envolvía refresh+subida+firma+invoke, así que una respuesta legítima de ~80s saltaba como "Tardó demasiado" aunque llegara OK. Ahora safety 165s e invoke 145s — coherentes con la cadena real del edge (≤120s).
`analizar-comida`: budget de visión Opus 4.7 subido de 60s a 90s. El gateway de Supabase corta a 150s, así que download(6)+Opus(90)+fallback(18)=114s con margen. Por fin Opus COMPLETA el análisis en vez de caer siempre a Sonnet.
Cap del retry-por-items-vacíos a 30s para que no se encadenen dos llamadas Opus de 90s y se rebase el límite de 150s del servidor.
El nivel "wow" que pediste. Antes Lutra abría el análisis de foto con una descripción heurística deducida de los ingredientes ("parece una ensalada"). Ahora Opus 4.7 — que SÍ ve la foto — devuelve una descripción real y específica del plato y Lutra abre con ella: "Una ensalada César con pollo a la parrilla, picatostes y virutas de parmesano", "Un bocadillo de jamón serrano en pan de cristal con tomate", "Un bol de açaí con plátano, arándanos y granola". Si por lo que sea no llega la descripción de visión, cae a la heurística de v1.21.21 (nunca se queda sin intro). ⚠️ Requiere deploy del edge function `analizar-comida` (vía agente de Lovable).
`analizar-comida` (vision): nuevo campo `descripcion` en el tool + instrucción a Opus 4.7 de identificar el plato en 1 frase concreta y apetitosa (no lista de ingredientes). Solo foto.
`buildLutraReasoning`: para foto usa la `descripcion` real de Opus como intro si llega; si no, cae a `inferDishType` heurístico (v1.21.21). `invokeAnalizarComida` propaga el campo; VoiceMealDialog lo pasa.
Verificado el flujo de micros end-to-end: se guardan en `meal_items.micros`, el dashboard los suma en `totals.micros` y el widget `MicrosRadial` los muestra vs objetivo. Las marcas canónicas (v1.21.21) ya aportan sus micros oficiales al total del día.
Dos mejoras de inteligencia que pidió Iñaki. (1) MICROS en las reflexiones: el razonamiento de Lutra ahora menciona los micronutrientes notables de cada alimento ("buena fuente de calcio (160mg)", "ojo con el sodio (1007mg)"), no solo kcal y macros. Y al canonicalizar marcas (Big Mac, etc.) ya no perdemos los micros — llevan el sodio oficial. (2) En el análisis de FOTO, Lutra ahora ABRE describiendo qué tipo de plato ve ("esto tiene pinta de ser una ensalada", "un bocadillo o sándwich", "un surtido de dulces") ANTES de desglosar ingredientes — para demostrar que de verdad entiende la foto, no solo lista cosas.
`lutra-reasoning`: cada item menciona sus micros notables (hierro, calcio, vitC, potasio, magnesio, vitD como "buena fuente de"; sodio alto como aviso). Forma parte de las reflexiones, como pediste.
`lutra-reasoning`: para FOTO, `inferDishType` deduce el tipo de plato de los items (ensalada, bocadillo, pasta, bowl de arroz, pizza, hamburguesa, surtido de dulces, desayuno) y Lutra abre con "esto tiene pinta de ser X" antes del desglose.
`brand-foods-db` + `canonicalizeBrandedMeal`: las marcas canónicas (Big Mac, Whopper, Cuarto de Libra, McNuggets) ahora llevan micros oficiales (sodio, calcio) y se conservan al colapsar — antes el item canónico salía con micros vacíos.
Bug recurrente cazado de raíz. Al reabrir la app (PWA resume), el widget principal de calorías aparecía a 0. Causa exacta encontrada: la lectura de las comidas de HOY se hacía con un JWT que tras el resume podía estar caducado → PostgREST devolvía 401 → el código caía a `meals = []` (un fallback pensado para 'no romper') → el anillo mostraba 0 kcal → y React Query lo CACHEABA como ÉXITO, así que nunca reintentaba. De ahí que solo se arreglara con refresco manual. Los fixes anteriores (v1.21.14) no cubrían este caso porque la query 'tenía éxito' con datos vacíos. Ahora: (1) refrescamos el token ANTES de leer, y (2) si la lectura crítica de comidas falla, LANZAMOS para que React Query reintente con backoff en vez de cachear un 0 falso.
`dashboard.tsx`: refresco proactivo del token (`supabase.auth.refreshSession()` con timeout duro de 4s) ANTES de las lecturas raw. En PWA resume el JWT suele estar caducado; sin refrescar, las lecturas daban 401.
`dashboard.tsx`: si la lectura CRÍTICA de comidas de hoy falla (`!mealsHoy.ok`), el queryFn LANZA `DASHBOARD_MEALS_FETCH_FAILED` en lugar de devolver `meals=[]`. React Query reintenta con backoff (1s/2s/4s) — con el token ya refrescado normalmente triunfa y el widget se rellena solo. `placeholderData:keepPreviousData` mantiene lo anterior visible durante los reintentos. El toast de error se silencia para este caso transient.
Dos arreglos del feedback de Iñaki. (1) Al añadir un producto icónico como un Big Mac, la app duplicaba ingredientes: la IA devolvía una descomposición genérica de "hamburguesa" Y otra específica del Big Mac, y el cliente re-descomponía encima. Ahora, para productos de fast-food con datos oficiales conocidos (Big Mac, Whopper, Cuarto de Libra, McNuggets), la app los reconoce y los registra como UN ÚNICO item con las calorías OFICIALES del fabricante — limpio, sin duplicar, y mostrando inteligencia ("sé que un Big Mac son 504 kcal según McDonald's"). Sigue descomponiendo los platos caseros/genéricos como antes. (2) La subida de fotos daba timeout: la resolución 1568px (v1.21.8) hacía a Opus 4.7 demasiado lento y el cliente cortaba a 70s antes de que respondiera.
`canonicalizeBrandedMeal` en recipe-decomposer: detecta productos icónicos de fast-food (Big Mac, Whopper, Cuarto de Libra, McNuggets) desde la transcripción/nota + nombres de items, y los colapsa a UN item oficial (kcal + macros del fabricante en `brand-foods-db`). Salta si hay extras de combo (bebida/patatas/postre) → entonces deja la descomposición normal. Mata la duplicación de ingredientes.
`brand-foods-db`: añadidos macros oficiales (P/C/G/fibra) + flag `canonicalFastFood` a Big Mac (504 kcal), Whopper (657), Cuarto de Libra (521) y McNuggets (272). El razonamiento de Lutra cita la fuente oficial del fabricante.
Timeout de foto: resolución bajada de 1568px → 1280px (sigue siendo 6× la miniatura vieja, pero ~35% menos tokens visuales → Opus 4.7 más rápido) + timeout del cliente subido de 70s → 85s y safety 75s → 90s. La cadena del edge (Opus 4.7 + fallback Sonnet, hasta 72s) ahora cabe sin que el cliente corte antes.
Tres arreglos del feedback de Iñaki: (1) el chip "Deja tu reseña" volvía a salir al cerrar y abrir la app aunque ya hubiera reseña — el flag local de v1.21.11 no existía para quien reseñó ANTES, así que ahora hacemos BACKFILL: la primera vez que la query confirma que hay reseña en BD, persistimos el flag → cold-opens futuros ocultan el chip síncrono al montar. (2) El panel de costes de /admin se quedaba en "Cargando gastos de API…" para siempre: el bug era que si el server fn fallaba (data undefined), la UI lo trataba como carga infinita en vez de error. Ahora distingue carga real de error y muestra el motivo exacto del fallo. (3) Añadida columna "Coste IA" en la tabla principal de usuarios (además de la tabla "Top usuarios por gasto" que ya existía abajo).
`FloatingReviewButton`: backfill del flag `lutrai_review_submitted_at` cuando la query devuelve una reseña existente. Arregla "cierro y abro y vuelve a salir el botón" para usuarios que reseñaron antes de que existiera el flag (v1.21.11). Una vez la query confirma la reseña una vez, el flag persiste y el chip se oculta al instante en todos los arranques futuros.
`ApiCostsSection` en admin: separa estado de CARGA (isPending+fetching) de ERROR (data undefined sin fetching). Antes `loading || !summary` dejaba "Cargando…" eterno cuando el server fn fallaba. Ahora muestra una tarjeta roja con el mensaje de error real del server fn (Forbidden / tabla no migrada / timeout SDK) para diagnóstico directo.
Columna "Coste IA" en la tabla principal de usuarios del panel admin: muestra el gasto del mes por usuario (lookup desde `adminGetApiCostsByUser`). Lo que Iñaki esperaba ver como columna, además de la tabla top-30 de abajo.
La naturalización IA (v1.21.15) y el tracking de costes (v1.21.9) NO funcionaban porque viven en las edge functions y Lovable no las redeploya automáticamente — quedaban atascadas esperando un deploy manual. Solución pragmática: la parte más impactante de la normalización (convertir números hablados a cifras) ahora se hace 100% CLIENT-SIDE, sin IA y sin deploy. "Doscientos cincuenta gramos de pollo" → "250 gramos de pollo", "treinta y cinco" → "35", "media taza" → "1/2 taza". Se aplica al pulsar Stop y antes de analizar, así que mejora YA con el deploy normal de Lovable. Además: la tabla de gastos por usuario ya estaba construida (v1.21.10) — se llenará en cuanto deployes las edge functions. Y he documentado el flujo de deploy en supabase/functions/DEPLOY.md para no volver a quedarnos atascados.
`normalizeSpanishNumbers` en `meal-input-utils.ts`: parser de números cardinales españoles 0-9999 (incluye compuestos "doscientos cincuenta", conector "y", "medio/media" + unidad → "1/2"). Conservador: NO toca "un"/"una" (artículos), solo cantidades inequívocas. Integrado en `normalizeTranscript` que ya corre al Stop del dictado y antes de analizar.
`supabase/functions/DEPLOY.md`: documentación del flujo de deploy manual de edge functions (CLI + dashboard) + checklist + cómo verificar. Cierra una tarea pendiente desde hace tiempo. Lovable NO redeploya edge functions — esto explica cómo hacerlo.
El gasto por usuario en /admin ya estaba implementado (v1.21.10): top 30 usuarios por coste del mes con desglose 📸📝💬💊 + tokens. Solo necesita que las edge functions estén deployadas para tener datos que mostrar.
Iñaki reportó que los costes en /admin estaban a 0. La causa más probable: las edge functions no se han redeployado tras v1.21.9 (Lovable NO las redeploya automáticamente — hay que pulsar Deploy en Supabase a mano). Antes el panel mostraba zeros sin explicación y parecía un bug del panel. Ahora distinguimos tres estados con mensajes específicos: (1) tabla no migrada → "espera al deploy de Lovable", (2) SERVICE_ROLE_KEY ausente en server fn → "añade la variable en Lovable Cloud Settings", (3) tabla migrada pero vacía → "redeploya las edge functions en Supabase Dashboard". El admin sabe en 5 segundos qué arreglar.
`admin.functions.ts`: el server fn ahora distingue `data === null` (mock por SERVICE_ROLE_KEY ausente) de `data === []` (tabla migrada pero vacía). Devuelve un campo `diagnosis: "table-missing" | "no-service-role"` cuando algo va mal.
`ApiCostsSection` en `admin.tsx`: banner amber con mensaje específico según el diagnosis. Para tabla migrada pero vacía: explica que las edge functions necesitan deploy manual en Supabase Dashboard y dónde hacerlo. Para SERVICE_ROLE_KEY ausente: explica dónde añadirla en Lovable Cloud Settings.
El panel ya no engaña al admin con $0.00 silencioso — siempre dice POR QUÉ está vacío y QUÉ hacer.
Las heurísticas de gap (v1.21.13) ayudan pero no son suficientes. Ahora cuando pulsas Stop, una llamada a Claude Sonnet 4.6 (~1.5s) post-procesa la transcripción: añade puntuación natural en español, capitaliza correctamente, convierte números hablados a cifras ("doscientos gramos" → "200 gramos"), corrige homófonos del contexto alimentario ("te" verbo vs "té" bebida), y separa palabras pegadas que la SR junta. Resultado: el textarea muestra el texto PULIDO antes de que pulses Analizar. Mientras la IA trabaja, un indicador inline "Puliendo…" + el botón Analizar deshabilitado evitan que se envíe el crudo. Fallback gracioso: si la IA falla o tarda, mantiene el texto original sin error. Coste: ~$0.005 por dictado. ⚠️ REQUIERE DEPLOY MANUAL del edge function analizar-comida en Supabase tras este push (Lovable no las redeploya automáticamente).
`supabase/functions/analizar-comida`: nueva rama `tipo: "naturalizar"` con SYSTEM prompt focused en post-procesar transcripciones del español. Reutiliza Sonnet 4.6 + el logging de coste de api_usage_log. Devuelve `{ textoNatural }`. Fallback: si IA falla, endpoint devuelve el original (sin error, no rompe el flujo).
`invokeNaturalizarTexto` en `invoke-edge.ts`: helper cliente para llamar al endpoint. Timeout 15s, retry implícito vía React Query (no necesario aquí — un solo intento).
`VoiceMealDialog.tsx`: tras `stopListening`, dispara naturalización ASYNC (no bloquea). Indicador inline "Puliendo…" en el textarea + botón Analizar deshabilitado mientras corre. Token de invalidación: si el user vuelve a grabar antes de que llegue la respuesta, se descarta la versión vieja.
El user ve el texto POLISHED antes de Analizar. Antes el crudo lleno de errores SR llegaba directo a la IA (que aún así lo entendía, pero el user no veía la diferencia).
Bug recurrente que pensábamos resuelto: al volver a la app después de un rato (PWA resume), el widget principal mostraba 0 kcal hasta refresco manual. Causa raíz identificada: race entre el auth refresh y el refetch automático de React Query. (1) PWA wake → `_app.tsx` lanza `supabase.auth.refreshSession()` (async). (2) React Query dispara `refetchOnWindowFocus` para el dashboard ANTES del refresh. (3) `queryFn` corre con JWT lagged → `getCurrentUserId()` devuelve null tras 3 retries de 250ms → queryFn devolvía un objeto vacío (zeros). (4) React Query lo trataba como ÉXITO y cacheaba los ceros. (5) No reintentaba hasta el siguiente focus, así que el widget se quedaba en 0 hasta refresh manual. Tres fixes combinados: throw en no-JWT (no devolver ceros), refetchOnWindowFocus:'always' (ignora staleTime), y listener explícito de visibilitychange/pageshow que invalida la query con 600ms de delay para dar tiempo al auth refresh.
`dashboard.tsx queryFn`: si no hay JWT tras retries, **THROW** `WAITING_FOR_AUTH` en lugar de devolver datos vacíos. React Query lo trata como error transient y reintenta con backoff exponencial (1s, 2s, 4s = ~7s total) — suficiente para que el auth refresh de PWA wake termine y el JWT esté disponible.
`refetchOnWindowFocus: "always"` y `refetchOnReconnect: "always"`: fuerzan refetch en cada focus/reconnect aunque la query esté "fresh". Sin esto, si el primer refetch tras wake completaba con datos vacíos (race con auth), React Query consideraba fresh y no reintentaba.
Listener explícito de `visibilitychange` + `pageshow` en el dashboard que invalida `["dashboard"]` con 600ms de delay. En PWA Android Chrome, el evento focus a veces no dispara en resume desde background largo — este listener es backup. El delay garantiza que el `supabase.auth.refreshSession()` de `_app.tsx` haya completado.
El toast "No pudimos cargar tu día" ya no se muestra para errores `WAITING_FOR_AUTH` — esos son transient y resuelven solos. Solo errores genuinos quedan visibles.
Dos quejas relacionadas con el dictado: (1) si me quedo a medias en el primer intento y vuelvo a darle a record, no graba; (2) el audio sale sin comas ni puntos. Fix: (1) la "gracia" de 120ms entre cerrar la SR vieja y abrir la nueva sólo se aplicaba si `recRef` estaba aún seteado — pero stopListening lo nullea, así que en el 2º click hadPrevious=false y arrancaba sin delay → audio device aún releasing → muda. Ahora usamos timestamp del último hardStopMic y garantizamos ≥250ms de gracia independientemente del estado de los refs. (2) bajados los umbrales de puntuación automática de appendSpeechSmart: ahora >450ms inserta coma (antes 600ms) y >1200ms inserta punto (antes 1600ms). El auto-restart del SR añade 120ms de gap propio que descontaba de las pausas reales; con los nuevos umbrales sale más puntuación natural sin spamear comas en stream continuo.
`VoiceMealDialog.tsx` + `AIFoodInput.tsx`: nuevo `lastStopMicAtRef` que registra el timestamp de cada `hardStopMic`. `startListening` calcula `Date.now() - lastStopMicAtRef.current` y espera el resto del presupuesto de 250ms antes de crear la nueva instancia SR. Funciona con cualquier patrón Stop → Record.
Doble check en el callback del setTimeout: si en los ms de gracia el user vuelve a pulsar Stop (`!wantsToListenRef.current`), no arrancamos. Evita carrera con doble tap.
`appendSpeechSmart` en `meal-input-utils.ts`: umbral de coma 600 → 450ms, umbral de punto 1600 → 1200ms. Más puntuación automática para dictados naturales.
Bug reportado: al salir de la app y volver a entrar, a veces el link Admin del menú desaparecía hasta pulsar recargar. Causa: el `useEffect` que llama `checkAdmin()` tenía `.catch(() => setIsAdmin(false))`. Cuando la app vuelve de background (PWA suspend/resume), el componente puede remountar y el primer `checkAdmin` corre con el JWT a punto de expirar o el SDK colgado, falla, y el catch lo bajaba a false silencioso. Pulsar recargar arregla porque la segunda llamada coge un token fresco y triunfa. Fix: cachear `is_admin` en localStorage como hint síncrono al montar + NO sobreescribir a false en error transient.
`_app.tsx`: nuevo useEffect post-mount lee `lutrai_is_admin` de localStorage y flippea el state a true si estaba cacheado. Esto hace que al re-montar (PWA wake) el link Admin aparezca INMEDIATO sin esperar al server fn — sin hydration mismatch (React #418) porque la lectura es post-mount, no en useState initializer.
`_app.tsx`: el `.catch()` del checkAdmin ya NO sobreescribe a false. Si la llamada falla por motivo transient (JWT expirando, SDK lagged), preservamos el valor cacheado (último éxito). El server fn al éxito sí escribe la cache, así que se mantiene actualizada al alza y a la baja siempre que la red coopere.
Cleanup con `active` flag en el effect para evitar setIsAdmin tras unmount si el server fn tarda más que el ciclo de vida del componente.
Bug reportado: un usuario que ya había enviado su reseña seguía viendo el chip flotante "Deja tu reseña" en la home. Causa: el `FloatingReviewButton` se basa SOLO en una query `my-review` con `staleTime: 5min` y la query a veces queda en estado raro (cache del SW, race con la invalidación, refetch que devuelve null por timeout y se trata como "tabla no migrada"). Fix bulletproof: flag local en localStorage al cerrar el submit con éxito. El chip lo lee SÍNCRONO al montar + re-chequea en window focus + se actualiza inmediatamente via `onSubmitted` callback. La BD sigue siendo la fuente de verdad para social proof + admin, pero el chip se rige por el flag local — ya no hace falta esperar a que la red coopere.
`ReviewFormModal` escribe `lutrai_review_submitted_at` en localStorage justo tras el submit exitoso del server fn, ANTES de la invalidación de cache. Si la red falla o el refetch tarda, el flag ya está.
`FloatingReviewButton` lee el flag tras mount (`useEffect`, no en `useState` initializer — evita hydration mismatch React #418). También escucha `window focus` y `storage` events para re-chequear si vuelves de otra pestaña/sección.
`onSubmitted` callback pasado desde el botón flotante al modal: al cerrar un submit exitoso, el chip oculta INMEDIATO sin esperar al window focus.
Compañero de v1.21.9. Una vez la edge function loguea cada call con su coste, el admin necesita verlo. Nuevo bloque en `/admin` con: gasto hoy / mes / 30 días rolling, breakdown por tipo de llamada (📸 foto / 📝 texto / 💬 coach / 💊 suplemento), breakdown por modelo (Opus 4.7 vs Sonnet 4.6), y top 30 usuarios por gasto del mes con detalle de qué hicieron. Útil para detectar power users (rentables) y casos raros (alguien que sube 200 fotos en una tarde). Solo lo ven admins (RLS de la tabla bloquea el resto).
Server fns `adminGetApiCostsSummary` + `adminGetApiCostsByUser` en `admin.functions.ts`. Una sola query trae las filas del último mes (volumen modesto) y agrupa en JS por tipo, modelo y usuario. Códigos 42P01 (tabla no migrada aún) se devuelven como `available:false` para que el UI muestre placeholder amable en lugar de un error técnico.
`ApiCostsSection` en `admin.tsx`: 4 StatCards (hoy, mes, 30d, llamadas mes) + 2 cards con breakdown por kind y por modelo + tabla con top 30 usuarios. Cada usuario muestra: coste, calls, tokens totales, y desglose 📸📝💬💊. Ordenada descendente por coste.
Coste mostrado en USD con 3 decimales (centésimas precisas). Tabla responsive con scroll horizontal en móvil. Iconos visuales para cada tipo de uso.
Función core de la app — el análisis de fotos debe deslumbrar. Subimos los modelos al estado del arte de mayo 2026: Opus 4.7 para vision (98.5% visual-acuity, 3× la resolución de Opus 4.6) y Sonnet 4.6 para texto/voz/coach (cerca del Opus anterior por el mismo precio que Sonnet 4.5). Combinado con la subida a 1568px de v1.21.8, el análisis pasa de "adivina lo que hay" a "identifica cada cosa, lee la etiqueta del envase, cuenta unidades visibles, distingue salsa de aliño". Coste extra estimado: ~$1-2/usuario activo/mes. Para tener trazabilidad real del gasto, añadimos `api_usage_log` que registra cada llamada exitosa con tokens y coste calculado al precio del momento, visible solo a admins. ⚠️ REQUIERE QUE DEPLOYES MANUALMENTE las edge functions en Supabase tras este push (Lovable no las redeploya automáticamente).
`supabase/functions/analizar-comida`: `ANTHROPIC_VISION_MODEL` claude-opus-4-5 → **claude-opus-4-7** y `ANTHROPIC_MODEL` claude-sonnet-4-5 → **claude-sonnet-4-6**. `VISION_BUDGET_MS` 45s → 60s (Opus 4.7 con 1568px tarda más pero es mucho más preciso).
`supabase/functions/preguntar-coach`: `ANTHROPIC_MODEL` claude-sonnet-4-5 → claude-sonnet-4-6. Coach con respuestas notablemente mejores por el mismo coste.
Tabla `public.api_usage_log` con migración SQL. Registra una fila por cada llamada exitosa a Anthropic con tokens input/output (+ tokens de cache), coste calculado al precio del momento, latencia, modelo y tipo (foto/texto/suplemento/coach). RLS: solo admins ven el log, escritura solo desde service_role del edge function.
Edge functions registran cada call con `logApiUsage()` best-effort (si falla por cualquier razón no rompe la respuesta al usuario). Coste en USD calculado al instante usando la tabla de precios Anthropic 2026 ($5/$25 Opus 4.7, $3/$15 Sonnet 4.6, $0.8/$4 Haiku 4.5).
Razonamiento de Lutra (v1.21.5) + items detallados (v1.21.6) + resolución 1568px (v1.21.8) + Opus 4.7 = la combinación más fuerte de análisis nutricional por foto hasta hoy. Comparable con apps especializadas en visión nutricional pero con todo el contexto coach + temporal + objetivos integrado.
El cuello de botella del análisis de fotos NO era el modelo, era el downscale. Reescalábamos a 512px ANTES de enviar a la IA — legacy de cuando usábamos Haiku 4.5 que se conformaba con miniatura. Pero los modelos vision modernos (Opus 4.7) procesan hasta 2,576px con 98.5% de visual-acuity. A 512px estábamos limitando a la IA a ver el plato como una miniatura borrosa, lo que explica que se saltase aliños, mezclase proteínas parecidas, y subestimase porciones. Subido a 1,568px (sweet spot que recomienda Anthropic) + quality JPEG 0.85. Mismo modelo, MUCHA más información para identificar texturas, marcas y tamaños. Coste extra por foto: ~$0.013 USD (input tokens visuales).
`fileToDownscaledBlob` en `meal-input-utils.ts`: maxDim 512 → 1568, quality 0.78 → 0.85. La foto pesará ~3-5x más pero sigue siendo modesta (~120-250KB por foto) y entra cómodamente en el límite de timeout. Lo nota la IA: ve textura del aliño, lee etiquetas de envases, distingue grano de arroz de fideo.
Sin cambios visuales para el usuario — solo notará que el análisis identifica mejor lo que aparece en pantalla. Especialmente útil con platos compuestos, marcas reconocibles (latas, envases) y porciones complejas.
Dos bugs reportados de SpeechRecognition: (1) Chrome mostraba "Lutra está usando tu micrófono" intermitentemente DESPUÉS de cerrar el diálogo de dictado, sin que el user lo entendiera; (2) la primera grabación tras dictar la anterior se quedaba muda — había que pulsar dos veces para que grabase. Causa raíz: usábamos `rec.stop()` (gracioso, espera al flush del último resultado, mantiene el indicador de Chrome durante segundos) en vez de `rec.abort()` (libera el dispositivo de audio inmediato); además, al pulsar "Analizar" sin haber pulsado Stop primero, `wantsToListenRef` quedaba en true → el `onend` posterior auto-rearrancaba una NUEVA instancia de SR aunque el usuario ya estuviera en pantalla de revisión.
`VoiceMealDialog.tsx` y `AIFoodInput.tsx`: nuevo helper `hardStopMic` que (1) detacha onresult/onerror/onend ANTES del abort para evitar auto-restart, (2) llama `abort()` (libera audio device inmediato) Y `stop()` como fallback, (3) limpia `wantsToListenRef` y `recRef`. Usado en `stopListening`, `reset` (cleanup al cerrar diálogo), unmount del componente, y al pulsar Analizar.
`startListening`: 120ms de gracia entre cerrar la instancia previa y abrir la nueva. Sin esto, la nueva `rec.start()` ganaba la carrera contra la liberación del audio device → grabación silenciosa. Pasaba con el patrón "dicto-analizo-dicto otra vez".
Cleanup en unmount del componente con `useEffect(() => () => hardStopMic())`. Si el componente se desmonta (logout, navegación) con SR activa, ahora libera el micro al instante en vez de esperar a GC del SR.
`analizarTexto`/`analizarFoto`: si el user pulsa Analizar SIN haber pulsado Stop primero, ahora forzamos hardStopMic antes de la llamada IA. Antes wantsToListenRef quedaba true y el onend posterior rearrancaba SR.
Pareja del razonamiento mejorado de v1.21.5: ahora puedes ajustar la cantidad de cada alimento ANTES de guardar la comida, con botones +/− y un input numérico. Al cambiarla, Lutra recalcula kcal/proteína/carbos/grasa/fibra/micros proporcionalmente. Si la IA estimó 200g de pasta y la tuya era 150g, basta tocar la cantidad y todo se reajusta. Si la cantidad era 0/null (la IA no estimó peso, solo unidades) y la cambias, asumimos que ese era el valor de referencia y dejamos las kcal intactas. Step inteligente: 10g/ml para gramos/líquidos, 1 para unidades/rebanadas/piezas.
`src/lib/meal-input-utils.ts`: nueva función `scaleItemByCantidad(item, newCantidad)` que escala proporcionalmente kcal/macros/micros. Maneja edge cases: cantidad original null → no escala; cantidad nueva null/0 → reset a cero.
`VoiceMealDialog.tsx` antes de guardar: cada item tiene ahora botones − y + para ajustar cantidad + input numérico editable + input de unidad. Step inteligente: 10g/ml para g/ml/cc, 1 para unidades. Al cambiar la cantidad, los kcal/macros se recalculan en vivo.
El usuario ya no necesita borrar el item y volver a dictarlo si Lutra estimó mal el peso — un toque a +/− ajusta el aporte calórico al instante.
El "Lo que he pensado" pasaba de ser una lista escueta a un análisis per-item donde Lutra explica de dónde sale cada número. Si detecta una marca conocida (Big Mac, Donuts, Activia, Coca-Cola, Kit Kat, Mahou, Estrella Galicia, Doritos, Lay's, Nutella, Bollycao y muchas más), cita el dato oficial de la marca/etiquetado. Si es un alimento genérico, menciona qué tablas científicas usa de referencia (BEDCA y USDA FoodData Central). Antes silenciaba los items 7+ con un slice(0,6) — ahora cobertura 100%. El objetivo: que el usuario sienta el rigor científico detrás de las estimaciones, no solo una lista de cifras.
`src/lib/brand-foods-db.ts`: base de datos curada con ~30 marcas/productos comerciales de presencia fuerte en España (McDonald's, Burger King, Coca-Cola, Mahou, Donuts, Bollycao, Kit Kat, Nutella, Activia, Actimel, Cola Cao, Doritos, Lay's, Pringles, Snickers, Twix, Kinder Bueno, M&M's, Phoskitos, Pantera Rosa, Red Bull, Aquarius, Telepizza, Pan Bimbo, Chocapic, etc.) con datos de etiquetado oficial y citación de fuente.
`src/lib/lutra-reasoning.ts` reescrito: una micro-explicación POR ITEM (sin truncar a 6) con la lógica de la cantidad asumida + kcal del item + fuente concreta ("según etiquetado oficial de Donuts (Bimbo)" o "valor medio BEDCA"). Heurística de tipo de alimento (fruta/carne/pescado/lácteo/cereal/legumbre/aceite/snack...) para que cada explicación sea contextual y no clónica.
`ReasoningBox` en `AIFoodInput.tsx`: ahora renderiza párrafos separados (`\n\n`) y negritas (`**bold**`) sin meter dependencia de react-markdown. Mini-parser inline de 6 líneas.
Cierre con pie que menciona BEDCA y USDA FoodData Central como fuentes de referencia para todo lo genérico — refuerza la percepción de rigor científico detrás de las estimaciones.
Caso edge: entrar con un JWT que apuntaba a un user borrado/inexistente (la app cargaba sin datos), luego pulsar Cerrar sesión → overlay rojo con `Minified React error #300: Rendered fewer hooks than expected`. Causa: en `onAuthStateChange(SIGNED_OUT)` se llamaba `router.invalidate() + navigate()` con los hijos del layout aún montados; las queries volvían null mientras se reconciliaba y algún componente con `if (data === null) return null;` antes de un hook dejaba de llamar ese hook. Fix mínimo: `setReady(false)` ANTES de invalidate+navigate para que el layout vuelva al LutraLoader y los hijos desmonten limpios antes de la transición.
`_app.tsx onAuthStateChange(SIGNED_OUT)`: añadido `setReady(false)` antes de `router.invalidate() + navigate()`. Los hijos del layout desmontan limpios → no se viola el orden de hooks en mitad de la transición → no más React #300 al hacer logout desde ghost sessions.
Auditoría sistemática del codebase con todas las lecciones aprendidas (50+ iteraciones). Detectados y arreglados 8 problemas críticos de la misma familia SDK supabase-js (queryFn de perfil, useAccess, usePaddleCheckout, bootstrap _app.tsx, redirects index/auth, sendTransactionalEmail) + 4 useState initializers no-determinísticos (Math.random + lectura de localStorage que causaban React #418 hydration mismatch) + logros usando MICROS_OBJETIVO base en lugar del ajustado por modo (un usuario Vegano que cubría B12 no se acreditaba el badge).
`perfil.tsx`: queryFn migrada de SDK supabase (getSession + 2 from-select) a raw fetch (`getCurrentUserId` + 2 `selectRows`) + retry + refetchOnMount. Antes la página de perfil podía quedarse con loader infinito en mobile.
`useAccess.ts`: `supabase.auth.getSession()` → `hasLocalSession()` (síncrono, lee localStorage). Todo el gating premium/trial (paywall, FloatingReviewButton, dashboard banners) dejaba de funcionar si el SDK colgaba.
`usePaddleCheckout.ts`: `supabase.auth.getUser()` → `getCurrentUserId()` + email decodificado del JWT. Antes el botón "Suscribirse" quedaba en spinner permanente sin error si el SDK colgaba.
`_app.tsx` bootstrap: si `hasLocalSession()` es true, marca `ready` inmediato y refresca user en background. Antes el `bootTimeout(6s)` echaba al user a landing aunque la sesión fuera válida si SDK colgaba.
`index.tsx` redirect: usa `hasLocalSession()` síncrono primero, SDK con `Promise.race(timeout 4s)` solo como fallback. Antes podía quedar atrapado en landing aunque hubiera sesión.
`auth.tsx` redirect post-login: `hasLocalSession()` síncrono en lugar de SDK.
`email/send.ts`: lee JWT de localStorage en lugar de `supabase.auth.getSession()`. Antes emails transaccionales no se enviaban si el SDK colgaba.
`_app.tsx` y `index.tsx`: useState initializers que leían localStorage migrados a `useState(default)` + `useEffect` post-mount. Causaban React #418 hydration mismatch (SSR no tiene localStorage, client sí).
`LutraMealAnalysisDialog` y `WelcomeSplash`: useState initializers con `Math.random()` → arranca determinístico (0 / primer item) + rota en `useEffect`. Causaban hydration mismatch potencial.
`logros.tsx`: `microsCubiertos` ahora usa `getEffectiveMicrosObjetivo(MICROS_OBJETIVO, nutrition_mode)` en lugar de MICROS_OBJETIVO base. Un usuario Vegano que cubría B12 (no en base) no se acreditaba el badge — ahora sí.
User reportaba: añadía la cena por la tarde y Lutra le hablaba como si fuera desayuno. Causa: la IA no tiene reloj y no le estábamos pasando ni hora, ni momento del día, ni qué tipo de comida había registrado. Fix: el `buildContext()` ahora incluye un bloque CRÍTICO al principio con el día de la semana, hora exacta (HH:MM), parte del día (mañana/mediodía/tarde/noche/noche cerrada) y el momento concreto que el usuario marcó (desayuno/almuerzo/comida/cena/snack). El SYSTEM prompt de la edge function refuerza la regla. Aplica YA en cliente sin esperar deploy de la edge function.
`AnalysisContext` extendido con `momentoComida`, `horaActual`, `parteDelDia`, `diaSemana`. Helper `buildTemporalContext()` calcula los 3 últimos a partir de `new Date()` (timezone del cliente).
`VoiceMealDialog` y `MealEditDialog` pasan ahora el `momento` seleccionado en `onSaved(items, momento)`. Antes solo iban los items y Lutra no sabía si era desayuno, comida o cena.
`buildContext()` del LutraMealAnalysisDialog inyecta el contexto temporal en las PRIMERAS LÍNEAS del prompt con instrucciones explícitas: "CONTEXTO TEMPORAL — úsalo SIEMPRE — NUNCA digas 'desayuno' si es tarde/noche".
SYSTEM prompt de `preguntar-coach/index.ts` con regla CRÍTICA explícita sobre contexto temporal (aplicará cuando se redeploye la edge function manualmente en Lovable; el cliente ya cubre el caso ahora).
Cuando un modo nutricional está activo y cambia macros o micros respecto a la base, el widget afectado muestra ahora un chip pill "💪 Ajustado por Sport / Performance" (con el emoji y color del modo). Al hacer hover/tap, el tooltip enumera los cambios concretos: "Proteína 210g (base 189g)", "Sodio 2300mg (base 1500mg)", "+B12 2.4µg (nuevo)". El chip enlaza al perfil para cambiar/ajustar el modo. Aplicado al CalorieRingHero, MacrosDonut y MicrosRadial.
`<ModeAffectedBadge>` (nuevo componente): chip pill con emoji + color del modo activo + tooltip con la lista de overrides. Sólo se renderiza si hay diffs reales — si el modo no cambia ese widget en concreto, no aparece (clean UX, no badge ruidoso).
Helpers `describeMacrosOverride()` y `describeMicrosOverride()` en `nutrition-modes.ts`: dado base + effective, devuelven array de strings humanos para el tooltip ("Proteína 210g (base 189g)", "+B12 2.4µg (nuevo)").
Badges aplicados a 3 widgets afectables por modo: CalorieRingHero (macros), MacrosDonut (macros), MicrosRadial (micros). Cada uno colorea el chip con el `mode.color` para reforzar visualmente la asociación con el modo.
Tap en el chip → link al perfil. Educa al usuario sobre dónde gestionar el modo y refuerza la trazabilidad de "por qué este valor es así".
Continuación de v1.20.8. Los micros (sodio, hierro, calcio, vitC, vitD, potasio, magnesio) eran un objeto global DRI estándar igual para todos los usuarios sin importar el modo. Pero un usuario Sport necesita ~2300mg sodio (no 1500) por pérdidas de sudor; un Vegano necesita ~25mg hierro (no 14) por menor biodisponibilidad y debe suplementar B12 que ANTES NO APARECÍA. Implementado un sistema de overrides por modo: cada modo puede ajustar el objetivo de un micro existente o añadir nuevos (B12, zinc en vegano). MicrosRadial y el panel "micros bajos" del dashboard ahora usan los ajustados.
`ModeDef.microsOverride` opcional. Cada modo puede ajustar el objetivo de cualquier micro o añadir uno nuevo. Helper `getEffectiveMicrosObjetivo(base, modeId)` hace el merge tipo drop-in para reemplazar `MICROS_OBJETIVO`.
Sport: sodio 1500→2300mg (ACSM 2016, electrolitos por sudor), magnesio 350→420mg (RDA hombre, calambres), vitC 80→200mg (recovery post-entreno), potasio 3500→4700mg (AI adulto).
Vegano: hierro 14→25mg (×1.8 por biodisponibilidad no-heme, AND 2016), calcio 1000→1200mg (sin lácteos), añadido vitB12 2.4µg (CRÍTICO, debe suplementarse SIEMPRE), añadido zinc 11mg (fitatos reducen absorción).
Diabetes: magnesio 350→420mg (deficiencia común en T2DM, Barbagallo & Dominguez 2015).
Heart-friendly: potasio 3500→4700mg (DASH antihipertensivo, Sacks 2001), calcio 1000→1200mg (DASH refuerza lácteos bajos en grasa).
MicrosRadial acepta prop opcional `microsObjetivo`. Si no se pasa, fallback al global. Añadidos iconos para vitB12_ug y zinc_mg.
User detectó que el widget general mostraba proteína 189g objetivo, pero el modo Sport activo mostraba 100g objetivo (con 140g ya como "objetivo alcanzado"). Bug: ModeFocusCard hacía `targetOrCap = focus.dailyCap ?? focus.dailyTarget ?? 100`. Sport no declara `dailyTarget` (su objetivo se calcula dinámicamente con `pesoKg × 2.0`), así que caía al fallback estático de 100g. Fix: el componente ahora recibe `targets` (los macros con override del modo aplicado, calculados ya en el dashboard) y usa el valor REAL del usuario. Auditados los 8 modos: la lógica científica de cada uno es correcta; solo había que conectar bien la presentación.
`ModeFocusCard` ahora acepta prop `targets: MacrosObjetivo` (los macros con override aplicado del modo). Precedencia del target: `focus.dailyCap` (caps absolutos como keto carbs=30) → `targets[focus.metric]` (valor real calculado, ej Sport prot=210g) → `focus.dailyTarget` (declarado estático) → 100 fallback.
`dashboard.tsx`: pasa `targets={macros}` al `<ModeFocusCard>`. Antes el componente no tenía manera de saber el objetivo real del usuario.
Auditoría coherencia macros por modo: Sport 2.0g/kg, Keto 1.6g/kg (intencional bajo para no salir cetosis), Vegano 1.3g/kg (biodisponibilidad), Antiinflamatoria 1.4g/kg, Diabetes 1.5g/kg, Mediterránea/Sugar-friendly base 1.8g/kg. Todos con respaldo científico documentado.
Captura del user en Galaxy Z Fold 6 plegado mostraba contenido cortado por la izquierda ("ARTES" en vez de "MARTES", "Hola" cortado). Causa raíz identificada: el WhatsNewDialog usa `w-[calc(100vw-1.5rem)]` y Tailwind v4 NO genera arbitrary classes con resta interna → el dialog cae a su `max-w-md` (448px) default que en el cover display del Z Fold (~376px) EXCEDE el viewport → scroll horizontal residual del body → todo el contenido se ve desplazado. Fix doble: `overflow-x:hidden` + `max-width:100vw` en html/body (contención global) + width del Dialog INLINE (que sí se aplica).
`styles.css`: añadido `html, body { overflow-x: hidden; max-width: 100vw }` en @layer base. Cualquier elemento que accidentalmente exceda viewport ya NO provoca scroll lateral del body. Protección global.
`WhatsNewDialog`: width y maxHeight movidos a `style={{ ... }}` inline (Tailwind v4 no generaba `w-[calc(100vw-1.5rem)]` por la resta interna, igual que con `env()`). Ahora SIEMPRE encaja en viewport por estrecho que sea.
Patrón: cuando un width/height/padding necesita `calc()` con operaciones complejas o `env()`, usar SIEMPRE inline `style={{ ... }}` en lugar de Tailwind arbitrary class. Esta es la regla 2 documentada en la memoria permanente.
User reporta que se ve mal en Galaxy Z Fold 6 plegado (cover display extra-estrecho) y "antes de arreglar el zoom de las actualizaciones iba bien". Revertidos TODOS los cambios globales de safe-area que introdujimos en v1.20.4 y v1.20.5: viewport-fit=cover, padding-bottom inline del <main>, FABs con env() inline, CookieBanner con env(). Vuelta al comportamiento responsive original. La lección: no añadir env(safe-area-inset-*) sin testarlo en foldables y dispositivos atípicos.
Meta viewport: REVERTIDO a `width=device-width, initial-scale=1` (sin `viewport-fit=cover`). El cover en pantallas plegables hacía que el safe-area-inset extendido rompiera el layout.
`<main>` REVERTIDO a sin padding-bottom inline. Los `+96px` de `paddingBottom: calc(6rem + env(...))` empujaban el contenido en pantallas cortas.
FAB Añadir comida (dashboard) y FAB Coach Lutra (CoachChat) REVERTIDOS a `className="... bottom-6 ..."` simple. Sin env(), sin max() inline.
CookieBanner REVERTIDO a `bottom-3` sin env().
Mantenido el WhatsNewDialog responsive de v1.20.2 (que el user dice que iba bien — el bug de "zoom" en updates lo arregló sin colateral).
v1.20.4 empeoraba en vez de mejorar. Dos errores: 1) `min-h-screen` → `min-h-dvh` introducía un salto de altura cada vez que la URL bar del navegador aparecía/desaparecía — eso era "el zoom raro que antes no tenía". 2) Las clases Tailwind arbitrary con `env()` y comas internas (`pb-[calc(6rem+env(safe-area-inset-bottom,0px))]`, `bottom-[calc(...)]`) NO se generan fiable en Tailwind v4 — quedaban sin estilo. Fix: revertido a `min-h-screen` y todos los safe-area movidos a inline `style={{ ... }}` que aplican siempre.
Restaurado `<div className="min-h-screen">` en _app.tsx. El cambio temporal a `dvh` causaba reflow al cambiar la URL bar mobile y eso era lo que se percibía como zoom raro. El comportamiento tradicional 100vh es estable aunque imperfecto.
`<main>` padding-bottom movido de Tailwind arbitrary class a `style={{ paddingBottom: "calc(6rem + env(safe-area-inset-bottom, 0px))" }}`. Tailwind v4 ignora silenciosamente las arbitrary classes con `env(...)` y comas → la clase queda vacía → no había reserva → FABs tapaban contenido.
FAB Añadir comida (dashboard) y FAB Coach Lutra (CoachChat) movidos a `style={{ bottom: "max(env(safe-area-inset-bottom, 0px) + 1.5rem, 1.5rem)" }}`. Garantiza 24px mínimos de respiro siempre + respeta home indicator iOS cuando aplica.
El user reportaba sensación de "zoom" extra y FABs flotantes desapareciendo por abajo. Causa triple: 1) faltaba `viewport-fit=cover` en el meta viewport (sin él, `env(safe-area-inset-*)` siempre devuelve 0 en iOS PWA y los FABs quedan tapados por el home indicator), 2) `min-h-screen` (100vh) crece con la URL bar dinámica del navegador mobile causando que el contenido empuje los FABs fuera del viewport visible, 3) faltaba padding-bottom en `<main>` para reservar espacio bajo los FABs. Los tres arreglados.
Meta viewport: añadido `viewport-fit=cover` → `env(safe-area-inset-bottom)` ahora devuelve el valor REAL del home indicator iOS, en vez de 0. Los FABs (Añadir comida, Coach Lutra) ya no quedan tapados por la barra de gesto.
`<div className="min-h-screen">` → `min-h-dvh` (dynamic viewport height) en `_app.tsx`. `100vh` en mobile incluye la URL bar dinámica del navegador → cuando aparece/desaparece, el viewport se recalcula y los FABs absolute-bottom quedaban empujados fuera. `dvh` se ajusta dinámicamente.
`<main>` ahora tiene `pb-[calc(6rem+env(safe-area-inset-bottom,0px))]` para reservar espacio bajo los FABs flotantes. Antes el último widget del dashboard ("Ajustar mi plan", GameCard) quedaba semi-tapado en mobile.
CookieBanner: pasado a `style.bottom = max(env(safe-area-inset-bottom), 0.75rem)` para respetar el home indicator iOS.
Dos bugs reportados por usuarios: 1) Al enviar una reseña aparecía "Unauthorized: No authorization header provided". 2) Las notificaciones push no llegaban a algunos usuarios pese a haber regenerado las VAPID keys en v1.15.7. Ambos arreglados de raíz: el middleware global que adjunta el JWT a las server fns ya no depende del SDK supabase-js (que se cuelga en mobile) — lee el token directo de localStorage. Y el setup de push detecta cuándo una subscription tiene una VAPID antigua y la regenera automáticamente.
`auth-attacher-safe.ts` nuevo wrapper del middleware client que adjunta JWT a server fns. El original autogenerado por Lovable llamaba a `supabase.auth.getSession()` que se cuelga en mobile → JWT no se adjuntaba → server fns como `submitReview` tiraban Unauthorized. El SAFE lee localStorage primero (síncrono), SDK solo como fallback. `start.ts` actualizado para usarlo.
Push: detección automática de VAPID key mismatch. Si la subscription existente tiene una `applicationServerKey` distinta a la VAPID actual (caso típico tras regenerar keys), `unsubscribe()` + cleanup del row obsoleto en BD + `subscribe()` nueva. Los usuarios que no recibían push se re-suscriben transparentemente al reabrir la app.
Helper `applicationServerKeysMatch` compara byte a byte la VAPID actual vs la esperada. Si falla la comparación por cualquier motivo, decide defensivamente regenerar la subscription (mejor un re-subscribe innecesario que push silenciosos).
El popup de novedades desbordaba viewport con zoom de Chrome Android (200%+) y el botón X de cerrar quedaba fuera. Rediseñado: ancho calc(100vw-1.5rem) + altura calc(100dvh-2rem) flex-col, line-clamp en highlight y changes, máximo 4 cambios visibles (resto en historial). Padding compacto en mobile.
WhatsNewDialog: `w-[calc(100vw-1.5rem)] max-w-md` reemplaza al `max-w-md` rígido. Ahora SIEMPRE cabe en viewport, incluso con zoom 200%+.
Altura controlada con `max-h-[calc(100dvh-2rem)]` + layout flex-col (header fijo, body scroll, footer fijo). El botón Entendido siempre visible.
`line-clamp-3` en highlight + `line-clamp-4` en cada change → entradas largas del changelog no rompen el popup. Hint visual "+N cambios más en el historial" si la entrada tiene >4 cambios.
Padding compacto en mobile (`p-4 sm:p-5`), título responsive (`text-xl sm:text-2xl`), `pr-12` en header reserva espacio físico para el X de Radix.
El user reportaba que "dos porciones de pizza pepperoni mediana" seguía sin descomponerse. Causa: la descomposición vivía solo en la edge function (SYSTEM prompt) y Lovable NO redeploya edge functions con cada push. SOLUCIÓN DEFINITIVA: descomposición 100% CLIENT-SIDE con un diccionario de recetas internas. 12 recetas conocidas (pizza pepperoni/4 quesos/jamón/margarita/genérica, hamburguesa/cheeseburger, bocadillo jamón, sándwich mixto, lasaña, paella, sushi, burrito, kebab, perrito, tortilla patatas, carbonara, boloñesa). Cada una con composición porcentual + densidad nutricional por 100g + food_quality por ingrediente. Cuando la IA devuelve "pizza pepperoni" como UN item, el cliente lo expande a 4-5 items con gramos y macros calculados al vuelo. El user ve la capacidad analítica YA, sin esperar deploys.
`recipe-decomposer.ts`: motor de descomposición con 12 recetas internas (pizza, hamburguesa, bocadillo, sandwich, lasaña, paella, sushi, burrito, kebab, perrito, tortilla, carbonara, boloñesa). Parser de cantidad reconoce porciones ("dos", "3", "tres"), tamaños (mediana 1.0×, grande 1.3×, familiar 1.6×) y los compone. Densidades BEDCA/USDA. Si la IA da kcal totales muy distintas a la estimación interna, ajusta proporcionalmente.
`MealEditDialog.onAIItems` y `VoiceMealDialog` (flujo texto y foto) pasan los items por `decomposeItemsArray()` antes de mostrarlos. Resultado: "2 porciones pizza pepperoni mediana" llega al preview como masa + tomate + mozzarella + pepperoni + aceite, con sus gramos y macros, y la calidad correcta por ingrediente.
`buildLutraReasoning` detecta si los items son una descomposición de plato compuesto y lo menciona explícitamente: "He descompuesto tu pizza en sus ingredientes principales para que veas qué hay realmente dentro". 3 variantes random para no sonar repetitivo.
Si el user dice algo genérico ("pizza", "hamburguesa" sin más) y la IA devuelve un único item, Lutra añade un hint sutil: "Si quieres más detalle por ingrediente, dilo más específico (ej: 3 porciones pizza pepperoni mediana)".
Dos mejoras de fondo en cómo Lutra analiza la comida. (1) DESCOMPOSICIÓN: pizza, hamburguesa, sándwich, lasaña, paella, sushi, kebab, burrito, etc. ahora se desglosan en sus ingredientes principales con gramos estimados. Antes "3 porciones pizza pepperoni familiar" iba como UN item; ahora va como masa + tomate + mozzarella + pepperoni × 3 con sus gramos cada uno y SU food_quality REAL por ingrediente (masa procesado, pepperoni ultra). (2) OVERRIDE NOVA: nuevo `STRICT_ULTRA_RULES` con keywords no negociables (tarta, pastel, merengue, tiramisú, bizcocho, magdalena, helado, etc.) que FUERZA ultraprocesado aunque la IA o un edge function viejo digan otra cosa. Adiós a "tarta de merengue → real_food".
`forceQualityIfStrictUltra(nombre, current)` en `food-quality.ts`: detecta keywords indiscutibles de NOVA 4 (postres, dulces, refrescos, snacks) y devuelve `"ultraprocesado"` siempre, sin importar lo que dijo la IA o un clasificador previo. Es la red de seguridad contra desclasificaciones.
Aplicado el override en TODOS los puntos donde se consume `food_quality`: `MealEditDialog` (preview y guardar), `VoiceMealDialog` (cuando llega de la IA), `meal-record.functions.ts` (al insertar en BD), `dashboard.tsx` (cálculo de calidad semanal, detección de ultra del día, listado de comidas). El override respeta los overrides manuales del usuario.
Edge function `analizar-comida` (SYSTEM_TEXT + SYSTEM_VISION) reescrita: pizza, hamburguesa, sandwich, lasaña, paella, sushi, burrito, kebab, etc. se DESCOMPONEN en ingredientes principales con gramos y `food_quality` por componente. Ejemplos explícitos en el prompt: "3 porciones pizza pepperoni" = masa×3 + tomate×3 + mozzarella×3 + pepperoni×3 con sus gramos. Postres/dulces siguen siendo UN único item ultraprocesado (descomponerlos engañaría con ingredientes que parecen real_food).
Recordatorio operativo: las edge functions de Supabase requieren redeploy manual en Lovable Cloud para que el prompt nuevo surta efecto. Mientras tanto, el override client-side YA garantiza que "tarta de merengue" salga como ultraprocesado sin esperar al deploy.
Solución definitiva al recurrente "Runtime error: supabaseAdmin not available: Missing SUPABASE_SERVICE_ROLE_KEY" que saltaba en el overlay de Lovable. Causa: en el entorno preview/dev de Lovable falta la `SUPABASE_SERVICE_ROLE_KEY` (es normal, los service-role secrets solo viven en prod). El wrapper `admin-safe.ts` devolvía `{ data: null, error: new Error(...) }` y los callers que hacían `if (error) throw error` (como `payments.functions.ts`) re-tiraban el error → SSR muere → overlay rojo. Fix: el mock ahora devuelve `{ data: null, error: null }` SIEMPRE. Los callers ven "no data" y caen en sus fallbacks naturales (level=free, sin chatbot usage, etc.) y la página renderiza limpia. En prod los secrets SÍ están → el wrapper delega al cliente real → cero cambios.
`admin-safe.ts`: `buildMockResult` retorna ahora `error: null` (antes `error: new Error("supabaseAdmin not available...")`). Los callers que hacían `if (lvlErr) throw lvlErr` o equivalente ya no propagan errores sintéticos del entorno preview.
Documentación inline ampliada: el mock SILENCIOSO es deliberado. En dev/preview NO hay secrets de service-role (estado normal), así que el caller debe interpretarlo como "no estamos configurados" no como "falló".
Patch genérico: NO requiere tocar ninguna server function de pagos/admin. Cualquier código existente que use `supabaseAdminSafe.rpc/from/etc` se beneficia automáticamente del mock silencioso.
Dos refinamientos del feel: 1) La animación del CalorieRingHero (anillo con pulse + sparkle + chip flotante "+kcal") ya NO se dispara al guardar la comida — el usuario se la perdía porque el popup de Lutra se abría justo encima. Ahora se reproduce al CERRAR el popup, encadenada con el confetti. 2) Nuevo helper `RevealOnView` con `IntersectionObserver`: cada widget del dashboard hace fade-up la PRIMERA vez que entra en viewport. Antes todos se animaban en el mount → los widgets de abajo (WeightTracker, WeeklyTrend, GameCard) ya estaban estáticos para cuando el usuario llegaba scroll abajo.
`onMealSaved` ya no llama `setMealCelebrationTick` inmediatamente. Lo hace `onAnalysisClose` del popup de Lutra, con cadencia: cierre → 80ms → animación anillo → 320ms → confetti. El usuario disfruta la secuencia visual completa.
`<RevealOnView>`: helper basado en framer-motion `whileInView` con `viewport.once=true`. Hace fade-up de 14px en 0.5s con cubic-bezier suave. Acepta `delay`, `y`, `duration` y `amount` (porcentaje mínimo visible para disparar).
Aplicado `<RevealOnView>` a TODOS los widgets del dashboard: CoachTips, ModeFocusCard, ModeScienceCard, MacrosDonut, MicrosRadial, FoodQualityCard, ActivityTracker, DailySupplementsCard, WeightTracker, WeeklyTrend, WeeklyQuality, GameCard. Cada uno aparece con un fade-up sutil cuando el scroll lo alcanza.
Tres bugs reportados: 1) Los badges/logros saltaban ENCIMA del popup de Lutra interrumpiéndolo. 2) Historial mostraba 0/0/0 hasta tirar pull-to-refresh. 3) Trofeos mostraba preloader infinito hasta pull-to-refresh. Causa común de los dos últimos: el SDK supabase-js se cuelga silenciosamente en mobile y nunca resuelve. Migrados ambos a `selectRows` (raw fetch) con timeout duro, igual que dashboard v1.17.10. Y el primer bug se arregla con un gate `pause/resume` en el bus de logros.
`achievement-events.ts`: añadidos `pauseAchievements()` y `resumeAchievements()`. Mientras pausado, las emisiones se encolan y se entregan al reanudar. Dashboard llama pause cuando se abre el popup de Lutra (`analysisContext≠null`) y resume al cerrarlo. Resultado: badges nuevos esperan a que el usuario lea la opinión de Lutra y luego saltan, sin solapamiento.
`/logros`: migrada queryFn entera de SDK → `selectRows` (raw fetch). Antes el preloader se quedaba infinito porque `supabase.auth.getSession()` + 5 queries SDK se colgaban a veces en mobile. Ahora userId via JWT local + 5 selectRows con timeout duro 6-8s. Añadidos `retry: 3` + `refetchOnMount: always`.
`/historial`: migrada queryFn entera de SDK (con `withTimeout` wrapper) → `selectRows` directo. El wrapper devolvía `[]` cuando el SDK colgaba, así que el usuario veía ceros falsos. Ahora raw fetch nativo con timeout, sin SDK intermedio.
Eliminada la función `withTimeout` en historial.tsx — dead code tras la migración.
Tras múltiples hotfixes infructuosos en v1.18.7-v1.18.9, dos cambios drásticos: 1) WelcomeSplash DESACTIVADO POR COMPLETO (retorna null salvo `?splash=force`). El OS splash del manifest ya cubre el cold start. 2) Popup de Lutra tras añadir comida REFACTORIZADO: en lugar del ballet anterior (ref + tick state + useEffect que escucha cambios en meals query), `onMealSaved(items)` ahora recibe los items como parámetro y setea `analysisContext` INLINE, síncrono, antes de cualquier red. Cero race conditions: el popup aparece SIEMPRE tras guardar.
WelcomeSplash desactivado por defecto. Tras 3 hotfixes (v1.18.7-v1.18.9) y feedback de usuario diciendo "sigue sin funcionar", optamos por no mostrarlo. Lo recuperaremos cuando podamos reproducir y arreglar a fondo el bug mobile. Para debug: `?splash=force`.
`onMealSaved` refactorizado de raíz. Antes: setQueryData → marca id en ref → tick → useEffect → meals.find(byId) → setAnalysisContext. En mobile el render no completaba a tiempo y el useEffect veía meals sin el meal nuevo → return → popup NUNCA aparecía. AHORA: VoiceMealDialog y MealEditDialog pasan items al callback → onMealSaved setea analysisContext directamente con esos items. Síncrono, garantizado.
Eliminados `pendingAnalysisMealIdRef` y `pendingAnalysisTick` state — código más simple, menos hooks, menos cosas que pueden fallar.
Si `profile` aún no está cargado al guardar (caso muy raro), el popup sigue apareciendo con valores razonables por defecto en lugar de no aparecer.
Bug crítico introducido en v1.18.8: el splash se quedaba COMPLETAMENTE atascado en mobile — ni el botón Entrar ni el tap-anywhere lo cerraban. Causa: el flujo `setExiting(true) → animación 320ms → setTimeout(setShow(false), 340ms)` se quedaba colgado si el setTimeout era throttleado por el navegador o la animación CSS fallaba. El state quedaba en exiting=true y CUALQUIER nuevo tap caía en `if (exiting) return` → splash bloqueado para siempre. Solución radical: dismiss es ahora SÍNCRONO con `setShow(false)` directo, sin animación, sin timeout. Bonus: salvavidas reducido de 30s a 10s y Escape/Enter/Space keys como cinturón extra.
WelcomeSplash: ELIMINADO el state `exiting` y la animación de salida `splash-root-out`. `dismiss()` ahora hace `setShow(false)` SÍNCRONO — desmonta el componente instantáneamente sin riesgo de quedarse colgado. Sin guard de exiting, sin setTimeout entre tap y desmount. Idempotente (varios taps no rompen nada).
WelcomeSplash: salvavidas auto-cierre reducido de 30s → 10s. Si por algún motivo futuro el dismiss volviese a fallar, el user sale en 10s en lugar de 30s.
WelcomeSplash: añadidos Escape, Enter y Space como salidas alternativas en desktop. Cinturón extra por si tap+botón fallaran simultáneamente.
WelcomeSplash: log diagnóstico `lutrai_splash_last_dismiss` en localStorage cada vez que dismiss() se ejecuta. Si vuelve a haber un bug así podemos confirmar si dismiss llegó a llamarse o no llegó.
El usuario reportaba que el botón Entrar del WelcomeSplash NO funcionaba en Chrome Android, incluso tras vaciar la caché. En desktop reproducir era imposible: clic = cierre. Solución radical: cualquier tap en CUALQUIER parte del splash ahora cierra. El botón sigue ahí como pista visual, pero ya no es la única salida — basta tocar en el mascot, en el fondo, donde sea. Plus: añadidas 6 variantes nuevas al saludo de tarde-sin-comidas para que no se repita siempre la misma frase.
WelcomeSplash: `onPointerDown` ahora dispara `dismiss()` SIEMPRE, sin importar el target. Antes filtraba `if (e.target === e.currentTarget)` lo que dejaba la responsabilidad al botón — pero en algún móvil el botón quedaba tapado por una animación o un layer interceptaba el evento. Ahora cualquier tap sale. Aria-label actualizado a "Toca en cualquier sitio para entrar".
`getLutraGreeting()` — el pool de variantes para tarde con pocas comidas pasa de 3 a 6 frases. Más variedad de ángulo: una invita a snack con proteína, otra a hidratarse, otra a planear la cena, otra a frutos secos + fruta, etc. Menos repetición visible para usuarios que usan la app varias tardes seguidas.
Validado en desktop que el flujo añadir-comida → popup de Lutra funciona end-to-end: análisis muestra el `buildLutraReasoning` cálido client-side, `LutraMealAnalysisDialog` aparece tras guardar con saludo personalizado ("Iñaki, la manzana es un snack ligero...") y nutrición contextual (TDEE restante, modo activo, objetivo). Si el usuario sigue viéndolo en móvil, queda como TODO investigar específicamente en Chrome Android — posible AnimatePresence o Radix Dialog quedándose preso.
El razonamiento de Lutra seguía sonando clínico porque Lovable NO redeploya las edge functions de Supabase con cada push — los cambios al SYSTEM prompt nunca llegaban al runtime. Fix definitivo: ignorar el campo `razonamiento` de la edge function y construirlo 100% en cliente con plantillas Barrio Sésamo. Plus: splash sin auto-cierre a 8s (el usuario lo confundía con "no funciona"). Plus: cada escenario del saludo tiene ahora 4-6 variantes que se eligen random.
`src/lib/lutra-reasoning.ts`: helper `buildLutraReasoning(items)` que construye el texto del ReasoningBox en cliente. Plantillas variables (7 aperturas, 5 cierres OK, 3 cierres baja-confianza, 3 cierres procesado). Hash determinístico para que el texto cambie cuando cambien los items, pero sea estable en re-renders. Cero llamadas IA extra.
`MealEditDialog` y `VoiceMealDialog` ahora pasan los items recién analizados a `buildLutraReasoning()` y guardan ese texto en lugar del que devuelve la edge function. Resultado: el razonamiento es siempre cálido en primera persona ("He apuntado tu 🍔 Big Mac, las 🍟 patatas y la 🥤 Coca-Cola..."), sin depender de deploys de la edge function.
`getLutraGreeting()` reescrito con POOL de variantes por escenario. Antes había una sola frase para racha alta ("9 días seguidos") → siempre la misma. Ahora cada escenario (racha muy alta, racha alta, racha media, peso bajando, peso subiendo, re-engagement, lunes mañana, domingo tarde, mañana, mediodía, tarde, noche, noche cerrada) tiene 3-6 variantes con ángulos distintos (motivación, ciencia ligera, humor, cariño).
WelcomeSplash: quitado el auto-cierre a 8s — el usuario decía "el botón Entrar no funciona" pero en realidad sí funcionaba; la confusión era que el splash se cerraba SOLO a los 8s y le parecía que el clic no había hecho nada. Ahora dura hasta que pulse Entrar o toque fuera (salvavidas a 30s por si se queda colgado sin tocar nada).
Cuatro mejoras tras feedback: 1) El razonamiento seguía sonando robótico — reescrito el SYSTEM completo de la edge function con ejemplos PROHIBIDOS Y OBLIGATORIOS explícitos al principio, no al final. Lutra ahora se presenta como nutria coach, no nutricionista clínico. 2) Card del análisis rediseñada con la nutria GRANDE centrada como protagonista (halo radial pulsante + ring coral + chip flotante "💬 Lo que dice Lutra"). 3) Confetti retrasado hasta que el usuario cierra el pop-up — antes se solapaba con la lectura. 4) Quitado el botón "Pregunta más a Lutra" para no incentivar gasto de tokens innecesario.
Edge function `analizar-comida`: el bloque del campo `razonamiento` ahora va al INICIO del SYSTEM (no al final). Lutra se presenta literalmente: "Eres LUTRA 🦦, una nutria coach divertida". Lista explícita de inicios PROHIBIDOS ("El plato consiste en…", "Se han identificado…", "Como nutricionista…") y OBLIGATORIOS (primera persona, "te he calculado", "tiré por", "me cuadra"). Ejemplo concreto al final del bloque para cada caso.
`LutraMealAnalysisDialog` rediseñada: gradient cálido de fondo + dos blobs pulsantes detrás de la nutria. Mascot de 96-112px CENTRADO con halo radial pulsante + ring coral grueso. Chip flotante "💬 Lo que dice Lutra" en la base del avatar. Respuesta en superficie blanca elevada con inset shadow para sentir profundidad. Botón "¡Sigamos!" grande centrado.
Confetti DESPLAZADO. Antes saltaba a los 350ms tras guardar y se solapaba con el pop-up de análisis. Ahora se dispara cuando el usuario cierra el pop-up (`onOpenChange(false)`). Fallback: si el pop-up no llega a abrirse, confetti en su sitio original.
Quitado el botón "Pregunta más a Lutra" del pop-up. No queríamos incentivar consumo de tokens del coach sin intención clara del usuario — el chat sigue accesible vía el FAB de Lutra del dashboard.
Dos quejas: 1) El "Razonamiento de Lutra" volvía al tono clínico ("se estimó una ración del usuario"). 2) El pop-up de opinión post-comida mostraba **asteriscos** sin renderizar y Lutra no usaba tu nombre/modo/objetivo. Fix: prompts MUY endurecidos con ejemplos buenos y prohibidos + parser markdown ligero en el dialog + buildContext que obliga a Lutra a usar tu nombre.
Prompt del campo `razonamiento` en `analizar-comida` (texto + visión) reforzado: REGLA INVIOLABLE con prohibición explícita de "el usuario", "la ingesta", "estimación de baja confianza". Lista de ejemplos BUENOS (que copiar) y PROHIBIDOS (que evitar). Sin markdown, primera persona, expresiones naturales españolas ("tiré por", "me cuadra", "no me la jugué con").
`buildContext()` del `LutraMealAnalysisDialog`: el nombre del usuario va al principio con instrucción explícita "usa este nombre AL MENOS UNA VEZ en tu respuesta". El modo nutricional se manda en comillas y se pide mencionar por nombre. El objetivo de peso (perder/mantener/ganar) en MAYÚSCULAS para que destaque. Kcal restantes calculadas explícitamente.
Prompt del análisis: REGLAS INVIOLABLES numeradas (usar nombre, mencionar modo, mencionar objetivo, primera persona, cero markdown). Formato exacto en 4 párrafos cortos separados por línea en blanco. Ejemplo real del tono a imitar ("🌱 Buenísima elección de cena, Iñaki...").
`LutraResponseBody`: componente nuevo que parsea la respuesta de Lutra y la renderiza bonita. Maneja **negritas** → <strong>, *cursivas* → <em>, `code` → <code>, líneas con `- ` → bullets con punto coral, párrafos separados por \n\n. El PRIMER párrafo recibe estilo destacado (background suave + texto más grande) porque es el veredicto con emoji.
Lovable preview spammeaba el error "Unauthorized: No authorization header provided" porque las queries `accessQuery` en FloatingReviewButton y ReviewTrialModal no envolvían `fetchAccess` en try/catch. Sin sesión activa, el error escapaba y se reportaba como blank screen del SSR. Ahora se silencia con `return null` igual que ya hace `getMyReview`.
`hasLocalSession()` en `invoke-edge.ts`: helper SÍNCRONO que comprueba si hay JWT de Supabase en localStorage. No bloquea, no hace red, no toca el SDK. Útil para guards de `enabled` en useQuery.
`FloatingReviewButton.tsx` y `ReviewTrialModal.tsx`: las queries `accessQuery` y `reviewQuery` ahora usan `enabled: hasLocalSession()`. Si no hay sesión, **NO se dispara la request** — evita que el server reporte "Unauthorized" como blank screen del SSR del preview de Lovable. En producción (con sesión) el comportamiento es idéntico.
Plus: queryFn envuelta en try/catch que devuelve null/`{ review: null }` como fallback. Doble seguridad: si por algún motivo `hasLocalSession()` da falso positivo, el error igualmente se silencia.
Bug: "pan de hamburguesa" mostraba emoji de chuletón 🥩 porque la regla de carne (que incluía "hamburguesa") matcheaba antes que la de pan. Fix: añadida sección `ICONIC_RULES` que se evalúa ANTES de las reglas de ingredientes. Si el nombre contiene una palabra-emblema de un plato icónico (hamburguesa, pizza, batido, taco, paella, sushi, ramen, lasaña, etc.), devuelve el emoji del plato completo INDEPENDIENTEMENTE de los ingredientes que mencione. "Pan de hamburguesa", "carne de hamburguesa" y "salsa de hamburguesa" todos salen como 🍔.
`foodEmoji()`: dos pasadas. (1) `ICONIC_RULES` con 28 patrones de platos emblemáticos (hamburguesa→🍔, pizza→🍕, bocadillo→🥪, perrito→🌭, taco→🌮, burrito→🌯, sushi→🍣, ramen→🍜, paella→🥘, lasaña→🍝, batido→🥤, croqueta→🥟, tarta→🍰, donut→🍩, croissant→🥐, helado→🍦, etc.). (2) Si no era icónico, las reglas específicas de ingredientes en orden (frutas, verduras, lácteos…). Los icónicos siempre ganan.
Cubiertos casos comunes que la IA puede desglosar: pizza margarita, batido de proteína, taco de pollo, sushi de salmón. Aunque pedimos a la IA tratar estos como UN item (v1.17.0), si por accidente desglosa, el emoji del icónico les llega igual.
Cuatro fixes urgentes: 1) El "algo no ha cargado bien" al entrar (React #310) era el mismo patrón que el #300 de WeightTracker — un useEffect que añadí en v1.18.0 estaba DESPUÉS del early return `if (!profile) return <LutraLoader/>`, así que el conteo de hooks cambiaba entre renders. 2) El pop-up de Lutra ahora muestra frases ingeniosas rotativas mientras piensa ("Lutra está mirando lo que has comido…", "Cruzando datos con tu objetivo…"). 3) El botón Entrar del WelcomeSplash arreglado: visible desde el inicio (sin opacity:0 inicial) + onClick + onPointerUp para móvil + z-index alto. 4) Mini-extra rotativo cada apertura del splash.
Dashboard React #310 ("Rendered more hooks than during the previous render"): el useEffect del análisis post-meal que añadí en v1.18.0 estaba DESPUÉS del `if (!profile) return <LutraLoader/>`. Render 1 sin profile = N hooks. Render 2 con profile = N+1 hooks → React detecta el mismatch y crashea. Movido el useEffect ANTES del early return + recalcula contexto inline (no depende de variables computadas post-return).
Botón Entrar del WelcomeSplash: quitado el `opacity: 0` inicial + animación delay 2200ms (si la animación CSS fallaba el botón quedaba invisible Y el usuario decía "no funciona"). Ahora visible desde el primer paint. Añadido `onPointerUp` además de `onClick` para robustez en móvil (gestos largos). `position: relative; zIndex: 10; userSelect: none; minHeight: 52` para hit target accesible.
`LutraThinkingPhrase`: componente que rota 12 frases empáticas cada 2.2s mientras Lutra piensa el análisis. Ejemplos: "Cruzando datos con tu objetivo… 🎯", "Pesando macros como una balanza romana… ⚖️", "Sin juicios, solo info útil 🦦💚". `AnimatePresence` con fade vertical sutil entre frases.
WelcomeSplash mini-extra rotativo: pool de 12 micro-frases cálidas ("Vamos a por hoy 💪", "Tú sigue, yo te miro 🦦💚", "Hidrátate bien 💧") que se eligen RANDOM en cada apertura del splash. Da sensación de variedad encima del saludo personalizado que ya tenía contexto del día.
Las cards de comidas del dashboard ahora tienen un listado mucho más vistoso: cada alimento muestra su emoji característico (🥚 huevo, 🍞 pan, 🐟 salmón, 🍫 cacao…), un dot de calidad NOVA al lado del avatar, separadores sutiles entre líneas, hover state interactivo, y mejor jerarquía visual con cantidad/unidad como subtítulo muted y kcal a la derecha en bloque destacado.
Helper `foodEmoji(nombre)` en `src/lib/food-emoji.ts`: 90+ reglas regex en cascada que asignan un emoji representativo a cada alimento en español. Específicas primero (jamón ibérico vs jamón york), genéricas después (frutas, verduras, lácteos, cereales…). Fallback 🍽️ si no encuentra match — nunca rompe el render.
Cada item en la card de meal ahora tiene: avatar circular con emoji + dot de calidad NOVA (verde real_food, amarillo procesado, rojo ultraprocesado) superpuesto en esquina inferior derecha. Nombre del alimento en negrita + cantidad/unidad como subtítulo muted en línea separada. Kcal a la derecha en bloque grande con "kcal" en uppercase abajo. Hover: fondo secondary/30 + rounded suave.
Separadores `divide-y` entre items en lugar del espaciado uniforme — el ojo escanea cada línea como entidad propia. Padding vertical aumentado a `py-2` para respirar.
Dos cambios narrativos: 1) El antiguo "Razonamiento del coach" pasa a llamarse "Razonamiento de Lutra", con la nutria mascot y el tono mucho más cercano, didáctico, empático (nivel Barrio Sésamo). 2) Tras añadir comida aparece un pop-up con Lutra haciendo un análisis rápido contextual de esa comida en relación a lo que llevas hoy, tu objetivo de peso y el modo nutricional activo — 3-4 frases cálidas con veredicto, contexto y sugerencia accionable.
`LutraMealAnalysisDialog`: nuevo pop-up que aparece tras guardar una comida. La nutria mascot anima con bounce + breathing. Lee items recién añadidos + totales del día (post-add) + objetivo kcal/macros (ajustado por ejercicio) + modo nutricional activo + objetivo de peso, y llama a la edge function `preguntar-coach` con un prompt específico para análisis rápido empático. Botones "Pregunta más a Lutra" (abre el chat con contexto) y "¡Sigamos!".
`ReasoningBox` renombrado a "Razonamiento de Lutra" + nutria mascot en avatar circular + gradient suave + nuevo titular "🦦 Lo que he pensado". Quitado el icono Brain genérico. La experiencia es ahora claramente "Lutra te explica" en lugar de "el coach".
Prompts del campo `razonamiento` en `analizar-comida` (texto + visión) reescritos a tono Barrio Sésamo: primera persona, cero tecnicismos médicos, cero culpabilización, formato natural ("asumí que era una ración normal, no estaba seguro del peso exacto pero por la descripción me cuadra"). Ejemplos en el prompt para guiar al modelo.
Dashboard: `pendingAnalysisMealIdRef` + useEffect que se dispara cuando `meals` se actualiza tras `onMealSaved`. Calcula el contexto del análisis con totals/obj/macros frescos y abre el pop-up. Si el optimistic fetch falla, el ref no se llena y el pop-up no se dispara (no molesta).
Los 3 widgets de tendencia del fondo del dashboard se quedaban a cero tras añadir comida hasta hacer pull-to-refresh. Causa: las lecturas semanales/mensuales/de peso seguían usando el SDK supabase-js + `withTimeout` con fallback `[]`. Cuando el SDK se colgaba silenciosamente en algunos refetches (bug recurrente), el fallback `[]` se aplicaba y los widgets se vaciaban. Migradas a `selectRows` raw fetch que no se cuelga.
`selectRows` en `invoke-edge.ts` ahora soporta `gte`/`lte`/`gt`/`lt` y `order` (column + ascending). Antes solo aceptaba filtros `eq.x` — insuficiente para queries de rango (last 7 días, last 30 días, etc.). Implementación: añade params `column=gte.value` / `order=column.asc|desc` directamente a la URL PostgREST.
`dashboardQuery`: las 3 lecturas que aún usaban el SDK migradas a `selectRows`:
• `weekRes` (meals últimos 7 días con meal_items para calidad NOVA)
• `mesRes` (meals último mes para calendario de actividad)
• `pesosRes` (weight_logs últimos N días ordenados por fecha)
Ahora TODAS las lecturas del dashboardQuery van por raw fetch (PostgREST directo). El SDK supabase-js queda fuera del path crítico de carga del dashboard. Los widgets de tendencia se actualizan al instante tras añadir comida vía el refetch + el optimistic setQueryData que ya teníamos.
Lovable detectó en su dev preview el error "Missing Supabase environment variable(s): SUPABASE_SERVICE_ROLE_KEY". En producción la env está bien y todo funciona, pero el preview de Lovable la borraba al desconectar Supabase Cloud, y eso tumbaba el SSR con "This page didn't load". Nuevo wrapper `supabaseAdminSafe` que devuelve `{data: null, error}` en lugar de throw cuando la env no está disponible — el SSR completa el render y la app sigue navegable.
`src/integrations/supabase/admin-safe.ts`: wrapper graceful sobre el `supabaseAdmin` autogenerado por Lovable. Detecta una sola vez si la env var está disponible (tocando `.auth` del proxy real). Si NO: devuelve un proxy híbrido thenable+chainable que responde a cualquier patrón típico (`.from(t).select(...).eq(...).maybeSingle()`, `.rpc(...)`, `.storage.from(...).upload(...)`) con `Promise.resolve({data: null, error: 'supabaseAdmin not available'})`. El SSR completa el render con datos vacíos en lugar de morir.
Migrados todos los imports de server-functions (`payments.functions.ts`, `reviews.functions.ts`, `push.functions.ts`, `admin.functions.ts`, `payments.server.ts`, `push.server.ts`, hooks `meal-reminders.ts` y `trial-emails.ts`) para usar `supabaseAdminSafe` en lugar del cliente raw. En producción se comporta idéntico (delega al real). En el preview sin env, devuelve errores legibles.
Log `[supabaseAdmin-safe] using mock client — server fns devolverán {data: null, error}` UNA SOLA VEZ en console del server para diagnóstico. Si Supabase se conecta en Lovable Cloud durante el preview, basta con reload — el wrapper reintenta detectar el real.
Tras v1.17.7 el #300 está cazado, pero tras añadir comida WeightTracker desaparecía y los widgets de debajo (WeeklyTrend, WeeklyQuality, GameCard) se quedaban sin actualizar. Causa: el refetch del dashboardQuery usaba `supabase.from("profiles").select` con SDK supabase-js, que se cuelga silenciosamente → withTimeout devolvía null tras 10s → profile en cache se reseteaba a campos vacíos → `pesoInicial` null → WeightTracker return null + cascada de datos vacíos.
`dashboardQuery`: profile fetch migrado del SDK supabase-js a `selectRows` (raw fetch a PostgREST). Mismo patrón que ya usábamos para meals/activity/supplements. El raw fetch tiene timeout duro y NO se cuelga silenciosamente.
Si el raw fetch del profile falla (network blip, RLS cambio temporal), ahora el dashboard NO sobreescribe el profile en cache con uno vacío. En su lugar, lee el profile previo del cache de React Query (`queryClient.getQueryData`) y lo preserva. Así un fallo transitorio del fetch no rompe WeightTracker ni los widgets que dependen del profile (peso, objetivo, fechas, modo nutricional, nombre).
Cuando el fetch del profile falla pero hay profile en cache, log warn `[dashboard] profile fetch failed, reusing previous from cache` para diagnóstico en /admin.
Cazado con sourcemaps + SafeBoundary. SafeBoundary del v1.17.6 marcó `[widget WeightTracker]` como el componente que crasheaba. Descompilando el throw site con sourcemaps de producción confirmé que era `Error(s(300))` lanzado por `finishRenderingHooks` cuando el render actual llama MENOS hooks que el anterior. Causa exacta: WeightTracker tenía un `if (!pesoInicial || !pesoObjetivo) return null;` ANTES del `useMemo(chartData)`. Cuando profile cargaba (tras refetch del dashboard query al añadir comida/actividad), pasaba de 6 hooks a 7 hooks → mismatch → crash.
`WeightTracker.tsx`: movido el `useMemo(chartData)` ANTES del `if (!pesoInicial || !pesoObjetivo) return null;`. Regla básica de React: los hooks deben llamarse en el mismo orden en cada render. Antes: 6 hooks con profile null, 7 hooks con profile cargado → React detectaba el mismatch en `finishRenderingHooks` y tiraba React #300 ("Rendered fewer hooks than expected"). Ahora useMemo siempre se ejecuta y el early return va al final, tras todos los hooks.
Auditado el resto del codebase con grep heurístico — no hay otros early returns en el component body antes de hooks. Los hits encontrados son todos dentro de bodies de useEffect/useCallback/useMemo (donde returns son OK porque son closures) o dentro de `useState` initializers (que no afectan el orden de hooks).
Diagnóstico definitivo gracias a v1.17.6: SafeBoundary identificó WeightTracker por nombre + sourcemaps prod permitieron decodear `ky/wh/Bh/Dv/cb/W1/cf` → `finishRenderingHooks`/`renderWithHooks`/`updateFunctionComponent`/etc. + análisis del bundle vivo mostró el `throw Error(s(300))` en la rama de mismatch de hooks. Cazar bugs en React minificado sin esta infra era imposible.
El React #300 aún se manifiesta para algunos usuarios tras añadir comida/actividad. El stack minificado solo muestra React reconciler internals (ky/wh/Bh/Dv/cb/W1/cf), no qué componente concreto renderiza `<undefined />`. Mientras lo cazo: 1) cada widget grande del dashboard ahora va envuelto en SafeBoundary — si un widget falla, los demás SIGUEN funcionando y se ve un fallback con el nombre del widget caído. 2) Sourcemaps hidden en producción para decodear los próximos stacks. 3) Stack ampliado de 8 a 30 líneas en la UI de error.
Dashboard: cada widget grande envuelto en `<SafeBoundary name="…">`. Si falla CoachTips, MacrosDonut, MicrosRadial, FoodQualityCard, ActivityTracker, DailySupplementsCard, WeightTracker, WeeklyTrend, WeeklyQuality o GameCard, el fallback aparece SOLO en ese widget ("FoodQualityCard no ha cargado bien") y el resto del dashboard sigue funcional. Antes UN solo bug tumbaba la página entera al ErrorComponent global.
`SafeBoundary` persiste cada error en `lutrai_last_errors` con prefijo `[widget X] ...` y el componentStack de React. En /admin → Últimos errores ahora se ve EXACTAMENTE qué widget crasheó (por su `name`) sin necesidad de decodear stacks minificados.
`vite.config.ts`: `sourcemap: "hidden"`. Genera los .map files junto al bundle pero NO incluye sourceMappingURL — el browser no los carga automáticamente. Yo puedo curlear `https://lutrai.app/assets/index-XXX.js.map` cuando necesite decodear un stack en futuras versiones.
ErrorComponent muestra ahora 30 líneas del stack en lugar de 8. Esto da espacio para que se vean frames de componente real bajo los frames de React fiber internos (que son siempre los 7-8 primeros).
Replanteado el auto-reload: ahora SOLO se dispara por chunk-stale REAL (módulo JS/CSS no existe en el servidor). Antes el regex cazaba React errors #300/#418 y "Cannot read properties of undefined" — esos son bugs de código, no chunk-stale, y recargar no arregla nada, solo molesta. Además: añadir actividad actualiza el widget de kcal al INSTANTE (optimistic update), no tras 2-4s de SELECT como antes.
Regex de auto-reload restringido a SOLO los patrones de chunk-stale reales: `Loading chunk N failed`, `Loading CSS chunk N failed`, `Failed to fetch dynamically imported module`, `Importing a module script failed`. Quitados los falsos positivos: React errors `#300|#418|#310|#423|#425`, `Element type is invalid`, `Cannot read prop(?:erty|erties) of undefined`. Aplicado al ErrorComponent y al global window.error listener inline.
Filosofía: el auto-reload existe para que tras un deploy el bundle viejo cargado en una pestaña abierta se actualice solo. Si el error NO es de chunk-stale, recargar no arregla nada — debe pintarse el ErrorComponent y que el usuario decida. Esto elimina la sensación de "recargas espurias tras añadir comida" que reportó el usuario.
`ActivityTracker.guardar()` ahora hace OPTIMISTIC update. Antes esperaba al INSERT en BD + el SELECT posterior antes de notificar al dashboard via onChange — el widget de kcal tardaba 2-4s en reflejar la nueva actividad (el usuario lo percibía como "no se actualiza"). Ahora: 1) añade el entry localmente con id temporal, 2) propaga al dashboard via onChange INMEDIATAMENTE → el ring/restante se mueven al instante, 3) hace el insert en BD, 4) si falla, rollback al estado previo. El SELECT posterior solo reconcilia el id temporal con el real.
Cazado en Chrome devtools en directo: el "algo ha salido mal" tras añadir comida + scroll NO era React #300 (chunk-stale) sino React #418 (hydration mismatch). Causa: el `useState` del WelcomeSplash leía localStorage en el initializer → SSR devuelve false, client devuelve true → HTML server vs client distinto → #418. Mi auto-reload listener cazaba el #418 como chunk-stale y disparaba reload espurio. También arreglado el bug del primer tap del trash en móvil (Radix Tooltip se tragaba el primer touch).
`WelcomeSplash.tsx`: el `useState(() => { read localStorage })` causaba React #418 hydration mismatch (SSR sin localStorage → false, client con localStorage → true). Movido el gate de versión/día a un `useEffect` post-mount. Inicialmente siempre `show=false` igual que SSR; useEffect flippea a `true` si toca mostrar. Cero mismatch, cero #418, cero reload espurio.
`dashboard.tsx`: quitados los `InfoTip` (Radix Tooltip) que envolvían los botones editar/eliminar comida. En touch (móvil), Radix Tooltip se traga el primer tap mostrando el tooltip — el usuario tenía que tocar DOS veces para que se disparara el click. Los iconos pencil/trash son universales; el `aria-label` + `title` HTML nativo cubre accesibilidad sin interferir con touch. Quitado también el `onPointerDown` ineficaz que intentaba parchearlo.
Revisado el resto de componentes (FloatingReviewButton, MedicalDisclaimer) para el mismo anti-patrón de read-localStorage-en-render. Todos OK — solo WelcomeSplash tenía el bug porque su show=true podía cambiar entre SSR y client en frame 0.
Tras cazar dos React #300 ("Element type invalid: got undefined") en el panel de errores de /admin, blindo los dos puntos donde un lookup tipo `Icons[key]` puede devolver undefined si por chunk-stale post-deploy el enum compilado no tiene la key esperada: `CoachTips` (tono del tip → icono) y `WhatsNewDialog` (tag de changelog → icono). Ahora ambos caen a un icono neutro en lugar de crashear toda la página.
`CoachTips.tsx:76`: `const Icon = ICONS[t.tono] ?? Lightbulb` y `const style = STYLES[t.tono] ?? STYLES.info`. Si por chunk-stale el bundle viejo lee un `tono` que no está en su `ICONS` compilado, ya no renderiza `<undefined />` (React #300) — usa el icono Lightbulb y estilo info como fallback neutro.
`WhatsNewDialog.tsx:78`: `const meta = TAG_META[c.tag] ?? TAG_META.mejora` y `const Icon = meta.icon ?? Plus`. Mismo patrón defensivo — si la entrada del changelog usa un tag no registrado en TAG_META (futura compatibilidad con tags nuevos en bundles viejos), cae a "mejora" + icono Plus en lugar de crashear con "Cannot read properties of undefined (reading 'icon')".
Usuario reportó que tras añadir una comida y hacer scroll salía brevemente la pantalla de error y luego se arreglaba al refrescar. Diagnóstico: el ErrorComponent se pintaba ~50-200ms ANTES de que el `useEffect` disparara `location.reload()` (el reload sí llegaba, pero el flash quedaba). Ahora, si detectamos chunk-error + vamos a auto-reload, mostramos un loader neutro en lugar de la UI de error.
`ErrorComponent` calcula sincrónicamente si va a auto-reload (chunk-error regex match + último reload >30s atrás según `sessionStorage.lutrai_chunk_reload_ts`). Si sí → early return con un spinner pequeño en lugar de la pantalla de error completa. El `useEffect` sigue disparando el reload inmediatamente. El usuario ya no ve el parpadeo de "Algo no ha cargado bien".
Si el chunk-error sigue tras un reload reciente (<30s), la UI de error sí se muestra — eso significa que el reload no arregla el problema y el usuario necesita la opción de reintentar manualmente o ir al inicio. Comportamiento previo preservado para errores reales (no chunk-stale).
Splash de bienvenida reducido a lo esencial: nutria animada + fondo hexagonal + orbitales. En lugar del tagline genérico ("Diseñado para humanos, no para hojas de cálculo"), ahora muestra un Tip de Lutra personalizado para el usuario — saludo por hora del día con su nombre + frase contextual según racha, peso, comidas registradas, etc. Botón Entrar centrado en el flujo (no absolute) con onClick simple para que funcione igual en desktop y mobile.
Splash minimalista: fuera Wordmark ("lutrAi.app" letra a letra), fuera Sparkles, fuera LoadingPips, fuera tagline fijo. Quedan: HexBackground (heptágono de circuit board), Orbitals (10 partículas en 3 anillos a 10/14/18s) y Mascot (entrance bounce + breathe + bob + halo). Mismas animaciones, menos ruido visual.
Componente `LutraQuickTip` reemplaza el tagline. Lee el snapshot de contexto del usuario (escrito por el dashboardQuery la sesión anterior — racha, peso, comidas hoy, día de la semana) desde `welcome-context-snapshot.ts` y llama a `getLutraGreeting(ctx)` para componer un saludo personalizado: "🥑 Buenos días, Iñaki" + frase contextual ("¿Cómo arrancas el día?…"). Card translúcida con backdrop-blur, fade-up a 1500ms.
Botón "Entrar" centrado dentro del flex column principal (no `position: absolute` con `bottom`), con `onClick` simple en lugar de `onPointerDown` + `preventDefault`. Eso causaba que en algunos navegadores el click no se registrara correctamente. Ahora: type="button", touchAction=manipulation, WebkitTapHighlightColor transparent, minHeight 48px para tap target accesible. Probado en desktop click y mobile tap.
Si el snapshot de contexto no existe todavía (primera apertura tras instalar o tras borrar storage), `getLutraGreeting` cae a una variante genérica por hora — sigue siendo personalizada por nombre. Markdown ligero (`**negrita**` → `<strong>`) renderizado inline sin librería extra.
El clasificador de calidad alimentaria (real_food / procesado / ultraprocesado) ahora lo decide la IA al analizar cada comida, no un regex local. Esto arregla casos absurdos como "tarta de merengue" saliendo como real_food porque el regex no la cogía y caía al default "procesado" — o peor, descomponiéndola en huevo+azúcar+harina y dejando que cada ingrediente apareciera como real_food individualmente.
Schema de `registrar_comida` añade campo `food_quality` por item con enum `real_food` | `procesado` | `ultraprocesado`. La IA lo rellena obligatoriamente según criterio NOVA contextual (no por matching de palabras). Sanitizer en la edge function valida el enum y, si la IA omite el campo (respuestas viejas), pasa `null` para que el cliente caiga al regex como fallback.
Prompts de Sonnet (texto) y Opus (visión) ampliados con bloque NOVA: definición clara de las 3 categorías, ejemplos típicos por nivel, y CASOS LÍMITE que solían fallar (tartas/pasteles/bollería incluso "casera" → ultraprocesado; yogur de sabores → ultraprocesado; pan blanco panadería vs Bimbo industrial; patatas fritas caseras vs de bolsa).
Nueva excepción en "DESCOMPOSICIÓN OBLIGATORIA": dulces y postres (tartas, pasteles, bollería, helados, galletas, chocolatinas) se devuelven SIEMPRE como UN único item con su nombre completo y food_quality="ultraprocesado", nunca descompuestos en "huevo + azúcar + harina + mantequilla". Eso engañaba al usuario haciendo que cada ingrediente individual saliera como real_food cuando lo que comió fue una tarta.
Cliente respeta la decisión NOVA de la IA: `meal-record.functions.ts`, `MealEditDialog.tsx` y `VoiceMealDialog.tsx` usan `item.food_quality` (de la IA) si viene, y solo caen a `classifyFoodOrDefault(nombre)` (regex) cuando la IA omite el campo. La columna `food_quality_override` sigue protegiendo overrides manuales del usuario.
`FoodQualityCard` ya no re-clasifica al display con regex cada vez (ese comportamiento perdía la decisión de la IA en items nuevos). Precedencia actualizada: override manual → stored (IA o regex previo) → regex live → default "procesado". Para items legacy que tienen food_quality=null en BD, sigue funcionando el fallback regex.
Tipo `AIItem` y `MealItemBasic` incluyen `food_quality?: "real_food" | "procesado" | "ultraprocesado" | null` para propagar la decisión de la IA a través de toda la cadena (edge fn → cliente → onAnalyzed → guardar).
Bug crítico del v1.16.3: tras dictar la primera frase el micrófono dejaba de capturar audio aunque el botón seguía en modo grabar — había que parar manualmente y volver a empezar. Causa: el auto-restart llamaba `rec.start()` sobre la MISMA instancia de SpeechRecognition que ya había emitido `onend`; en muchos navegadores (Chrome móvil especialmente) eso no captura más audio aunque no tire error. Fix: crear SIEMPRE una instancia nueva al reiniciar.
Refactor del auto-restart de SpeechRecognition en `VoiceMealDialog` y `AIFoodInput`: extraído helper `attachFreshRecognition(Ctor, isMobileDictation)` que crea una INSTANCIA NUEVA del reconocedor cada vez. `startListening` ahora solo prepara el estado inicial (transcript acumulado, timestamps, `wantsToListenRef = true`) y delega en el helper. `onend` también llama al helper en lugar de hacer `rec.start()` sobre la instancia muerta. Resultado: el dictado fluye sin cortes silenciosos.
El handler `onend` nullifica `recRef.current` ANTES de programar el restart, así si llega un `onerror` paralelo de la instancia vieja no pisa la nueva. Y si `attachFreshRecognition` falla en `rec.start()` (timing con la instancia anterior), limpia estado y muestra toast claro "prueba de nuevo" en lugar de quedarse colgado.
Estado preservado entre instancias (no se pierde lo dictado): `finalRef.current` (transcript acumulado) y `lastFinalTimestampRef.current` (gap inter-frase para la puntuación inteligente del v1.16.3). Estado reseteado por instancia (porque `e.resultIndex` y `e.results` arrancan de cero): `processedResultsRef` y `lastFinalRef`.
Dos mejoras al dictado por voz: (1) el micrófono ya no se corta cuando paras unos segundos para pensar — auto-restart en cuanto el navegador detiene el reconocedor (común en móvil aunque pidamos continuous=true). (2) El transcript se devuelve con puntuación inferida del silencio entre frases (comas en pausas medias, puntos en pausas largas, mayúsculas tras punto) — Claude lo parsea mejor.
Auto-restart del SpeechRecognition en `onend`: mientras `wantsToListenRef` esté activo (usuario no pulsó stop), reabrimos el reconocedor con un delay de 120ms. Si el navegador (especialmente Chrome móvil) detiene la sesión tras silencio breve aunque pidamos `continuous: true`, lo reabrimos sin que se note. Errores `no-speech` / `aborted` / `audio-capture` ya no terminan la sesión — son normales en este ciclo.
Helper `appendSpeechSmart(base, chunk, gapMs)` en `meal-input-utils.ts`. Inserta puntuación entre chunks finales del reconocedor según el GAP (silencio en ms) detectado entre uno y el siguiente: `<600ms` → espacio (continuación), `600-1600ms` → coma (pausa media, separador de items), `>1600ms` → punto + mayúscula (fin de frase). Compensa que la Web Speech API en español no devuelve puntuación nativa.
Helper `normalizeTranscript(value)` para la pasada final al pulsar stop o analizar: capitaliza primera letra de todo + de cada frase tras `.!?`, quita espacios antes de puntuación, garantiza espacio después, añade punto final si falta. Sonnet/Opus reconoce mucho mejor texto puntuado que un wall-of-text.
El `transcript` mostrado al usuario se va puntuando en vivo según habla — ve comas y puntos aparecer como dicta, no como una sola línea corrida. Al pulsar stop o analizar se aplica la pasada final de normalización (mayúsculas, punto final, limpieza).
Mismos fixes aplicados a `AIFoodInput.tsx` (que comparte el pattern de SpeechRecognition con `VoiceMealDialog.tsx`). Cualquier punto de entrada por voz se beneficia: dashboard FAB, perfil, configuración.
Usuario reportó error pantalla "Cannot read properties of undefined (reading 'icon')" tras el deploy de v1.16.1. Causa: chunk-stale en el bundle viejo de la pestaña abierta. El auto-reload por chunk-stale debería haberlo arreglado solo, pero el flag binario se quedaba pegado tras un primer reload. Doble fix: navItems defensivo + flag pasa a timestamp con ventana de 30s.
`navItems.map` ahora filtra items undefined antes de renderizar el icon. Si por chunk-stale post-deploy un item del nav o su lucide-react icon resulta undefined, el filter lo descarta en lugar de crashear con `Cannot read properties of undefined (reading 'icon')`. Patrón aplicable a otros maps de la app.
Auto-reload por chunk-stale ahora usa TIMESTAMP en lugar de flag binario 1/0. Antes: si ya habías recargado por chunk-error en esta sesión, NUNCA más se disparaba el auto-reload aunque hubiera otro chunk-error después → te quedabas con la pantalla de error. Ahora: si han pasado >30s desde el último reload, permite OTRO reload. 30s es suficiente para confirmar boot ok del primer reload pero corto para responder a errores posteriores.
Regex del global error listener ahora incluye `Cannot read prop(?:erty|erties) of undefined` (antes solo lo tenía el del ErrorComponent, no el inline script del head). Ahora window.error globales con ese patrón también triggerean auto-reload, no solo los que llegan al ErrorBoundary de React.
Tres mejoras al sistema de reviews tras feedback: (1) botón flotante CTA permanente arriba-derecha en todas las rutas del app, visible hasta que dejen reseña — funciona en CUALQUIER momento (trial activo, free, premium). (2) Tarjeta CTA en /perfil con el incentivo "+15 días gratis" si no hay review, o resumen con "Editar" si ya hay. (3) Errores manejados graceful cuando la tabla `reviews` aún no se ha aplicado en BD (Lovable tarda en sincronizar la migración).
`FloatingReviewButton` — chip flotante esquina sup-der bajo el header, visible siempre que el usuario no haya dejado review. Pulse animation (halo) + microcopy contextual ("· +15d" solo si está en trial/free). Botón X discreto para ocultar 4h sin spam. Mount en `_app.tsx` layout — aparece en dashboard, historial, perfil, logros, pricing.
`ReviewCtaCard` en /perfil sección Preferencias. Dos estados: (a) sin review = tarjeta con gradient primario/accent + incentivo +15 días + botón "Dejar reseña". (b) con review = tarjeta success con las estrellas actuales + badge "Visible en landing" si ≥4★ + botón "Editar".
Modal de review extraído a `ReviewFormModal` reusable (controlled via open/onOpenChange props). `ReviewTrialModal` ahora es un thin wrapper que decide cuándo auto-pop (level=free + sin review + no snoozeado). Mismo modal usado desde `FloatingReviewButton`, `ReviewCtaCard` y `ReviewTrialModal`.
Manejo graceful del periodo de transición tras deploy: si la tabla `reviews` aún no existe en BD (Lovable no aplicó la migración todavía), `getMyReview` falla con error de tabla inexistente. Lo capturamos y devolvemos null en lugar de tirar — el botón flotante simplemente no se renderiza hasta que la migración esté aplicada. Antes el error rompía toda la app.
Toast del modal ahora detecta el error específico de tabla no migrada y muestra mensaje claro: "El sistema de reseñas aún no está activo en este entorno. Inténtalo en unos minutos cuando Lovable termine de aplicar la migración". Antes mostraba el error SQL críptico.
Sistema completo de reseñas en 6 piezas: (1) trial gratuito baja de 30 a 7 días; (2) tras expirar, modal pide reseña 1-5★ + comentario a cambio de 15 días extra; (3) columna nueva en /admin con las estrellas del usuario (click = ver detalle); (4) push notification al admin cada vez que alguien deja reseña; (5) sección "Lo que opinan nuestros usuarios" en la landing con social proof (solo 4★ y 5★ son públicas); (6) el resto solo las ves tú en el panel.
Trial gratuito reducido de 30 a 7 días (RPC `get_access_level` ahora usa `+ interval '7 days'` en lugar de 30). Nueva columna `profiles.trial_extended_until` que cuando está poblada extiende el trial hasta esa fecha. Trigger SQL `extend_trial_on_review` la setea automáticamente a `now() + 15 días` al insertar una review (idempotente — solo la primera vez).
Modal `ReviewTrialModal` aparece automáticamente cuando el trial ha caducado y el usuario no ha dejado review. UI con 5 estrellitas interactivas (hover effect + microcopy contextual por rating) + textarea + botón "Enviar y +15 días gratis". Snooze de 24h si pulsa "Ahora no" (no spamea). Aviso de privacidad: si rating >= 4 puede salir en landing pública.
Sección "Lo que opinan nuestros usuarios" en la landing (`/`) entre PRICING y TRUST. Grid responsive 1/2/3 columnas. Cada review con avatar (fallback iniciales), nombre, estrellas, comentario entre comillas tipográficas. Solo se muestran reviews con rating ≥ 4 — la server-fn filtra y limita a 20. Si no hay ninguna, la sección queda oculta.
Columna "Reseña" nueva en `/admin` entre VIP y Comidas. Si el usuario tiene reseña, muestra las estrellitas con título del comentario; click abre modal con detalle completo + estrellas + comentario + indicador visual (verde si ≥4 → aparece en landing, amber si <4 → solo admin). Si no tiene reseña, `—`.
Push notification al admin cada vez que un usuario deja una review. Título: `Nueva reseña de {nombre} ★★★★★`. Body: primeros 140 chars del comentario. Click → abre `/admin`. Tag `lutrai-review` para que se agrupen si llegan varias.
Tabla `public.reviews` con RLS estricto: usuario solo lee/escribe la suya, admin lee/borra todas. Constraint UNIQUE(user_id) — 1 review por usuario, editable pero no duplicable. Trigger SQL `extend_trial_on_review` BEFORE INSERT setea `profiles.trial_extended_until` la primera vez (idempotente, ediciones no acumulan 15d extra).
Usuario reportó un error pantallazo tras añadir foto-comida que se auto-recuperó tan rápido que no pudo capturarlo. Ahora todos los errores globales (window.error, unhandledrejection, route ErrorComponent) se persisten en localStorage ANTES del auto-reload. Panel nuevo en /admin para ver los últimos 10 errores con timestamp, mensaje, stack truncado y URL.
Persistencia de errores en localStorage (`lutrai_last_errors`). Cada error capturado (incluso los que se auto-recuperan con hard reload por chunk-stale) queda guardado con timestamp + mensaje (500 chars) + stack (2000 chars) + URL + motivo. Sobreviven a reloads. Capacidad: últimos 10 errores.
Panel "Últimos errores capturados" en /admin (sección Mantenimiento). Lista colapsable, cada error con `<details>` expansible para ver stack completo. Botón "Limpiar" para resetear el historial. Útil para diagnosticar el error pantallazo que se auto-recuperó tan rápido que el usuario no pudo capturarlo.
Global listener en `__root.tsx` ahora persiste TODOS los errores (no solo los que disparan chunk-reload). Antes solo logueábamos a console — perdidos en el reload. Ahora quedan en localStorage para inspección posterior.
El historial tenía el SET COMPLETO de bugs que arreglé en el dashboard hace varias versiones: `supabase.auth.getUser()` colgándose, sin retry, sin refetchOnMount, sin realtime, usaba peso_inicial_kg. Mismo patrón de fix: raw fetch con timeouts + realtime + refetchOnMount:always + peso current.
Historial ya no requiere pull-to-refresh. Causa: `supabase.auth.getUser()` se colgaba silenciosamente (mismo bug ya arreglado en dashboard v1.14.1). Reemplazado por `getCurrentUserId()` con 3 reintentos. Si no hay JWT, devuelve datos vacíos en vez de quedarse colgado eternamente.
Cada SDK read del historial envuelto en `withTimeout` (8-10s, fallback vacío). Mismo helper que la dashboardQuery — si una de las 4 lecturas se cuelga, las demás siguen y la página renderiza al menos parcialmente. Antes una sola lectura colgada bloqueaba el Promise.all entero.
TMB/TDEE/objetivo del historial ahora usan el peso CURRENT (último weight_log) en vez de `peso_inicial_kg`. Mismo bug que arreglé en dashboard v1.15.3 — pero el historial lo seguía teniendo. Resultado antes: el `obj.kcalObjetivo` que el historial mostraba era distinto del que mostraba el dashboard si habías perdido/ganado peso.
Realtime subscription en el historial — escucha cambios en meals, meal_items, activity_logs, weight_logs y refresca automáticamente. Antes solo dependía de que el dashboard invalidara `["historial"]` al guardar comida, lo cual no servía si el cambio venía de otra pestaña o desde el editor de comidas.
`useQuery` del historial con `refetchOnMount: "always"` + `refetchOnWindowFocus: true` + `retry: 3` con backoff exponencial. Navegar a /historial siempre dispara refetch fresh. Volver de background (alt-tab) también.
El OS splash no puede animarse (limitación del estándar PWA — el SO solo pinta icono + color de fondo sólido antes de que la app cargue). Lo que SÍ se puede hacer: cambiar el background_color del manifest de naranja saturado a cream pastel, IGUAL que el fondo de la animación React. Así la transición OS splash → animación es invisible: mismo cream, mismo mascot, solo añade animaciones encima.
Manifest `background_color` cambiado de `#ed8a6f` (naranja coral saturado) a `#F8F0DB` (splash-cream, el mismo color que el centro de la radial gradient en la animación React). Cuando Android pinte el OS splash al abrir la PWA, verás: cream pastel + mascot transparente centrado (los iconos ya eran transparentes desde v1.15.3). Cuando React monta, la animación arranca sobre el mismo cream sin corte visible.
`theme_color` se mantiene en `#ed8a6f` (naranja) porque controla el color de la barra de estado del sistema mientras usas la app, NO el splash. Mantener identidad de marca en la status bar.
Manifest cache-busters bumpeados a `v=1158` + `id: lutrai-app-v3` para forzar a Android Chrome a re-fetchear el manifest cuando llegue el deploy. Sin esto la PWA seguiría usando los valores cacheados del manifest viejo.
El error "INVALID PKCS8 INPUT" al enviar push de prueba significa que las claves VAPID en `app_config` están en formato incorrecto (probablemente Lovable las guardó como PKCS8 cuando webpush-webcrypto espera raw 32 bytes base64url). Solución: server-fn `regenerateVapidKeys` (admin-only) genera un par fresh con la propia librería + botón en /admin.
Server-fn `regenerateVapidKeys` admin-only en `push.functions.ts`. Genera nuevo par via `ApplicationServerKeys.generate()` (formato correcto garantizado), upsert ambas claves en `app_config`, asegura subject `mailto:` válido, borra TODAS las `push_subscriptions` (atadas a la public key vieja → endpoints rechazarían pushes con la pública nueva), resetea el caché en memoria. Devuelve nueva publicKey + count de suscripciones eliminadas.
Botón "Regenerar VAPID" en `/admin` (sección Mantenimiento, junto a las estadísticas). Confirmación obligatoria porque borra todas las suscripciones. Tras click, toast de éxito con el inicio de la nueva public key + número de subs borradas. Usuarios afectados deben desactivar/reactivar el switch de notificaciones para registrar nueva suscripción contra la nueva clave.
Error message en `getKeys()` ampliado: si `fromJSON` falla (típicamente PKCS8 error), el throw incluye el motivo + instrucciones ("el admin debe llamar a regenerateVapidKeys (botón en /admin)"). Antes solo decía "VAPID keys missing". Combinado con v1.15.6 (diagnóstico real en toast), el usuario ahora ve exactamente qué pasa y qué hacer.
Dos fixes: (1) el splash ahora sale OBLIGATORIAMENTE la primera vez que abres una versión nueva (no solo 1 vez al día) — así el usuario que reinstaló la PWA en Android verá la animación aunque Chrome haya conservado el localStorage. (2) El toast del "Enviar push de prueba" ahora muestra el error real del endpoint web-push (HTTP status, dominio FCM/Mozilla/Apple, texto de respuesta) para diagnosticar exactamente qué falla.
WelcomeSplash con doble gate: nueva versión OR nuevo día. Antes solo era daily gate, pero Android Chrome no borra localStorage al reinstalar PWA (solo borra el shortcut del home screen), así que `lutrai_welcome_last_shown_date` persistía y el splash NO volvía a salir hasta el día siguiente. Con el version gate (`lutrai_welcome_last_shown_version`), cada deploy nuevo garantiza que veas la animación al menos una vez. Console.log diagnóstico ampliado: `[WelcomeSplash vX.Y.Z] gate: lastVersion=... isNewVersion=... lastDate=... isNewDay=... → show=...`.
Push test con diagnóstico real. Antes el toast decía solo "Falló el envío (1/1)" sin pistas. Ahora `sendPushToUser` devuelve `errors: string[]` con el motivo específico de cada fallo: `HTTP 401 desde fcm.googleapis.com: Unauthorized: Missing authorization header` (ejemplo). El toast muestra el primer error con duración 10s para que dé tiempo a leerlo. Así podrás decirme exactamente qué dice el endpoint y diagnosticamos.
Suscripciones caducadas (404/410) ahora se identifican y eliminan automáticamente de `push_subscriptions`. El usuario solo necesita desactivar/reactivar el switch para registrar una nueva.
El status chip arriba del anillo decía "¡EN TU OBJETIVO!" en verde aunque te hubieras pasado un 50% del budget. Causa: clampábamos `overall` al 100% ANTES de evaluar el umbral 110, así que cualquier excedente pasaba el check y se mostraba como verde. Visible en la captura del usuario v1.15.4: comió 2898 de 2533 budget, status decía "¡EN TU OBJETIVO!" contradiciendo el chip rojo "+365 por encima" y el slider "Por encima del objetivo".
BUG GRAVE en el status del CalorieRingHero: `overall = Math.min(100, ...)` clampaba al 100% antes de evaluar los umbrales de status. Resultado: comieras lo que comieras por encima del budget (102% o 200%), el status decía "¡En tu objetivo!" en verde porque pasaba el check `<= 110`. Ahora usa `overallReal` sin clamp para el status (el ring fill sigue con clamp visual). 5 estados: <60 "Por debajo" (info) · 60-95 "En buen ritmo" (primary) · 95-105 "¡En tu objetivo!" (success) · 105-115 "Por encima" (warning) · >115 "Te has pasado" (destructive).
Umbral de "En tu objetivo" reducido de 110% a 105% para ser más realista (110% = +10% del budget ya es bastante). Y añadido tier intermedio "Por encima" (warning, 105-115%) antes de "Te has pasado" (destructive, >115%) — así no saltas directo de verde a rojo.
Auditoría del widget tras tu queja de "no me cuadra". Tres cambios para que sea más claro: (1) el chip "te quedan" muestra "+X por encima" en rojo cuando te pasas del budget en vez de quedarse en 0. (2) Header del slider renombrado a "Déficit vs mantenimiento" y tiene tooltip con la fórmula entera mostrando los números reales. (3) Splash design del handoff confirmado idéntico al implementado en v1.15.0.
Chip "te quedan" del CalorieRingHero ahora muestra "+X por encima" en rojo (con icono destructive) cuando comes más que tu budget (Base + Ejercicio). Antes lo clampábamos a 0 con `Math.max(0, restantes)` → si te pasabas, seguía viendo "Restan 0" sin saber cuánto. Ahora ves exactamente cuánto te has excedido.
Header del DeficitSlider renombrado de "Déficit de hoy" a "Déficit vs mantenimiento" (más explícito). Tooltip nuevo (toca el header) muestra la fórmula completa con tus números reales: `Déficit HOY = mantenimiento real (TDEE+ejercicio) − comido`. Útil para ver exactamente de dónde viene cada número.
Confirmado: el splash que el usuario ha vuelto a enviar (LUTRAI-handoff.zip) es BIT-IDÉNTICO al handoff original que ya integré en v1.15.0 (mascot bouncing + hex pattern dibujándose + wordmark letra-a-letra + tagline + pips). Si sigue sin verse: badge "🦦 v1.15.x" arriba-derecha del splash diagnostica caché stale del bundle. La forma drástica de forzar refresh: Chrome → Settings → Site Settings → lutrai.app → Clear & reset.
Dos cambios gordos: (1) iconos PWA reemplazados por mascot transparente sin texto incrustado — el SO mostrará la nutria flotando sobre naranja Lutra, sin palabra cortada. (2) BUG GRAVE encontrado en cálculos: el dashboard usaba el peso INICIAL del perfil para TMB/TDEE/déficit/macros, así que conforme perdías/ganabas peso los números NO se actualizaban. Ahora usa el peso ACTUAL (último weight_log).
BUG GRAVE en cálculos: TMB, TDEE, déficit ideal y objetivo de proteína se calculaban con `peso_inicial_kg` (el peso de cuando hiciste el onboarding) en vez del peso ACTUAL. Si empezaste a 80kg, objetivo 70kg, y ahora pesas 75: la app mostraba siempre los números basados en 80kg. Resultado: déficit ideal sobreestimado, objetivo de proteína 9-10g más alto del real, slider mal calibrado. Ahora usa el último weight_log (con fallback a peso_inicial_kg si no hay registros). El resto de cálculos (totales meals/suplementos, kcal quemadas, restantes) ya estaban bien.
OS splash drástico: iconos `icon-192.png` e `icon-512.png` reemplazados por el mascot transparente (cutout sin fondo cream ni texto "lutrAi.app" incrustado). Sobre el `background_color: #ed8a6f` del manifest, el SO ahora mostrará la nutria flotando sobre naranja Lutra, sin palabra cortada. Generados con `sips` desde el mascot 1024×1024.
Manifest con cache-busters fuertes: `?v=1153` en los src de iconos + `id: "lutrai-app-v2"` + `start_url` con query. Android Chrome trata el manifest como nuevo y re-fetchea los iconos sin tener que reinstalar la PWA (en teoría; en práctica puede tardar varias aperturas).
Eliminadas las entradas `purpose: "maskable"` del manifest. El mascot ocupa todo el frame del icono y no respeta la safe-zone del 80% que necesita el maskable, así que Android cropeaba feo en adaptive icons. Mejor solo `purpose: "any"` que muestra el icono como-es.
Para diagnosticar por qué a veces el usuario sigue viendo "la pantalla antigua" pese a que lutrai.app tiene v1.15.x desplegado: añadido un badge "🦦 v1.15.2" pequeño y semi-transparente arriba a la derecha del WelcomeSplash. Si lo ves coincidiendo con la versión esperada, el bundle es fresh; si ves una versión vieja en el badge → el navegador tiene caché stale.
Badge "🦦 v{APP_VERSION}" pequeño y discreto en la esquina sup-der del WelcomeSplash. Sin impacto visual notable pero permite diagnosticar al instante qué versión del código está renderizando el splash que ves.
El slider de déficit no contaba las kcal quemadas en el cálculo. Si entrenas, tu cuerpo quema MÁS hoy y por tanto el déficit real es mayor del que mostraba. Corregido: tanto los números (Hoy / Ideal) como la posición del marcador en el slider ahora usan el TDEE efectivo (basal + ejercicio del día).
Déficit "Hoy" ahora se calcula contra (TDEE + kcal quemadas hoy), no solo TDEE basal. Si tu TDEE es 2400 y has quemado 500 en ejercicio, tu mantenimiento real del día es 2900 — si comes 1900 estás en déficit de 1000 (no 500 como decía antes). Ahora el número refleja la realidad.
Posición del marcador en el slider ahora usa effectiveTdee = TDEE + burned como denominador. Antes el marcador no se movía al añadir ejercicio aunque tuvieras más margen para comer; ahora sí refleja la nueva posición relativa al gasto real del día.
Posición del marker del TARGET también ajustada (effectiveTarget = kcalObjetivo + burned). Lutra te deja consumir las kcal del ejercicio como margen extra, así que el target del slider se desplaza a la derecha cuando entrenas.
Tooltip del "Ideal" ampliado: si has hecho ejercicio hoy, te explica que tu gasto total ha subido a X kcal y tu margen de comida a Y kcal. Antes solo mencionaba el TDEE basal sin contexto del entreno.
Status labels más claros: "Por debajo del objetivo" (info) cuando comes menos que el target, "Por encima del objetivo" (warning) cuando lo superas pero sin pasar de mantenimiento, "Hoy en superávit" (destructive) cuando comes más que el gasto total. Diferenciados por goalDirection.
Implementado el welcome splash diseñado en Claude Design: bounce de entrada del mascot + patrón hex dibujándose stroke a stroke + 10 partículas orbitando + 6 sparkles alrededor de la manzana + wordmark letra-a-letra con colores correctos (lut + .app en sage, rAi en coral) + circuit dot sobre la i + tagline + loading pips. Animación CSS pura, ~2.7s de coreografía + loops infinitos (breathe + bob + orbit + twinkle + pip).
WelcomeSplash reescrito desde cero siguiendo el handoff de Claude Design (lutrai/project/design_handoff_welcome_splash/README.md). Mascot otter cutout con bounce de entrada (1100ms, ease-bounce, scale 0.32→1.12→0.96→1.02→1.0 + rotación -12°→0° + translateY 60→0). Halo coral radial pulsante. Loops breathe (scale 1↔1.025, 3.6s) + bob (translateY -6px, 4.2s).
Hex/circuit background SVG en sage: 7 hexágonos + 8 conectores + 8 dots que se dibujan en cascada con stroke-dashoffset (200ms→560ms para hexes, 300ms→720ms para líneas, 700ms→1190ms para dots). Opacity 0.45, no compite con el mascot.
10 partículas orbitando en 3 anillos (165/200/235px) a velocidades 10/14/18s. Cuatro colores rotando (coral, sage-deep, coral-deep, apple). Twinkle individual (scale 0.85↔1.15) en bucle alternate. Sparkles (6 estrellas 4-puntas) alrededor de la manzana con stagger 120ms.
Wordmark "lutrAi.app" como texto vivo (no PNG) en Quicksand 700 con stagger por letra (55ms, 520ms duración). Colores literales del spec: 'lut' + '.app' en sage (#8FA783), 'rAi' en coral (#ED8A6F). Circuit dot sage pop sobre la 'i' a 1780ms. Tagline en Nunito 500: "Diseñado para humanos, no para hojas de cálculo."
Loading pips: 3 dots coral pulsando en secuencia (1.1s, stagger 140ms). Bottom safe-area aware (max 56px, env(safe-area-inset-bottom)). Botón "Entrar" aparece tras la coreografía completa (2700ms) y permite saltar antes del auto-cierre (8s).
Tokens del design (--splash-cream, --splash-coral, --splash-sage, --splash-apple, etc.) + easings (--splash-ease-standard/out/bounce) añadidos a styles.css. Fuentes Quicksand + Nunito vía Google Fonts display=swap.
Mascot cutout (mascot-otter-cutout.png, 455KB transparente) copiado a src/assets/. Sin fondo cream incrustado, sin patrón hex embebido — el hex se dibuja como SVG animado detrás. Recipe de regeneración documentado en el README del handoff.
Respeta `prefers-reduced-motion: reduce` (todas las animations a 0.001ms). Cross-fade out de 320ms al dismiss. Daily gate via localStorage (1 vez/día) mantenido del v1.14.3+ — funciona en iOS PWA donde sessionStorage fallaba.
Cazada la causa real del error "Minified React error #300" tras añadir comida: un módulo importado devolvía undefined porque era un chunk del bundle viejo (Lovable había deployado y los chunks de la pestaña abierta ya no existían). Ahora se detecta y se hace UN hard reload automático con guard para evitar loops.
Detección AMPLIADA de "stale chunk after deploy". Antes solo capturábamos texto "chunk" / "Loading chunk" / "Failed to fetch dynamically imported". Ahora también minified React errors #300/#130/#310/#418/#423/#425, "Element type is invalid", "Cannot read properties of undefined" cuando el stack toca React internals. Todos casi siempre = chunk viejo.
Listener GLOBAL `window.error` + `unhandledrejection` que captura errores antes incluso de que React Error Boundary los vea. Si el patrón coincide con chunk stale, hard reload automático en 50ms (con guard sessionStorage para no entrar en loop).
El flag de "ya recargué" se limpia tras 3s sin errores — si MAÑANA Lovable deploya otra vez con chunks nuevos, el auto-reload puede volver a dispararse en esta misma sesión sin reiniciar.
Componente `SafeBoundary` (error boundary local). Envuelve subárboles para que un crash de un widget concreto no tumbe la página entera. Aplicado en CalorieRingHero: si el anillo crashea, solo se ve una tarjeta "Este widget no ha cargado bien · Reintentar" en su parcela y el resto del dashboard sigue funcionando.
El WelcomeSplash de Lutra ahora es una continuación visual del splash del SO (mismo naranja, misma posición del mascot) con la nutria animada con respiración, balanceo y saltitos. Se cargaron los reloads aleatorios (causa: auto-recover del ErrorComponent demasiado agresivo). Y el preloader ya no se queda atascado por race de auth.
WelcomeSplash rediseñado para fluir desde el splash del SO: mismo fondo naranja-marca (gradient radial con #ed8a6f), mascot en la misma posición centrada que el OS pinta, sin card/border alrededor — la nutria simplemente "cobra vida" donde estaba estática. Animación idle múltiple: respiración (scale 1↔1.02), balanceo (rotate ±4°), saltito doble (y bounce a -10/-4). Burbujas blancas tipo agua-de-río sobre el naranja. Texto blanco con drop-shadow. Botón blanco con texto naranja para contraste. Se siente como UNA experiencia, no como un splash detrás de otro splash.
Reloads aleatorios eliminados. Causa: el auto-recover del ErrorComponent en v1.14.4 disparaba `router.invalidate() + reset()` a los 800ms ante CUALQUIER error de route, lo que re-fetcheaba todo y se sentía como una recarga espontánea. Quitado el auto-recover; ahora el usuario decide reintentar con el botón. Los errores transitorios reales siguen cubiertos por el `retry: 3` de React Query en la dashboardQuery.
Detección automática de "chunk load error" (assets viejos tras deploy de Lovable). Cuando React lazy intenta cargar un chunk que ya no existe porque el bundle cambió, hacemos UN hard reload (con guard de sessionStorage para evitar loops). Antes el usuario se quedaba con la app rota hasta refresh manual.
Preloader atascado a la espera de auth ya no pasa. Causa: `getJwt` filtraba localStorage solo por substring "auth-token", pero supabase-js en ciertos contextos PWA usa otras variantes del key (`sb-{ref}-auth-token-code-verifier`, etc) o reestructura la storage durante refresh. Ahora escanea TODOS los keys que empiecen con `sb-`, `auth-token` o `supabase` Y busca `access_token` recursivamente (top-level, currentSession, session, array wrapper).
El botón "Entrar" del WelcomeSplash no respondía al primer tap en iOS PWA y obligaba a esperar los 8s del auto-close. Causa: motion.button + whileTap/whileHover + entry delay 0.7s + onClick. Sustituido por button plano + onPointerDown.
Botón "Entrar" del WelcomeSplash ahora responde al primer toque en iOS Safari/PWA standalone. Antes era `motion.button` con animación de entrada delay 0.7s + `whileTap` + `whileHover` + `onClick` — esa combinación con framer-motion sobre touch screens no disparaba el click event hasta el segundo intento o nunca. Ahora es un `<button>` plano con `onPointerDown` (más fiable que click en touch) + `touch-action: manipulation` para eliminar el delay de 300ms de detección de double-tap.
Overlay del splash también con `onPointerDown` en vez de `onClick`, con check `e.target === e.currentTarget` para que solo dismissmee si tap fuera de los botones hijos (que tienen su propio handler). Antes en iOS el tap fuera de los hijos a veces no registraba.
Botón "Entrar" un poco más grande (py-3.5 vs py-3, px-7 vs px-6, text-base vs text-sm) — tap target más cómodo en mobile.
Cazada la causa raíz del ErrorComponent ("This page didn't load") que salía de vez en cuando: la queryFn del dashboard lanzaba "No session" cuando el JWT en localStorage no estaba listo aún (race con el bootstrap de auth de supabase). Ahora reintenta 3 veces antes de rendirse, React Query reintenta 3 más, y el ErrorComponent se auto-recupera solo a los 800ms — el usuario ni se entera.
Dashboard queryFn ya no lanza "No session". Antes, si `getCurrentUserId()` devolvía null al primer intento (race entre el bootstrap de supabase escribiendo localStorage y nuestro queryFn corriendo), tirábamos error y el route ErrorComponent se disparaba. Ahora reintenta 3 veces con backoff (250ms/500ms/750ms) y si aún falla devuelve datos vacíos en vez de tirar — el dashboard ve `profile=null` y muestra el LutraLoader hasta que React Query reintente.
React Query con `retry: 3` + backoff exponencial (1s, 2s, 4s) en la dashboardQuery. Una sola excepción intermitente (timeout SDK, network blip al volver de background) ya no manda al usuario al ErrorComponent.
ErrorComponent del route boundary AUTO-RECOVER: tras 800ms hace `router.invalidate()` + `reset()` automáticamente. Mientras tanto muestra solo un spinner (no la cara de error). Si recover, el usuario ni se entera del error. Si tras el auto-retry sigue fallando, ahí sí se muestra la UI de error.
ErrorComponent ahora muestra DETALLES TÉCNICOS expandibles (mensaje del error + primeras 8 líneas del stack). Si vuelve a salir, el usuario puede pulsar "Detalles técnicos" y mandar screenshot — sabremos exactamente qué tiró. UI ahora también en español.
Widget de calorías actualiza al INSTANTE al guardar comida vía optimistic-update (setQueryData) — ya no depende del refetch. Onboarding completado YA NO te lleva a página de error (invalidate antes de cerrar wizard). Splash de Lutra vuelve a 1 vez al día con localStorage por fecha. Splash del SO ahora es naranja-marca en lugar de cream genérico.
Widget de calorías actualiza al INSTANTE tras guardar comida. Causa real del bug que no me dejaba en paz: aunque hubiera matado todos los `getSession()`, el refetch entero seguía pasando por la queryFn con todas sus lecturas — si UNA todavía colgaba, no actualizaba. Ahora hago un raw fetch directo + `queryClient.setQueryData` que patchea el cache YA. React Query notifica a los observers y el anillo recalcula. El refetch sigue corriendo en paralelo para sincronizar weekly/quality/etc, pero el widget principal no espera.
Onboarding completado YA NO te lleva a página de error. Causa: el cache de React Query tenía el profile OLD (con `onboarding_completado=false`, campos null) — al cerrar el wizard el dashboard intentaba renderizar con datos basura y el ErrorComponent del route se disparaba. Reload arreglaba porque fetcheaba fresco. Ahora `refetchQueries(['dashboard'])` se completa ANTES de cerrar el wizard en ambos flujos (SetupWizard modal + ruta /onboarding).
WelcomeSplash vuelve a aparecer 1 vez al día (no en cada abrir-cerrar). Gate con localStorage por fecha YYYY-MM-DD — funciona correctamente en iOS PWA standalone donde sessionStorage fallaba. Si se mostró hoy, no vuelve hasta mañana. Si no se mostró hoy, sale al primer mount.
OS splash (PWA manifest) ahora tiene background_color naranja-marca (#ed8a6f) en lugar de cream genérico (#f7f3e8). El mascot Lutra sale sobre el color principal de la app — más reconocible, más intencional, menos "app de plantilla". También añadidos categories (health/lifestyle/fitness/food) para mejor categorización en stores PWA + iconos any+maskable separados (PWA spec moderna).
Manifest `name` simplificado de "lutrAI.app - Nutrición por voz" a "lutrAI.app" (más limpio bajo el icono en iOS) y description acortado para que no se corte.
Solución drástica al splash. Quitado el gate de sessionStorage que en iOS PWA standalone persistía a través de force-quits y dejaba sin aparecer la bienvenida personalizada. Ahora cada vez que abres la app, Lutra te saluda por tu nombre con mensaje contextual durante 8 segundos (o hasta que pulses Entrar).
WelcomeSplash SIEMPRE aparece al abrir la app (cada mount de `_app.tsx`). Antes lo gateábamos con sessionStorage para evitar repeticiones, pero en iOS PWA standalone sessionStorage persiste a través de force-quits — el splash dejaba de aparecer y el usuario veía solo el splash del SO (manifest icon) sin la bienvenida personalizada de Lutra.
WelcomeSplash se mantiene montado MIENTRAS la app está activa, no solo durante el bootstrap de auth. Antes el splash vivía en el early-return `!ready` y se desmontaba a los ~500ms cuando auth resolvía — el timer 8s jamás corría. Ahora vive en el layout post-ready y el auto-close sí dispara.
Primer nombre del usuario sacado de `user_metadata` (full_name / name / email) y cacheado en localStorage como `lutrai_first_name`. La PRÓXIMA vez que abres la app, el splash arranca con "Hola, María" desde el primer paint, sin esperar a la dashboardQuery.
El splash del SO (PWA manifest, mascot+background) cubre los primeros ~500ms-1s de bootstrap (eso no es controlable desde React). Cuando React carga, el WelcomeSplash de Lutra aparece encima con saludo personalizado durante 8s. Botón "Entrar" sigue disponible para saltar antes.
El splash de bienvenida aparece YA en el primer pixel, sin flash del loader detrás, y arranca con saludo personalizado desde la primera vez que abres la app (snapshot persistido en localStorage). El widget de calorías + actividad + suplementos + peso ya no dependen del SDK supabase-js que se colgaba — ahora se actualizan SÍ o SÍ al añadir algo. Bonus: slider de déficit calórico debajo del anillo.
WelcomeSplash sin race condition: ahora lee sessionStorage SÍNCRONO en el useState initializer. Antes había un frame donde show=false y se colaba el LutraLoader detrás → ese era el "welcome screen que sigue apareciendo". Ya no hay frame intermedio.
Snapshot de contexto persistido en localStorage. Cada vez que el dashboard carga, guardamos racha + comidas hoy + peso + modo. La próxima vez que abres la app, el splash arranca con saludo personalizado SIN esperar a la dashboardQuery. Se descarta si tiene >24h (mejor genérico que mentiroso).
Widget de calorías AHORA SÍ se actualiza al añadir comida. Causa raíz REAL: `supabase.auth.getSession()` se colgaba al inicio del queryFn, ANTES de las protecciones `withTimeout`. Migrado a `getCurrentUserId()` (JWT desde localStorage). Igual fix aplicado a ActivityTracker, DailySupplementsCard y WeightTracker — los 4 widgets que dependían del SDK auth.
DailySupplementsCard usa raw fetch (insertRow + deleteRowsIn) en vez de `supabase.from()`. Marcar/desmarcar un suplemento ya no se queda colgado en producción. `deleteRowsIn` ahora soporta `extraFilters` para combinar IN con eq.
DeficitSlider añadido al CalorieRingHero. Slider horizontal con zonas de color (verde = en objetivo, ámbar = por encima, rojo = superávit). Muestra hoy vs ideal en kcal y cabeza pulsante con la posición. Tooltip explica el cálculo del déficit ideal para tu objetivo.
Saludo del WelcomeSplash ahora REACTIVO: si el contexto cambia entre renders (p.ej. snapshot llega tras el primer paint), el mensaje se recalcula. Antes el useMemo solo dependía de nombre y el contexto fresco se ignoraba.
El análisis de fotos pasa de Sonnet 4.5 a Opus 4.5 — distingue mejor entre proteínas parecidas, cuenta unidades visibles con más precisión, y caza salsas/bebidas/aliños que Sonnet a veces se saltaba. Texto y voz se mantienen en Sonnet (rápido y suficiente). Prompt de visión reescrito con anti-errores explícitos y proceso mental de 4 pasos.
Análisis de fotos con Claude Opus 4.5 (en lugar de Sonnet 4.5). Opus es el modelo Anthropic con mejor visión: identifica con más fiabilidad alimentos visualmente parecidos, cuenta unidades exactas y detecta salsas/bebidas/condimentos que Sonnet a veces ignoraba. Texto y voz se mantienen en Sonnet (más barato y rápido, suficiente para parsing de transcripciones).
Prompt de visión reescrito con "proceso mental" de 4 pasos (Inventario → Identificación → Conteo → Verificación anti-error) y un bloque PROHIBIDO explícito contra los errores tontos más frecuentes: inventar ingredientes que no se ven, fusionar items perezosamente ("plato combinado"), duplicar items, ignorar bebidas con kcal, subestimar aliños. Confianza ahora es estricta: <0.4 = NO devolver el item.
Anclas nutricionales del prompt ampliadas de 18 a 38: añadidos salmón, ternera, jamón, chorizo, patata cocida/frita, pan baguette, aceitunas, ensaladilla, gazpacho, varios formatos de bebida (caña, pinta, copa de vino, refresco azucarado vs zero, café solo vs con leche). Mejor calibración para platos típicos españoles.
Fallback automático Opus → Sonnet si Opus falla por timeout o error de API. El usuario siempre obtiene análisis aunque pierda precisión visual. Logging del modelo usado en cada llamada para diagnosticar a futuro (`[claude-opus-4-5]` vs `[claude-sonnet-4-5]`).
VISION_BUDGET_MS ajustado a 45s para Opus (vs 40s anterior, suficiente para fotos complejas) + 15s reservados para el fallback Sonnet. Total worst-case 60s ≤ límite cliente 70s — sin riesgo de timeout encadenado.
Rediseño profundo del anillo de calorías: ahora es vertical y enorme, ocupa todo el ancho, el número central tiene 64-78px con gradiente, fondo con burbujas animadas tipo agua-de-río, indicador pulsante en el extremo del progreso. Y el widget SÍ actualiza al añadir comida — el SDK de Supabase ya no puede congelar el refetch completo.
CalorieRingHero rediseñado de cero como vertical hero: anillo gigante (h-64→h-72), número central HUGE (64-78px con gradiente foreground→primary), status pill con emoji y glow arriba, ring r=58 con stroke 13px (vs r=52 stroke 11), dot indicador pulsante en el extremo del progreso, fondo con halo animado + 3 burbujas decorativas tipo "superficie de agua" para encajar con la nutria Lutra. Macros en barras horizontales con icon-tile e iconos grandes. Sin desentonar con look & feel: misma paleta, mismas referencias científicas, misma tipografía display.
Widget de calorías se actualiza SIEMPRE tras añadir comida. Causa raíz definitiva: el `Promise.all` de la dashboardQuery incluía 4 lecturas SDK supabase-js que pueden colgarse silenciosamente — UNA de ellas era suficiente para bloquear el refetch entero. Reemplazado por `withTimeout` helper individual (8-10s por query, fallback a vacío) — la home jamás se queda atascada porque un read SDK no responda.
Fiesta del anillo más vistosa: 16 sparkles radiales (vs 12), 4 colores rotando (primary/success/warning/info), pulso radial el 50% más grande, chip "+X kcal" sube 130px en vez de 100, badge "Guardado" más legible.
El anillo de calorías ahora es la METRICA estrella: más grande, con halo y borde más marcado. Cuando guardas una comida, hace fiesta — pulso, sparkles radiales, chip flotante "+X kcal" y scroll automático al anillo. Y se ha eliminado el flash del landing público al abrir la app si ya tienes sesión iniciada.
CalorieRingHero rediseñado como north-star: anillo +20% en mobile (40→48) y desktop (44→52), número central +25% (34→42px / 40→50px), border más marcado, halo de glow más visible. Sigue encajando con el look & feel pero ahora SE VE como la métrica principal del día.
Fiesta al añadir comida: cuando guardas un alimento, el anillo dispara un pulso radial brillante, 12 sparkles tipo confeti, un chip "+X kcal" flota desde el centro hacia arriba, y un badge sutil "Guardado" aparece en la esquina. Y el scroll se va automáticamente al anillo para que veas el FX.
Widget de calorías ya se actualiza al añadir comida. Causa raíz: la lectura de `meals` en la query del dashboard usaba el SDK supabase-js que se cuelga silenciosamente en producción. Migrada a raw fetch a PostgREST con `meal_items(*)` embedded. Además: refetch sin `type: "active"` (que se saltaba refetch si el componente no estaba siendo observado) + red de seguridad de 1.5s.
Eliminado el flash del landing público al entrar en lutrai.app con sesión iniciada. Antes se veía el landing 200-500ms antes de redirigir a /dashboard — el "splash que no es" que se reportaba. Ahora `index.tsx` chequea un hint en localStorage y, si existe, muestra directo el WelcomeSplash sin renderizar el landing.
Toast de "Enviar push de prueba" mucho más informativo: muestra `sent/total` cuando va bien, distingue "no hay suscripción" vs "endpoint rechazó", y captura excepciones del server-fn. Antes solo decía "No se pudo enviar. Revisa el permiso del navegador" para todo.
Lutra te saluda desde el primer pixel: el splash se monta YA en el layout de _app (encima del preloader de auth), se auto-cierra a los 8s para no atascar al usuario que no toca la pantalla, y el botón "Entrar" sigue ahí por si quieres saltar antes. Notificaciones push reparadas de verdad: la causa era RLS en app_config.
WelcomeSplash montado en `src/routes/_app.tsx` desde el bootstrap de auth. Antes salía DESPUÉS del LutraLoader inicial (1s de preloader pelado, luego saltaba el splash). Ahora la primera pantalla que ves al abrir la app es ya a Lutra saludando, con el LutraLoader cargando por debajo.
Splash auto-cierre a 8s. Si el usuario no toca la pantalla, el splash se cierra solo y entra al dashboard. El botón "Entrar" sigue visible y funcional por si quiere saltar antes — los 8s son un techo, no una espera obligatoria.
Notificaciones push reparadas. Causa raíz real: la tabla `app_config` tiene RLS que solo permite al rol `service_role`, así que el raw fetch desde el cliente (con anon key) recibía siempre array vacío o HTTP 406. Vuelto a usar la server-fn `getVapidPublicKey` que internamente usa `supabaseAdmin` y sí lee la fila. La SDK NO era el problema aquí — era la RLS.
Eliminadas las dos mounts de WelcomeSplash en `dashboard.tsx` (ahora redundantes). El splash es único, vive en el layout `_app.tsx` y comparte sessionStorage con todas las rutas.
El splash de bienvenida aparece como primera pantalla al abrir la app (sobre el preloader) y se queda fijo hasta que pulses "Entrar". También: mensaje claro cuando las notificaciones fallan por VAPID no configurada en el servidor.
WelcomeSplash se renderiza ANTES del LutraLoader: ahora es la primera cosa que ves al abrir la app. Antes aparecía después del preloader porque estaba dentro del early-return del dashboard.
Splash sin auto-cierre. Antes desaparecía a los 6.5s solo. Ahora se queda fijo hasta que pulses "Entrar" o toques la pantalla. Bien para leer el saludo personalizado con calma sin presión de tiempo.
Mensaje de error de notificaciones push mejorado: si la VAPID public key no está configurada en el servidor (HTTP 406 PGRST116), ahora dice claramente "VAPID no configurada en el servidor. Avisa al equipo (admin)." en lugar del JSON técnico.
selectRows para VAPID ya no usa `single: true` (que disparaba 406 cuando la fila no existía). Lectura más robusta manejando lista vacía en cliente.
Tres bugs reales: el splash ahora aparece cada vez que abres la app (no "una vez al día"). El Service Worker se registra al cargar (las notificaciones funcionan otra vez). El widget de actividad ya no se queda vacío hasta refrescar.
WelcomeSplash: cambio de localStorage (una vez al día) a sessionStorage (una vez por sesión). Cuando cierras la app y vuelves a entrar, ves a Lutra con el saludo recalculado por contexto. Si solo cambias de pestaña no te aparece de nuevo.
Service Worker `/sw.js` se registra al cargar la app desde `_app.tsx` (no solo cuando activas las notificaciones). Detectado in situ que `swRegistered: false` aunque el archivo existía en `/public/sw.js`. Causa de que las notificaciones no se activaran nunca.
Widget "Actividad de hoy": el SELECT de activity_logs usaba el SDK Supabase y a veces devolvía vacío silenciosamente. Migrado a raw fetch a PostgREST con timeout y orden cliente. Mismo bug del SDK que ya hemos arreglado en todas las escrituras.
ReminderSettings con toasts más informativos al activar notificaciones. Antes solo decía "Permiso denegado" — ahora distingue: permiso denegado, navegador no soportado, preview de Lovable, error técnico con código.
Detectado in situ: el FAB de añadir se quedaba con scale(0.6) opacity:0 permanente porque DropdownMenuTrigger asChild clona el motion.button e interfería con Framer Motion. Quitada la animación initial/animate problemática.
Causa raíz del FAB pequeño y desalineado: motion.button con `initial={{ opacity: 0, scale: 0.6 }}` se quedaba CONGELADO en ese estado inicial al ser clonado por `<DropdownMenuTrigger asChild>`. El boundingRect mostraba el FAB con 41x41px en lugar de los 68x68px declarados. Eliminada la animación inicial; el botón aparece directo a tamaño real.
Los dos FAB ya están a la misma altura (bug real: el space-y-6 aplicaba margin a los position:fixed). Splash dura más tiempo (6.5s). Disclaimer enlaza Términos/Privacidad. Registro requiere confirmar 16+ años. Aviso legal en pricing.
FAB de añadir comida + FAB de Lutra ahora alineados verticalmente al pixel. Causa raíz: el wrapper `space-y-6 animate-fade-in` del dashboard aplicaba margin-bottom/top a los position:fixed (inspeccionado via JS: 24px de diferencia exacta). Sacados ambos FABs + todos los modales fuera del wrapper a un Fragment hermano.
Splash de bienvenida sube de 5s → 6.5s para leer el saludo personalizado con calma.
Disclaimer médico ahora enlaza al final con los Términos de uso, Política de Privacidad y Aviso Legal (páginas ya existentes en /legal/...). Abren en pestaña nueva para no perder el modal.
Registro: añadido checkbox obligatorio "Confirmo que tengo al menos 16 años" (RGPD-K, edad mínima en España para servicios de la sociedad de la información sin supervisión parental). El botón crear cuenta + Google OAuth permanecen disabled hasta marcar ambos checkboxes (legal + edad).
Página /pricing: añadida tarjeta legal de recuerdo bajo los planes. "lutrAI.app es herramienta educativa, no sustituye consejo médico profesional. Si tienes patología, consulta antes de pagar." Enlaza términos y política de reembolsos. Protección legal en el flow de cobro.
Antes de usar la app por primera vez, todos los usuarios deben aceptar explícitamente un descargo de responsabilidad: lutrAI.app es una herramienta educativa, NO un dispositivo médico ni sustituye al consejo profesional sanitario.
Modal de descargo de responsabilidad bloqueante al primer acceso. Cubre: estimaciones aproximadas (±10-30%), contraindicaciones para patologías (diabetes, IRC, embarazo, TCA, alergias...), Lutra no diagnostica ni prescribe, responsabilidad del usuario, referencia al 112 en emergencias.
Aceptación con checkbox explícito + botón "Acepto y entro". Sin marcar el checkbox, el botón está disabled. Se guarda timestamp y user-agent en localStorage como constancia (con versionado: si actualizamos el texto legal, todos vuelven a aceptarlo).
Link permanente en Perfil para revisar el aviso legal. Muestra si está aceptado o no. Pulsando se abre el modal en modo lectura (cerrable).
Documentación clara de para qué sirve la app y para qué NO sirve. Reducción del riesgo legal en caso de mal uso o reclamación.
Última pasada del bypass del SDK Supabase: cualquier operación que aún se colgase en producción ya va por fetch raw. El welcome splash aparece una vez al día (no solo post-login) y dura 5s. Los dos FAB están perfectamente alineados.
Migrados a fetch raw los últimos 7 sitios que usaban el SDK Supabase para escrituras y se colgaban en producción: MealEditDialog (update meal + delete/insert/update meal_items), AddActivityDialog, AddWeightDialog (upsert), dashboard.eliminarComida, VoiceMealDialog.loadFavorites + eliminarFavorito, perfil.guardar, onboarding.guardar, SetupWizard hidrate + finalizar.
Helper `deleteRowsIn` añadido en invoke-edge.ts para DELETE con filtro IN (lista de ids). PostgREST acepta `id=in.(1,2,3)`.
WelcomeSplash: ahora aparece UNA vez al día (no solo post-login). Si abres la app por la mañana ves a Lutra; si vuelves a entrar por la tarde no te aburre con otra. Duración subida de 3.2s a 5s para leer el saludo personalizado tranquilamente.
FAB de Lutra y FAB de añadir comida perfectamente alineados verticalmente. El sparkles que sobresalía del FAB de Lutra rompía la simetría; eliminado. La nutria ya transmite "IA" sin necesidad del badge.
El chat con Lutra ya no se cuelga ni da toasts rojos: migrado a edge function. Splash de bienvenida rediseñado con saludo personalizado por contexto del día (hora, racha, peso, comidas). Personalidad de Lutra centralizada para coherencia en toda la app.
Chat con Lutra reescrito: la server-fn `preguntarCoach` se colgaba en producción (mismo bug del SDK). Migrado a edge function de Supabase con retry exponencial (3 intentos en errores transitorios 408/429/502/503/504), timeout 35s, modelo Claude Sonnet 4.5.
Toast rojo "Error contactando al coach" sustituido por mensaje con código HTTP útil. Si la respuesta falla, tu pregunta se queda en el input para reintentar sin reescribir.
Personalidad de Lutra centralizada en `lib/lutra-personality.ts`: tono cercano y motivador, anti-clichés, vocabulario propio, vocabulario afectivo ocasional. Una sola fuente de verdad usada por splash + chat + edge function.
Welcome Splash rediseñado: ya no se corta, layout responsive con safe-areas (notch, dynamic island). Bubbles flotando como agua de río, halo radial pulsante, Lutra animada con balanceo, emoji contextual aparece flotando.
Saludo del splash PERSONALIZADO por contexto: hora del día, racha de registros, peso bajado/subido, comidas hoy, día de la semana. Si llevas 7+ días: "🔥 Esto ya es hábito, no esfuerzo". Si bajaste peso: "📉 Esto es ritmo sano, no carrera". Si es lunes mañana: "☀️ Nueva semana, mismas ganas". 12+ variaciones.
Botón "Entrar →" visible en el splash en lugar de "tap-anywhere" oculto. Reduce confusión sobre cómo continuar. El tap-anywhere sigue funcionando como atajo.
Cada modo (Keto, Vegan, Mediterránea, Sport, Diabetes-Friendly, Heart-Friendly, Sugar-Friendly) ahora tiene su tarjeta científica: rationale, macros explicados, nutrientes a vigilar, suplementación basada en evidencia, hábitos diarios, precauciones y links a fuentes oficiales (PREDIMED, AHA, ADA, ISSN, OMS, NIH).
Auditoría completa de los 7 modos. Reescritos con datos respaldados: Keto cita Volek & Phinney 2011 y Virta Health, Mediterránea cita el estudio PREDIMED (NEJM 2018), Sport cita ISSN Position 2017 y ACSM, Diabetes-Friendly cita ADA Standards of Care 2024, Heart-Friendly cita AHA 2021 y patrón DASH del NIH, Vegan cita Academy of Nutrition 2016 y The Vegan Society, Sugar-Friendly cita OMS 2015 y Hall NIH 2019.
Tarjeta "ModeScienceCard" colapsable en Dashboard (debajo del modo activo) y en Perfil (al seleccionar un modo). Muestra: rationale, macros con explicación nutricional individual, lista de nutrientes clave con severidad (crítico / importante / vigilar), suplementación recomendada con dosis y nivel (esencial / recomendado / opcional), hábitos diarios, contraindicaciones y links a estudios originales.
Vegan: B12 marcado como suplementación ESENCIAL (no opcional). Sin ella deficit garantizado a medio plazo. También se vigilan omega-3 EPA/DHA de algas, vitamina D, hierro, calcio, zinc y yodo.
Keto: electrolitos (sodio, potasio, magnesio) marcados como críticos. La "keto flu" de los primeros días se debe casi siempre a déficit de electrolitos.
Sport: creatina monohidrato 3-5g/día marcada como suplementación esencial (suplemento más estudiado del mundo). Carbs explicados por tipo de entreno (4-7 g/kg fuerza, 7-10 g/kg resistencia).
Heart-Friendly: añadido objetivo de grasa saturada <6% kcal (AHA), sodio <1500 mg, omega-3 EPA+DHA 250-500 mg/día prevención primaria o 1000 mg/día secundaria.
Mediterránea: añadidas guías cuantitativas concretas — AOVE 50g/día (PREDIMED), frutos secos 30g/día, pescado azul 2-3 veces/semana, legumbres ≥3/semana, carne roja <2/semana.
Tooltips con respaldo en cada métrica clave. Links directos a NEJM, Lancet, ADA, AHA, ISSN, NIH, OMS, EAT-Lancet, Fundación Dieta Mediterránea.
El objetivo de sodio era 2300mg, que es el LÍMITE MÁXIMO recomendado por la OMS — no el mínimo. Por eso el coach decía "te falta sodio" incluso si habías tomado un sobre de electrolitos. Recalibrado a 1500mg (mínimo adecuado) y excluido del aviso "te falta".
Objetivo de sodio bajado de 2300mg a 1500mg/día (Adequate Intake según OMS/IOM). La cifra 2300mg es el TOPE recomendado, no la cantidad ideal. Casi nadie llega a 2300mg en una dieta moderada — usar esa cifra como objetivo daba lecturas "40% de tu sodio" que sugerían incorrectamente añadir sal.
Coach: el aviso "te falta sodio · añade una pizca de sal" está deshabilitado. En una dieta normal el sodio rara vez está bajo (la sal está en todo lo procesado) y empujar a añadir más es contraproducente. Sí se siguen mostrando déficits de hierro, calcio, magnesio, potasio, vitamina C/D — donde sí hay déficits reales frecuentes.
Los chips de cada suplemento ahora muestran TODOS los micros que aporta (no solo kcal/prot). Si un suplemento que obviamente debería tener micros (electrolitos, multivit, vitamina D…) salió sin ellos, lo marcamos en amarillo y puedes re-investigarlo con un click.
Cada suplemento investigado muestra ahora un chip verde por cada micro que aporta ("+500mg Sodio", "+60mg Magnesio"…). Antes solo se mostraba kcal/prot — los micros estaban guardados pero invisibles.
Botón Re-investigar (icono refresh) en cada suplemento ya añadido. Fuerza una nueva consulta a Lutra y sobrescribe los datos guardados. Útil cuando la primera investigación quedó incompleta.
Detección automática de suplementos con datos incompletos: si el nombre matchea (electrolitos, multivit, vitamina, magnesio, hierro, etc.) y aporte.micros está vacío, el item se marca en amarillo con badge "Sin micros" y el botón refresh resalta para invitarte a re-fetch.
Prompt de Sonnet reforzado: "REGLA NO NEGOCIABLE: vitamina/mineral/electrolito → SIEMPRE incluye al menos un valor en micros". Específico para electrolitos: sodio, potasio y magnesio obligatorios. Multivitamínicos: 5+ micros.
Bug doble: el contador de éxitos siempre daba 0 (closure inicial de React) y el UPDATE final lo hacía el SDK Supabase que estaba colgado. Migración corregida — ahora investiga, guarda y reporta correctamente.
Contadores `okCount`/`failedCount` locales dentro del loop en lugar de `items.filter(...)` tras el closure. Antes siempre decía "0 actualizados ✨" aunque los 13 suplementos se hubieran investigado bien.
El UPDATE a `profiles.suplementos` pasaba por `supabase.from().update().eq()` que se quedaba colgado en producción (mismo bug del SDK). Migrado a PATCH raw a PostgREST con el nuevo helper `updateRow`.
Toast de resumen ahora distingue tres casos: todo OK ("X suplementos actualizados ✨"), todo fallido ("No se pudo investigar...") y mixto ("X actualizados · Y fallaron"). Si el UPDATE en BD falla, muestra el código HTTP.
Tras añadir una comida ya no hace falta refrescar la app: anillo de kcal, macros, micros, comidas, calidad NOVA y demás widgets se actualizan al instante.
Tras guardar una comida ahora hacemos refetch FORZADO del dashboard en lugar de solo invalidate (lazy). React Query con keepPreviousData mostraba datos viejos hasta que algo dispara la red — ahora pegamos a la red sí o sí.
El canal Realtime de Supabase usaba supabase.auth.getUser() que se cuelga en producción (mismo bug del SDK), por lo que el listener NUNCA se suscribía y los eventos de meals/meal_items/etc. nunca llegaban. Migrado a getCurrentUserId() que lee el JWT directo de localStorage.
Tool schema con sintaxis OpenAPI (nullable:true) hacía que Anthropic rechazara la llamada con 400. Reescrito a JSON Schema clásico. Gemini desactivado a petición, volvemos a Sonnet 4.5 puro.
Tool schema reescrito en JSON Schema clásico (sin `nullable: true`, que es notación OpenAPI 3.0 que Anthropic rechaza con HTTP 400 BadRequest). Los campos opcionales ahora simplemente no aparecen en `required`; el sanitizador rellena defaults.
Migración automática de suplementos: ahora SÍ investiga cada item en lugar de marcar todos como fallidos. Si tenías suplementos en estado "falló", entra a Perfil → Suplementos y los detectará otra vez.
Gemini desactivado tanto en suplementos como en comida. Volvemos a Anthropic Claude Sonnet 4.5 puro (con el retry de items vacíos de v1.6.8). Menos dependencias externas, una sola superficie de problemas.
Si ya tenías suplementos guardados antes de v1.10.0, la próxima vez que entres en la app Lutra los investigará automáticamente y los enriquecerá con marca, posología y micros — con una barra de progreso bonita que puedes saltar.
Diálogo de migración automática: al entrar al dashboard, detecta suplementos no investigados y los procesa uno a uno con barra de progreso en %, lista de estado por item (en cola / investigando / OK / falló) y persiste el snapshot completo en BD al terminar.
Botón "Saltar de momento" disponible mientras corre la migración. Si lo pulsas, no te pregunta de nuevo en las próximas 12 horas (localStorage).
Tras terminar, el botón cambia a "Ver mi día →" con vibración háptica de celebración (patrón de 7 pulsos) y toast resumen "X suplementos actualizados ✨".
Al añadir un suplemento, Lutra investiga la marca, posología y micros que aporta. Ese aporte se refleja en tus totales del día al marcarlo como tomado. Las notificaciones push vuelven a funcionar.
Suplementos con IA: al añadir uno, Lutra consulta su composición típica (marca + producto), posología recomendada (cantidad por dosis, dosis/día, momento), ingredientes clave y micros que aporta. Tarda 5-15s con un loader vistoso que cuenta qué está haciendo.
Cuando marcas un suplemento investigado como tomado, su aporte real (kcal, prot, micros) se suma a tus totales del día en MacrosDonut y MicrosRadial. Los suplementos viejos (sin investigar) siguen usando la heurística como fallback.
Notificaciones push reparadas: las server-functions de TanStack Start estaban colgándose en producción (mismo bug del SDK). Migrado getVapidPublicKey, savePushSubscription, deletePushSubscription y getReminderSettings a raw fetch a PostgREST.
Vibración háptica en Samsung Internet y Chrome Android: ahora se dispara DENTRO del user gesture (antes del primer await), que es donde estos navegadores la aceptan. Antes se llamaba tras el insert y la rechazaban silenciosamente.
Helper haptic() centralizado con patrones por uso: tap (12ms), success (14-28-14), error (60-40-60), celebrate (patrón de 7 pulsos). Reemplaza las llamadas dispersas a navigator.vibrate.
MicroFX juicy al marcar suplementos, registrar peso o añadir actividad: destello en toda la fila, anillo expansivo, partículas, confetti, bounce del botón y vibración háptica en móvil.
Componente CelebrationBurst one-shot: combina destello radial, anillo expansivo, ~12 partículas con motion blur y mini-confetti que sube. Reutilizable en cualquier acción que merezca premio.
Marcar suplemento: el item entero hace pulse de escala, un destello atraviesa la fila de izquierda a derecha, glow inset verde pulsa, el checkmark entra con spring (rotate -30→0 + scale) y el botón hace un bounce con rotación.
AddWeightDialog y AddActivityDialog: el mismo CelebrationBurst sobre el botón "¡Listo!" / "¡Añadida!" durante 850ms antes de cerrar el diálogo.
Vibración háptica corta (14-28-14ms) en móviles que la soporten: refuerza el "click físico" del marcado. Ignorado silenciosamente en navegadores sin Vibration API.
Los consejos del coach son ahora botones que abren el chat con Lutra automáticamente. Reorganización general por bloques, microFX al guardar peso/actividad/suplementos y un loader que ya no se queda colgado.
Los consejos del coach (CoachTips) son ahora 100% interactivos. Cada tip tiene su icono visible en una burbuja a color, y al tocarlo se abre el chat con Lutra con una pregunta ya formulada y contextualizada a tu día.
Reorganización de la home: Lutra y consejos suben justo después del botón "Registrar comida". Bloques agrupados por intención: macros+micros+calidad / actividad+suplementos / peso+gráficos semanales / gamificación al final.
MicroFX al guardar: sparkles + checkmark animado al añadir peso, actividad o marcar un suplemento como tomado. El botón se queda visible 700ms con "¡Listo!" antes de cerrar el diálogo.
Loader entre pantallas: rediseñado con tres fases de paciencia (normal → tarda un poco → recargar). Animación de Lutra mejorada y nunca más se queda colgado al 95% para siempre.
WelcomeSplash más ágil (2.4s en lugar de 3.2s) y con hint sutil "toca para entrar". Mismo encanto, menos espera.
Las comidas que ya tenías guardadas mostraban la clasificación NOVA antigua. Ahora la app recalcula en tiempo real con las reglas más nuevas, así no tienes que volver a registrar nada.
El badge de calidad (🟢/🟡/🔴) en "Real Food Score" ahora se recalcula con el clasificador actual cada vez que abres la app, en lugar de leer el valor guardado en BD. Si tú lo editaste manualmente, se respeta tu elección.
"Olivas" reconocido como sinónimo de aceitunas → procesado (NOVA 3). "Aceite de oliva" sigue siendo real food gracias a un lookbehind negativo en el regex.
Polvos de proteína (whey, isolate, gainer, batido de proteína…) clasificados como ultraprocesado (NOVA 4): aunque sean útiles para deporte, llevan aditivos, edulcorantes y aromas industriales.
Cada recomendación numérica de la app ahora cita una fuente oficial (OMS, FAO, EFSA, IOM, ISSN). Nueva página /ciencia con todas las referencias y enlaces a los papers originales.
Badge "Fuente" en widgets clave (Real Food Score, Tu Presupuesto de Hoy, Macros, Micros vs CDR). Toca → popover con autores, año, publicación y enlace al paper o documento oficial.
Página /ciencia con la lista completa de fuentes: protocolo NOVA (Monteiro 2019), TMB Mifflin-St Jeor (1990), PAL FAO/OMS/UNU (2004), IMC OMS, AMDR IOM (2005), proteína ISSN (2017), micronutrientes EFSA, hidratación EFSA (2010).
Tooltips de TMB, TDEE, IMC y macros actualizados para mencionar la fuente concreta y la metodología (ej. "Calculado con Mifflin-St Jeor, la más precisa según la American Dietetic Association").
Aviso clínico explícito: lutrAI es apoyo basado en estándares poblacionales, no sustituye criterio médico personalizado.
Reescrito el motor de clasificación de alimentos siguiendo el protocolo NOVA oficial (OMS/Monteiro 2019). Café solo, té, vinagre, limón, sal, miel y otros vuelven a estar en verde. Listas mucho más exhaustivas.
Café (solo, con leche, descafeinado), té (verde/negro/blanco), infusiones (manzanilla, rooibos…), vinagre, limón, lima, sal, miel y aceite de oliva ya NO aparecen marcados como procesados en amarillo. Son NOVA 1-2, real food de verdad.
Listas exhaustivas siguiendo el protocolo NOVA oficial: NOVA 1 (sin procesar) + NOVA 2 (ingredientes culinarios) = real food 🟢, NOVA 3 (procesados tradicionales como queso, jamón ibérico, pan integral, conservas) = 🟡, NOVA 4 (industrial: refrescos, bollería, embutidos comerciales) = 🔴.
Cobertura ampliada de frutas, verduras, pescados, frutos secos, hierbas, especias, marcas industriales. Detección por regex con lookahead negativo para distinguir, p. ej., "yogur natural" (procesado tradicional) vs "yogur de sabores" (ultra).
Suite de tests de regresión integrada en el código: 42 casos representativos pasando al 100%, incluyendo los falsos positivos que reportaste.
Borrado el heurístico de respaldo que camuflaba los fallos de IA con datos falsos. Y prompt de foto rediseñado para que separe cada alimento en lugar de fusionarlos.
Eliminado el fallback heurístico tanto en texto como en foto. Si la IA falla, ahora ves toast claro con el error real y un "Reintenta". Cero datos camuflados disfrazados de análisis válido.
Prompt de visión rediseñado: ANTES decía "trátalo como UN ITEM con macros agregados" para platos preparados → de ahí venía el batiburrillo perezoso. AHORA: itemiza cada alimento visible por separado (lechuga + tomate + cebolla + aliño = 4 items), salvo excepciones de receta cerrada (pizza, bocadillo, sushi).
Anclas nutricionales específicas en el prompt de foto: cantidades típicas para verduras de ensalada, proteínas, cereales, aliños. Sonnet ya no tiene que adivinar de cero.
Toasts de error mucho más informativos: muestran el código de fallo real (AI_FAILED, IMAGE_TOO_LARGE, AI_NOT_CONFIGURED…) y siempre invitan a reintentar.
Decidimos quedarnos con Claude Sonnet 4.5 y endurecerlo: tool_choice específico, max_tokens 8000 y prompt rediseñado para multi-alimento. Adiós a los items vacíos.
tool_choice cambiado de "any" a tool específico ("registrar_comida"). Garantiza que Sonnet llame al tool exacto cada vez, sin opción a no llamar.
max_tokens subido a 8000 (era 4000): margen 4x sobre el peor caso realista. El JSON del tool ya no se trunca por mucho que haya alimentos.
Prompt SYSTEM_TEXT rediseñado: más corto, positivo, con anclas nutricionales específicas para multi-alimento (lechuga, tomate, cebolla, aliños, granola fit…). Sin contradicciones.
Log explícito de stop_reason + usage en CADA respuesta de Sonnet, no solo en fallo. Cualquier futuro problema se diagnostica de un vistazo.
Gemini 2.5 Pro reemplaza a Claude Sonnet como motor principal de análisis. Más barato, más rápido, structured output nativo (no se trunca como nos pasaba). Sonnet queda como red de seguridad.
Gemini 2.5 Pro es ahora el modelo principal para texto y foto. Usa responseSchema (JSON schema nativo) en vez del tool_use de Anthropic, que con prompts largos se quedaba a mitad de respuesta.
Cascada de fallback: si Gemini falla por cualquier motivo (red, schema, vacío), salta automáticamente a Sonnet 4.5. Si Sonnet también cae, heurístico local. Cero pantallas en blanco.
Coste estimado por análisis: ~$0.005 (Gemini) vs ~$0.02 (Sonnet). Más sostenible para fase beta con muchos usuarios.
Log explícito de qué motor respondió cada petición (source=gemini/sonnet) en los logs del servidor.
Rollback del prompt de visión (el refuerzo de v1.6.8 degradó la calidad del análisis de foto) y subida de tokens máximos para que los textos con muchos alimentos no se trunquen.
El refuerzo "items debe contener AL MENOS UN alimento" al inicio del prompt de visión hacía que Sonnet priorizara rellenar rápido por encima de razonar. Revertido: el prompt de foto vuelve a ser el de v1.6.7.
Subido max_tokens de 2000 a 4000. Con descripciones largas (10+ alimentos en una sola frase) Sonnet se quedaba sin tokens a mitad del JSON del tool_use y devolvía objeto truncado/items vacíos, lo que disparaba el fallback heurístico.
Log explícito de stop_reason + usage cuando Sonnet devuelve items vacíos. Sirve para diagnosticar futuros casos sin tener que adivinar.
El botón "Guardar" tras analizar comida volvía a colgarse (mismo bug del SDK). Bypass total con fetch raw. Y si Sonnet devuelve items vacíos, lo forzamos a reintentar antes de caer al heurístico.
Guardar comida (texto/voz/foto/favorito) ya no se queda inerte. Pasaba por supabase.from('meals').insert() + getUser() que el SDK perdía en producción. Reescrito con getCurrentUserId() + insertRow() en fetch raw, igual que las actividades.
Sonnet devolvía items vacíos con descripciones largas multi-alimento. Ahora detectamos ese caso y reintentamos automáticamente con un tool_result is_error: true que fuerza a Claude a rellenar los alimentos identificados (mejor confianza 0.5 que fallback heurístico tonto).
Prompt del modelo refuerza desde la primera línea: "items debe contener AL MENOS UN alimento" con ejemplo concreto. Mismo refuerzo en visión.
El prompt anterior era tan paranoico ("no inventes, no añadas") que Sonnet devolvía items vacíos por miedo a equivocarse. Reescrito en positivo.
Causa raíz: instrucciones negativas excesivas hacían que el modelo no devolviera nada. Ahora el prompt es positivo y permisivo: "incluye TODOS los alimentos que el usuario mencione".
Prompt más corto (~30 líneas vs 70), temperatura 0.5 (vs 0.3), max_tokens 2000 (vs 2500). Mejor balance precisión/permisividad.
Calibración nutricional condensada con datos por porción típica (pan, huevo, plátano, naranja, leche desnatada, granola, etc.).
Subimos del modelo "barato y rápido" (Haiku) a "buen razonamiento nutricional" (Sonnet 4.5). Las estimaciones de kcal, macros y porciones son notablemente mejores.
Modelo: Claude Sonnet 4.5 en lugar de Haiku 4.5. Mejor estimación de cantidades, mejor identificación de alimentos en foto, menos errores numéricos.
Prompt rediseñado con tabla de calibración nutricional (porciones estándar de 25+ alimentos comunes) y guía explícita para asignar confianza.
Temperatura 0.3 (más conservador) y max_tokens 2500 (margen para razonamiento). Tiempo de respuesta sube ligeramente (~8-15s típico).
El mismo bug del SDK Supabase que afectaba al análisis de foto también colgaba la inserción de actividades. Ahora va por fetch raw a PostgREST.
Añadir actividad (correr, gym, bici…) ya guarda en BD. Antes el botón se quedaba inerte sin error.
Borrar actividad también pasa por fetch raw — mismo motivo.
Helpers reutilizables insertRow() y deleteRow() en invoke-edge.ts para futuras pantallas con el mismo patrón.
supabase.auth.getUser() también se colgaba. Ahora extraemos el userId del JWT directamente. CERO llamadas al SDK durante el análisis de foto.
getCurrentUserId() parsea el sub del JWT desde localStorage. supabase.auth.getUser() ya no se llama en el flujo de foto.
getJwt() lee directo de localStorage. Si por lo que sea localStorage está roto, fallback al SDK con timeout duro de 2s.
El SDK estaba perdiendo también las subidas a Storage. Ahora todo el flujo (upload, firma de URL, edge function) va por fetch raw.
supabase.storage.upload() y createSignedUrl() perdían peticiones igual que functions.invoke(). Reproducido en directo: el log de fetch del navegador mostraba auth/refresh pero NUNCA la subida a Storage. Sustituido por fetch raw al endpoint REST.
Toasts específicos con código HTTP en cada paso del flujo de foto (1/3, 2/3, 3/3) para que cualquier fallo futuro sea identificable de un vistazo.
El SDK estaba perdiendo peticiones silenciosamente. Ahora llamamos a la edge function con fetch raw — visible en logs, identificable cualquier error.
supabase.functions.invoke() perdía peticiones en producción: los logs del servidor confirmaron que la llamada nunca llegaba. Ahora usamos fetch raw + JWT del localStorage.
Timeout específico por tipo: 30s para texto, 55s para foto, sin caer al fallback antes de tiempo.
Cualquier fallo HTTP ahora aparece en pantalla con su código ("IA (502): ...") en vez de timeout silencioso.
Tu sesión Supabase se refresca activamente antes de analizar la foto. Adiós a los "se queda al 99% y no pasa nada".
Refresh proactivo del token antes de cada análisis (foto y texto). Si tu sesión llevaba un rato sin uso, ya no se traga el primer intento.
Safety timeout subido de 40s a 60s para foto: Anthropic vision a veces tarda 20-30s y el botón se liberaba antes de tiempo.
Toasts de error más visibles cuando algo falla en cualquiera de los 3 pasos de la foto (auth, subida, IA).
Los análisis funcionan al instante aunque dejes la PWA en segundo plano un buen rato.
El token de sesión se refresca solo al volver del segundo plano, así el siguiente análisis no se queda colgado.
Timeouts protectores en la capa de auth: si algo se atasca, falla limpio en vez de quedarse pensando para siempre.
Botón de análisis (voz, texto y foto) con liberación de seguridad: nunca más verás «Analizando...» eterno.
Verás un aviso cada vez que publiquemos una versión nueva.
El popup de novedades ahora aparece para todos al detectar una nueva versión.
Número de versión visible en el menú con acceso directo al historial.
Animación de entrada más suave y enlace a /changelog desde el aviso.
Nuevo lenguaje visual en toda la app y un changelog público.
Tipografía display extrabold, eyebrows en versalitas y tarjetas rounded-3xl en todas las pantallas.
Iconos en burbuja (icon-circle), chips de estado consistentes y botones rounded-full con sombra.
Página /changelog pública con el historial completo de versiones.
Popup automático al detectar una nueva versión publicada.
Notificaciones inteligentes y vitrina dorada para tus trofeos.
Recordatorios push para desayuno, comida y cena con horarios personalizables.
Hall of Fame con podio, rarezas y progreso global.
Animaciones de confeti y sonido al desbloquear logros.
Adapta tu plan a tu estilo de alimentación.
Modos nutricionales (vegano, keto, mediterráneo…) que ajustan plan y avisos.
Editor de suplementos con totales contados en tu balance diario.
Onboarding más rápido con vista previa de IMC y TMB en vivo.
¿Echas algo de menos? Dínoslo y lo añadimos a la próxima versión.
Volver a la app