EL PRODUCTO
Los acuaristas apuntan sus mediciones en libretas, preguntan en foros de hace quince años y compran peces sin saber si van a convivir. Tankily une todo eso en un solo sitio que gira alrededor del tanque: cada acuario tiene su agua, sus habitantes, su diario y sus recordatorios. Alrededor nace la comunidad: muro, foro, compraventa entre aficionados, directorio de tiendas y WikiTank, una enciclopedia con fichas técnicas que alimenta todo lo demás. Lo he concebido, diseñado, programado y lanzado yo solo, y hoy está en producción con usuarios reales, plan de pago y campaña en redes.
INFRAESTRUCTURA TÉCNICA
- FRONT-END: Next.js 16 (App Router) y React 19 con export estático: miles de páginas pregeneradas, servidas como HTML plano.
- BACKEND: Supabase: Postgres con RLS en todas las tablas, Auth (email, Google y Apple), Storage y Realtime.
- SERVIDOR: Edge Functions en Deno (avisos, push, Stripe, traducción) y tareas programadas con pg_cron.
- PAGOS Y DATOS: Stripe para el plan Pro, Umami para analítica sin cookies, IndexNow y sitemaps por idioma.
01 / EL CORAZÓN DEL PRODUCTO
El diario del tanque
Cada acuario es una ficha viva: volumen, días en marcha, parámetros del agua con gráficas, habitantes enlazados a WikiTank, diario de mantenimiento y publicaciones. Los tanques públicos se pueden seguir, y cada uno es también una página indexable que trae visitas desde buscadores.
- Mantenimiento: tareas periódicas por tanque; el cambio de agua es una tarea virtual que se calcula a partir del último evento del diario. Un tanque nuevo nace con recordatorios sugeridos según su tipo.
- Panel «Hoy»: la portada reúne lo pendiente de todos los tanques del usuario, con avisos por email y notificaciones push.
- Unidades: en la base de datos todo es métrico;
useUnidades()convierte al sistema imperial al mostrar y al guardar.
// Pantalla: tanque público con mediciones de agua de arrecife.
02 / LÓGICA DE DOMINIO
Motor de compatibilidad
La pregunta que más se repite en acuariofilia es «¿pueden convivir?». La respuesta sale de un módulo de funciones puras, con pruebas, que cruza las fichas de las especies entre sí y con la última medición real del tanque.
- Qué detecta: agua dulce o marina equivocada, especies no aptas para arrecife, rangos sin solape de temperatura, pH, GH y salinidad, litros insuficientes, grupos demasiado pequeños y agresivos junto a pacíficos.
- Dónde vive: en la pestaña Habitantes, como vista previa al añadir una especie y como calculadora pública sin registro, que es también la mejor puerta de entrada al producto.
- Embudo: la calculadora guarda las especies elegidas como «tanque pendiente» y las añade solas cuando el visitante crea su cuenta.
// Pantalla: betta y gamba cherry en 60 litros, con el agua ideal para ambos.
03 / CONTENIDO COMO MOTOR
WikiTank y 1.847 especies
La enciclopedia es el sistema nervioso de Tankily: sus fichas técnicas alimentan el autocompletado de habitantes, el motor de compatibilidad, las calculadoras y el SEO. En el build se leen los Markdown, se unen duplicados por nombre científico y se genera un índice compartido por todo el producto.
- Datos contrastados: nombres y familias validados con GBIF; temperatura, pH y tamaño cotejados con FishBase como referencia.
- Fotos con licencia: imágenes de Wikimedia Commons con licencia libre y crédito visible, revisadas a ojo en hojas de contactos para descartar ejemplares de museo y dibujos.
- 8 idiomas: fichas y artículos traducidos pieza a pieza, con canónica y hreflang propios; una ficha solo se indexa en un idioma cuando está traducida entera.
// Pantalla: catálogo con filtros por tipo, agua, dificultad y aptitud para arrecife.
04 / MOBILE FIRST · PWA
Pensado para el móvil, junto al acuario
Tres de cada cuatro visitas llegan desde el teléfono, normalmente con el test de agua en la mano. La interfaz cambia la barra lateral por una barra inferior de cristal, prioriza las cifras grandes y se instala como app (PWA) con iconos propios, modo sin conexión y notificaciones push.
- Navegadores de apps: detecta el navegador interno de Instagram y Facebook, donde Google bloquea el acceso con su cuenta, y guía al usuario para abrir Chrome o Safari.
- Rendimiento: la refracción de cristal en SVG se reserva para modales y fichas; el resto usa cristal ligero para mantener el scroll fluido en móviles modestos.

// Portada

// Ficha de especie

