Llevaba meses pagando la cuota mensual de Listonic para gestionar la lista de la compra qye comparto con la familia, y cada vez me sentaba peor. No es solo el dinero — es que, pagando, la app seguía sin ir del todo fina. Un día, hablando con Claude sobre el tema, le pregunté algo que llevaba tiempo rondándome: ¿y si me hago la mía?
Este artículo es el resumen de ese proceso: de una idea suelta a una PWA completa, funcionando en mi homelab, que ahora mismo usa toda la familia.
Primer intento: aprovechar lo que ya tenía
Antes de ponerme a programar, la primera pregunta razonable era: ¿de verdad necesito construir algo, o ya tengo la pieza en casa? Como todos los que llevamos usuarios de familia configurados en Home Assistant, probé la integración local_todo — listas de tareas nativas, con tarjeta en el dashboard, sync instantáneo entre todos.
Funcionó, hasta cierto punto. Añadir productos iba bien, marcar como comprado también. Pero en cuanto quise poner cantidades ("Leche, 6 litros") o —sobre todo— fotos de producto para no liarme comprando la marca equivocada, HA se quedó corto. No hay campo de cantidad estructurado, y ni de lejos soporta adjuntar una imagen por ítem.
Ahí decidí que sí, tocaba construir algo a medida.
La decisión de stack: PWA, no app nativa
Mi primer instinto fue "app de Android". Pero pensándolo con calma, una PWA instalable encajaba mucho mejor: cero curva de aprendizaje de Android Studio/Kotlin, se instala en el móvil como una app normal, y reutilizo mi stack de siempre.
Backend elegido: Node.js + Express + Socket.io, con node:sqlite — el módulo de SQLite integrado en Node 22+, que evita el clásico infierno de compilar better-sqlite3 en un LXC con red restringida. Sin dependencias nativas, sin node-gyp, sin dramas.
Frontend: HTML/CSS/JS vanilla, sin build step. Nada de React para una app tan sencilla — sería matar moscas a cañonazos.
Identidad visual: nada de plantilla genérica
Antes de escribir una sola línea de JS, decidimos la dirección visual: en vez del típico crema+terracota que sueltan por defecto muchas herramientas de IA, fuimos a por una estética de etiqueta de mercado — verde toldo de frutería, papel kraft, mostaza de sello de precio. Al marcar un producto como comprado, aparece un sello de tinta rojo, como el que usarían en un almacén.
Es un detalle pequeño, pero marca la diferencia entre "otra app gris más" y algo con personalidad propia.
Construyendo la base: listas, productos, fotos
El primer MVP fue sencillo: una lista, productos con nombre y cantidad, marcar comprado, borrar. Desplegado en un LXC nuevo de mi Proxmox (el mismo patrón de siempre: rsync + systemd + Nginx en el VPS de Hetzner haciendo de proxy inverso hacia la LAN vía WireGuard).
Las fotos de producto trajeron su propia lección: subir una foto de cámara sin procesar (3-5MB) chocaba contra el límite por defecto de Nginx (1MB) y, aunque lo subiera, no tenía sentido cargar tanto peso para simplemente ver un producto o leer una etiqueta. La solución fue redimensionar la imagen en el propio móvil, con canvas, antes de subirla — de varios MB a 100-300KB, sin perder nada de utilidad real.
Categorías y buscador: el toque de "lista de papel de verdad"
Con el uso diario surgió una necesidad muy concreta: agrupar "Manzanas, Naranjas, Peras" bajo "Fruta", o "Jamón" bajo el nombre del puesto del mercado donde lo compro siempre. Un campo de categoría libre, con autocompletado de las que ya has usado, resolvió esto sin complicarme con un catálogo cerrado de categorías predefinidas.
El buscador vino después, cuando la lista empezó a crecer: escribes "basura" y te aparece "bolsa basura papelera", "bolsa Basura grande" y "bolsa Basura terraza", sin importar mayúsculas, acentos, ni en qué parte del nombre esté la coincidencia. Y si no existe nada parecido, un botón "Crear 'basura'" abre el formulario ya con el nombre puesto.
El bug que se repitió tres veces
Vale la pena contarlo porque es un clásico del CSS que me pilló tres veces seguidas en este proyecto: una clase con display: flex en un elemento que también llevaba el atributo hidden de HTML. Resulta que una regla de autor con display explícito gana siempre al display: none que el navegador aplica por defecto al atributo hidden, sin importar la especificidad. El resultado: paneles que deberían estar ocultos se quedaban visibles y tapando media pantalla, o campos que nunca llegaban a ocultarse aunque el JavaScript hiciera bien su trabajo.
La solución definitiva, después de parchear el síntoma un par de veces, fue una regla global:
[hidden] {
display: none !important;
}Con eso, el atributo hidden vuelve a mandar siempre, pase lo que pase con el resto de reglas.
Autenticación de verdad
La versión inicial usaba un selector simple de "¿quién eres?" sin contraseña — suficiente para probar el concepto, pero con un problema evidente: cualquiera con el enlace entraba sin más. Para uso estrictamente personal habría tirado de .htpasswd en Nginx, como hago con otros servicios de mi infraestructura. Pero aquí hay varios miembros de la familia compartiendo el mismo dominio, y además quería publicarlo en abierto para que otros lo puedan reutilizar — así que le tocaba tener su propio sistema de login.
Implementé sesiones reales: contraseña por persona con scrypt (nativo de Node, sin dependencias externas de hashing), cookie de sesión firmada por el servidor (httpOnly, Secure, SameSite=Lax), opción de "recordarme" en el dispositivo. El primer acceso de cada familiar es también el registro: tocas tu nombre, y la contraseña que escribas esa primera vez se convierte en la tuya.
Publicándolo
Con todo esto funcionando de forma estable durante unos días de uso real —y con la familia haciendo de beta-testers usándolo sin quejas, que es la prueba de fuego de verdad— tocaba el siguiente paso: subirlo a GitHub bajo licencia MIT, por si a alguien más le sirve para lo mismo que a mí.
El código está aquí: github.com/jferrep/compra-app
Si tienes un homelab similar al mío (Proxmox + LXC + Nginx como proxy inverso + WireGuard), el despliegue debería sonarte muy familiar. Y si no, el único requisito real es Node.js 22.5+ — el resto corre en cualquier sitio, sin dependencias nativas que compilar.
Lo que queda pendiente
No está cerrado del todo — quedan cosas en el tintero: poder editar la foto de un producto ya creado (ahora mismo hay que borrarlo y crearlo de nuevo), y backup automático del LXC en mi rotación de Proxmox Backup Server, que todavía no he añadido.
Pero para ser una app que nació de estar hasta el gorro de pagar una cuota mensual por algo que no acababa de funcionar bien, no me puedo quejar del resultado.