NUTRISYNCBuilders Hub
🏠 🛠
NUTRISYNC · Docs

Sesión r12 — El piloto pasa a medirse solo

4 de agosto de 2026 · de po81 a po89 · 9 despliegues web, 1 doble OTA, 7 ficheros SQL, 2 Edge Functions y 2 automatismos nuevos.

El hilo conductor del día: dejar de mirar números ilustrativos y empezar a medir el piloto de verdad. Al final de la jornada sabemos que 8 de 35 testers de iOS tienen la app instalada — un dato que esta mañana no existía en ninguna parte.

1 · Los dos bugs de Pilar

El anillo de movimiento que no cerraba

Reportado como "con strength training no me cierra el anillo cuando es el most recommended". Tuvo dos causas encadenadas, y la segunda solo apareció tras arreglar la primera:

  1. Intensidades compuestas: el catálogo trae valores como Moderate-High, Low-Mid o Restorative Yoga que no casaban con el mapa de cinco valores limpios. Se sustituyó por clasificación por contenido, tomando siempre el nivel más alto presente.
  2. El ítem sin intensidad: varias fichas de fuerza no declaran intensidad porque la tarjeta muestra "Most Recommended" en su lugar. Se guardaba null y el componente de movimiento quedaba a cero justo en el caso por defecto. Ahora, si el ítem no lo declara, manda su categoría (strength → moderate, HIIT → high, yoga → low). Arregla también lo ya registrado, sin migración.

El CSS que nunca aparecía

La tarjeta pedía un ciclo cerrado como referencia, así que con un solo ciclo en curso quedaba bloqueada ~2 meses y mostraba "11/7 días", que no significa nada. Se añadió una escalera de baseline: sin ciclo cerrado, la referencia son los 7 primeros días registrados y la ventana actual son los posteriores. Se desbloquea a los 12 días y mientras tanto dice exactamente cuántos faltan.

La regla que salió de aquí

El valor por defecto es una elección. Dos bugs de la misma familia en dos semanas (la rueda de métricas que no confirmaba el valor visible, y el checklist que guardaba null). Todo selector entrega valor aunque no se toque; si el dato no viene, manda el respaldo declarado, nunca un null silencioso. La lógica de selección vive en lib/pickers.ts, pura y con unitarios propios.

2 · El piloto, en tres superficies

Las gráficas del piloto vivían dentro del panel Business case del MIS, mezcladas con una escala mensual que llega a 2027 — el piloto quedaba aplastado hasta ser invisible. Se separó en tres pestañas con responsabilidades distintas:

PestañaPara qué
🧪 PilotoOperar: cohortes, invitaciones, recordatorios, nombres cercanos, CRUD.
🗓 Plan pilotoDecidir: objetivo semanal editable, hitos que no se mueven, objetivo vs real.
📡 Obs. pilotoObservar: distribución del build, uso real, embudo por cohorte y feedback.

Las gráficas son una sola fuente (hub/pilot-charts.js, funciones puras con unitarios) para que las dos pestañas no se separen con el tiempo. El MIS enlaza, no duplica.

Decisiones de diseño que costaron una iteración

3 · Comunicación con las testers, automatizada

Tres plantillas por audiencia, cada una con su botón, todas en la Edge Function (una sola fuente, nada de copiar y pegar):

La audiencia la calcula el backend, así que nadie selecciona filas. Salen solos cada mañana a las 9:00 vía pg_cron; el botón es solo para adelantarlos. Anti-spam por construcción: máximo 2 por persona, 5 días entre ellos, y paran en cuanto la tester avanza de estado.

Se añadió el nombre cercano: las founders escriben cómo llaman a cada tester y el saludo se resuelve en una única función SQL — apodo → nombre de la waitlist → sin nombre.

4 · El dato que faltaba: quién ha instalado de verdad

Hasta hoy solo sabíamos quién creaba cuenta en la app. Ahora la Edge Function store-stats pregunta a App Store Connect cada mañana y guarda una instantánea.

Primera lectura: 35 testers en TestFlight, 8 instaladas, 27 solo invitadas. El cuello de botella del piloto no es conseguir testers, es que crucen la puerta.

Para Google Play se decidió no montar el equivalente: su API no expone el estado de instalación del track interno y las métricas viven en informes con export a Cloud Storage. Para 12 testers, matar moscas a cañonazos. La señal de Android es la nuestra — cuenta creada + log de accesos — que además mide uso, no descarga.