// Tanque
05 / COMUNIDAD SIN FRONTERAS
Foro, muro y mercado
Un acuarista polaco puede responder al hilo de uno brasileño. Cada publicación guarda su idioma y, si no coincide con el del lector, se traduce sola con «Traducido del francés · Ver original».
- Traducción por capas: caché local y tabla compartida, luego el traductor integrado de Chrome y, en último caso, una Edge Function que solo traduce filas existentes. Etiquetas, menciones y enlaces nunca se tocan.
- Antispam en la base de datos: un trigger genérico
limitar_frecuencia()limita publicaciones, mensajes, anuncios y reseñas por usuario y periodo. - Campos blindados: permisos por columna para que nadie pueda marcarse como admin, verificar su tienda o destacar su anuncio desde el cliente.
// Pantalla: foro organizado por tipo de acuario y ayuda.
06 / INGENIERÍA DE ENTREGA
Del commit a producción, sin servidor que mantener
El export estático multiplicaba las páginas por ocho idiomas y el primer build pesó 5,6 GB en 184.000 archivos: el despliegue por FTP no terminaba nunca. Cargar el diccionario por idioma en lugar de incrustarlo en cada página y cambiar a rsync por SSH lo dejó en menos de la mitad, con despliegues incrementales en minutos.
Push a main lanza el flujo de GitHub Actions.
Índice de especies, ads.txt, sitemaps y páginas por idioma.
Solo sube lo que cambia, con clave dedicada.
Regenera tanques, hilos y anuncios públicos y avisa con IndexNow.
07 / ARQUITECTURA
Cómo encajan las piezas
- 43migraciones SQL
- 32tablas
- 105políticas RLS
- 32pruebas con node --test
- 4Edge Functions
- 8idiomas
Navegadorlo que recibe el usuario
Supabasedatos y reglas
Servidorsin servidor que mantener
Entregadel commit a producción
// Decisiones técnicas y por qué
limitar_frecuencia(), con índices por autor y fecha para que el recuento no recorra la tabla.src/lib, sin React, cubiertas con pruebas de node --test.08 / EN EL CÓDIGO
Fragmentos reales del repositorio
Motor de compatibilidad
Para saber si varias especies pueden compartir agua, se cruzan sus rangos de temperatura, pH, GH y salinidad. Si la intersección está vacía, hay conflicto. Cada parámetro tiene un margen de tolerancia para no avisar por diferencias que no importan.
Es una función pura: recibe datos y devuelve avisos, así que se prueba sin navegador ni base de datos.
// Rango común de una lista de rangos [min, max]; null si no se solapan
function interseccion(rangos) {
if (!rangos.length) return undefined;
const min = Math.max(...rangos.map((r) => r[0]));
const max = Math.min(...rangos.map((r) => r[1]));
return min <= max ? [min, max] : null;
}
const PARAMS = [
// margen: tolerancia de medida para no avisar por diferencias que no importan
{ clave: 't', parametro: 'temperatura', margen: 0.5 },
{ clave: 'ph', parametro: 'ph', margen: 0.1 },
{ clave: 'gh', parametro: 'gh', soloDulce: true, margen: 1 },
{ clave: 'sal', parametro: 'salinidad', soloMarino: true,
convertir: densidadAPpt, margen: 2 },
];
Antispam en la base de datos
Un solo trigger sirve para ocho tablas: recibe por argumentos qué columna identifica al autor, cuántas inserciones se permiten y en qué periodo. Si se supera, Postgres rechaza la inserción con un código que la interfaz traduce a un mensaje claro.
create or replace function public.limitar_frecuencia()
returns trigger language plpgsql security definer set search_path = '' as $$
declare
columna text := tg_argv[0]; -- columna con el autor
maximo int := tg_argv[1]::int; -- inserciones permitidas…
periodo interval := tg_argv[2]::interval; -- …en este periodo
autor uuid := (to_jsonb(new) ->> columna)::uuid;
recientes int;
begin
if autor is null or (select auth.uid()) is null or public.es_admin() then return new; end if;
execute format('select count(*) from %I.%I where %I = $1 and creado_en > now() - $2',
tg_table_schema, tg_table_name, columna) into recientes using autor, periodo;
if recientes >= maximo then
raise exception 'LIMITE_FRECUENCIA' using errcode = 'P0001';
end if;
return new;
end $$;
create trigger limite_mensajes before insert on public.mensajes
for each row execute function public.limitar_frecuencia('emisor_id', '60', '10 minutes');
RESUMEN DEL IMPACTO
Tankily es un producto completo construido por una sola persona: investigación del nicho, marca, diseño de interfaz, modelo de datos con seguridad a nivel de fila, lógica de dominio con pruebas, internacionalización real en ocho idiomas, SEO técnico a escala de miles de páginas, pagos, notificaciones y una campaña de contenido en redes. Es la demostración más clara de cómo trabajo: entender un problema concreto y llevarlo hasta algo que funciona en producción.