Ayunora: de «hazme las preguntas necesarias» a una app en producción en un día
Quería una app de ayuno intermitente sin suscripciones ni anuncios, así que me la hice en un día con un agente de IA: PWA para iPhone, avisos push, historial y peso. Lo interesante no es la velocidad, sino dónde se equivocó la máquina, dónde me equivoqué yo y qué nos salvó.
IAPWAProyectosNode.js
Hago ayuno intermitente y usaba una de esas apps que te recuerdan cada dos pantallas que existe la versión premium. Lo que yo necesitaba era mucho más sencillo: apuntar cuándo empiezo y cuándo termino, que me avise al acabar y ver cómo voy. Así que un jueves por la mañana abrí un repositorio vacío y le escribí a Claude Code: «quiero hacer una app de ayuno intermitente, una PWA instalable en iOS, con SQLite; ayúdame a definirla, hazme las preguntas necesarias».
Unas horas después ya me llegaban al iPhone las notificaciones de la app, con la pantalla bloqueada. Esto no va de lo rápido que fue. Va de lo que aprendí sobre trabajar así, y de los cuatro o cinco sitios donde el proyecto podría haber salido mal.
El resultado es Ayunora, y el código está en GitHub.
Primero las preguntas, después el código
Lo primero que hizo el agente no fue escribir código, sino frenarme con una restricción que yo no tenía presente: una PWA en iOS no puede programar notificaciones locales. No hay forma de decirle al iPhone «avísame dentro de 16 horas». El único camino es Web Push enviado desde un servidor, y solo funciona si la app está instalada en la pantalla de inicio.
Eso cambió el diseño entero. SQLite ya no podía vivir solo en el navegador: hacía falta un backend siempre encendido, con un planificador que supiera a qué hora enviar cada aviso. A partir de ahí vinieron las preguntas que sí me tocaba contestar a mí: si tendría cuentas (sí, sencillas y solo con invitación, por si alguien de casa se anima), dónde la alojaba (en mi VPS, que ya tiene Cloudflare delante), qué funciones entraban en la primera versión y qué avisos quería.
Con eso salió una especificación, un plan por hitos y una decisión de orden que repetiría en cualquier proyecto: el primer hito no construye ninguna funcionalidad, valida el mayor riesgo. Antes de escribir el temporizador, el objetivo era que llegase al iPhone una notificación de prueba con la app cerrada. Si eso fallaba, todo lo demás daba igual.
El stack, y por qué tan pequeño
Vite + React con vite-plugin-pwa y un service worker propio, para controlar a mano los eventos push y notificationclick.
Hono sobre Node 24, con Drizzle y better-sqlite3 en modo WAL.
web-push con claves VAPID, y un planificador en el mismo proceso que revisa cada 15 segundos qué avisos tocan.
systemd en un VPS de 2 GB que ya comparte con varias webs en WordPress. El proceso entero ocupa unos 80 MB.
Sin Docker, sin colas ni Redis. Para una app de uso personal, cada pieza de más es una pieza más que mantener.
Lo que salió mal (y cómo lo pillamos)
Lo interesante de este proyecto está aquí. El agente escribía código rápido y con buen criterio, pero no fue infalible, y yo tampoco. Lo que nos salvó, casi siempre, fue lo mismo: comprobar el resultado en vez de fiarse de él.
Los cambios de hora. El recordatorio diario tiene que sonar a las 20:00 hora local, también el día que se adelanta o se atrasa el reloj. La primera versión de la conversión entre hora local y UTC parecía correcta y lo era en el 99 % de los casos. Los tests de los dos domingos del cambio de hora la tumbaron: en primavera devolvía las 01:30 para una hora que no existe, y en otoño elegía la segunda vez que el reloj marca las 02:30. Sin esos dos tests, el fallo habría aparecido un domingo de marzo y nadie habría entendido por qué.
El botón que se quedaba pensando. Ya en producción, pulsé «Activar notificaciones» en el iPhone, concedí el permiso… y el botón se quedó en «Un momento…» para siempre. El servidor no había recibido nada. La causa: el manifest de la PWA aparecía dos veces en la lista de ficheros que el service worker guarda para funcionar sin conexión, con dos versiones distintas, y Workbox aborta la instalación cuando ve eso. Sin service worker no hay push, y navigator.serviceWorker.ready espera para siempre. El arreglo fue una línea. La lección fue otra: ahora el build falla si esa lista tiene duplicados, y el cliente nunca espera sin límite de tiempo.
El login que funcionaba sin funcionar. El servidor respondía 200 y la pantalla no cambiaba. Era un queryClient.clear() antes de guardar los datos de sesión: borraba también la consulta a la que estaba suscrito el componente, y el dato nuevo caía en una consulta que nadie miraba. Lo encontró el agente revisando la app en el navegador, no los tests.
La trampa del DNS. Al crear el registro DNS antes que el vhost, ayunora.es redirigió durante unos minutos a la web de un cliente que tengo en el mismo servidor: Nginx, sin un vhost propio para ese dominio, cae en el primero que escucha en el 443. Estaba documentado en el runbook del VPS como trampa conocida, y aun así pasó. Documentar los errores no impide repetirlos, pero sí reconocerlos en segundos.
Los avisos que nunca llegarían. Una PWA de iOS que vuelve de segundo plano no navega, así que no comprueba si hay una versión nueva de la app. Sin tocar nada, me habría quedado semanas con una versión vieja. Ahora la app busca actualizaciones al volver a primer plano y cada hora.
Lo que hice yo
Sería fácil contar esto como «la IA lo hizo todo», pero no es verdad. Hubo decisiones que solo podía tomar yo: el nombre, el dominio, qué entraba en la primera versión, cuándo desplegar. Hubo también una parte de criterio que conviene no delegar. Las reglas de mi VPS piden trabajar por fases y con confirmación antes de tocar Nginx o las copias de seguridad, y el agente las respetó: me explicaba cada paso y esperaba. Cuando quiso desplegar a producción por su cuenta, el propio entorno se lo impidió, y lo lancé yo.
Y hubo una cosa en la que me alegro de que fuera desconfiado. Al pedirle que publicara el repositorio, antes de subir nada revisó la historia de git y encontró la IP real del servidor, la que Cloudflare oculta, en una versión antigua de la documentación, además de mi email del trabajo en todos los commits. Se reescribió la historia antes de publicarla. Publicar es fácil de hacer y muy difícil de deshacer.
La segunda vuelta: Turnstile y una revisión de seguridad
Con la app ya en uso, pedí dos cosas más: Cloudflare Turnstile en el login y el registro, y una revisión de seguridad de verdad.
Turnstile parece trivial (pegas un widget y listo), pero el widget no protege nada por sí solo. Lo que cuenta es lo que hace el servidor con el token: canjearlo en siteverify, comprobar que se emitió para esa acción concreta (login o register) y para mi dominio, y no aceptarlo dos veces. Si Cloudflare no responde, se rechaza: fallar cerrado. Y se verifica antes de comprobar la contraseña, porque el hash es caro a propósito y no quiero que un bot lo use para gastarme la CPU. En producción es obligatorio: sin la configuración, el servidor directamente no arranca.
Hubo un detalle que no esperaba: con las claves de prueba que Cloudflare publica para desarrollo, siteverify no devuelve el campo action, así que mi verificador, que lo exigía, rechazaba todo en local. La solución fácil habría sido relajar la comprobación. La que dejé fue aceptar respuestas de prueba solo fuera de producción y, en producción, negarse a arrancar si el secreto es una clave de prueba: con ella, Cloudflare da por bueno cualquier token.
La revisión encontró algo más serio. Para enviar una notificación, el servidor hace un POST a la URL que el navegador le da al suscribirse, y esa URL se aceptaba sin mirar. Cualquiera con una cuenta podía registrar https://127.0.0.1:… y usar mi servidor para hablar con servicios internos del VPS: un SSRF de manual. Ahora solo se admiten HTTPS y los servicios de push reales de Apple, Google, Mozilla y Microsoft. También apareció un contador de intentos de login que se reiniciaba al acertar, de modo que quien tuviera una cuenta podía seguir probando contraseñas de la otra. Y faltaba una CSP.
El orden de publicación también importaba. El repositorio es público, así que no subí el arreglo hasta que producción estuvo parcheada: publicar el parche antes es publicar el agujero. Después lo validé con un token real de Cloudflare: el primer uso pasa y el mismo token, reutilizado, se rechaza.
Copias de seguridad, o no está terminado
Una app que guarda datos de alguien no está terminada hasta que se puede restaurar. La base de datos SQLite entra ahora en la copia diaria cifrada del servidor, junto con las claves de las notificaciones (si se pierden, hay que reactivar el push en cada móvil). Se usa .backup en lugar de copiar el fichero, porque con la app escribiendo en modo WAL una copia a pelo puede salir incoherente. Después se comprobó lo importante: restaurar la copia en otro sitio y verificar que tiene los mismos ayunos, pesos y dispositivos que la real.
Lo que me llevo
Empezar por las preguntas. La restricción de iOS habría aparecido de todas formas, pero mejor el primer día que el tercero.
El primer hito debe validar el mayor riesgo, no avanzar funcionalidad.
Mirar el resultado. Los tests pillaron la lógica; la app abierta en un navegador pilló el resto. Ninguno de los dos sustituye al otro.
Un captcha es tan bueno como su verificación en el servidor. El widget es lo de menos.
Un agente rinde mucho más con contexto escrito. El runbook del VPS, con sus trampas documentadas, hizo que el despliegue fuera un procedimiento y no una improvisación.
Ayunora lleva un día en uso y ya ha cumplido su único requisito real: la uso, me avisa y no me pide que pague por ver mi propio historial.