5 · Guardarraíles: cinco packs y dos lecciones nuevas

El pack de regresión creció de 10 a 29 unitarios web y de 96 a 129 jest. Todos corren automáticos en el Deploy y abortan el push en rojo.

Lo que se aprendió por las malas

El gateway de Supabase exige Authorization antes de invocar la Edge Function. Sin él responde UNAUTHORIZED_NO_AUTH_HEADER y nuestro control ni se evalúa. El cron de recordatorios lo tenía mal: habría fallado a la mañana siguiente en silencio. Toda llamada desde pg_net lleva ahora Authorization + apikey + el secreto propio.

En App Store Connect, /v1/apps/{id}/betaTesters da 403 con clave de equipo. Lo que funciona es el recurso de primer nivel /v1/betaTesters?filter[apps]={id}. Y el detail del error de Apple es lo único que explica un 403: truncarlo a 200 caracteres nos costó una iteración.

También se corrigieron dos falsos positivos propios: el smoke trataba como fallo el 308 de Cloudflare Pages hacia las URLs limpias (/legal/terms.html/legal/terms), y el linkcheck marcaba ?r=builders como roto. Un guardarraíl que grita sin motivo se acaba ignorando.

Y un fallo silencioso menos: si falta el SQL del piloto, la tarjeta del MIS lo dice con el nombre del fichero, en vez de quedarse en blanco.

6 · Operación diaria

Cierra el círculo la pestaña 📋 Operación: salud de los automatismos (verde discreto, rojo grande), qué toca hoy con números y enlace directo, los días que quedan para el 17-ago y el 8-oct, y los procedimientos escritos — desplegar, publicar OTA, qué hacer si un cron falla, dar de alta testers, volver atrás un deploy.

Se comprueba solo al abrir la página. No hay nada que ejecutar a mano.

7 · Lo que queda en manos de otros

8 · El día después: dos horas persiguiendo una página en blanco

Con todo desplegado, las tres pestañas del piloto salían vacías. La caza del fallo dejó tres lecciones que valen más que la funcionalidad que arreglaron.

Causa 1 · SQL entregado a trozos

La web llamaba a admin_pilot_weekly(p_cohort) y en la base solo existía la versión sin argumentos: fui dando los ficheros SQL sueltos entre otras conversaciones y dos nunca se ejecutaron. Ahora los guardarraíles lo impiden por dos vías: un test de contrato que compara cada rpc() de las páginas del hub con las funciones definidas en backend-sql/ y aborta el deploy si falta alguna; y nsFail() en las páginas, que ante un error de consulta pinta un banner rojo con el nombre del fichero a ejecutar en vez de dejar la pantalla en blanco.

Causa 2 · Un JavaScript cacheado

El síntoma más engañoso del día: las cifras de cabecera salían bien y la gráfica y la tabla no. El navegador servía la versión anterior de pilot-charts.js, sin la función que se había añadido esa mañana, y el renderizado moría exactamente ahí — sin un solo error visible en la página. Regla nueva: todo asset propio va versionado por contenido (?v=<sha8>), y lo calcula integrate.py en cada integración para que nadie tenga que acordarse.

Causa 3 · La cohorte inventada

Las 26 testers de TestFlight entraron todas como wave-1 porque el CSV se llamaba así. En App Store Connect había tres grupos. Copiar a mano un dato que ya vive en un sistema externo crea una segunda verdad que nace desincronizada. Ahora store-stats trae el grupo de cada tester y pilot_sync_testflight mantiene founders / advisory / wave-1 solas cada mañana: si mueves a alguien de grupo en Apple, al día siguiente está movida aquí.

Y una lección de método, la que más costó: medir antes de hipotetizar. La primera captura ya acotaba el fallo a un punto exacto — lo que se pinta ANTES funcionaba y lo de DESPUÉS no —, y aun así probé tres teorías distintas antes de mirar ahí. Cuando algo "no se ve", la pregunta no es qué puede estar roto sino hasta dónde llegó a funcionar.


Registro de sesión · generado el 4-ago-2026, ampliado el 5-ago con la sesión de depuración. El detalle técnico vive en CLAUDE.md (memoria operativa), los ficheros SQL en backend-sql/ y los zips de cada despliegue en releases/, inmutables.