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.
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:
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.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.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.
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.
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ña | Para qué |
|---|---|
| 🧪 Piloto | Operar: cohortes, invitaciones, recordatorios, nombres cercanos, CRUD. |
| 🗓 Plan piloto | Decidir: objetivo semanal editable, hitos que no se mueven, objetivo vs real. |
| 📡 Obs. piloto | Observar: 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.
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.
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.
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.
El gateway de Supabase exige
Authorizationantes de invocar la Edge Function. Sin él respondeUNAUTHORIZED_NO_AUTH_HEADERy 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}/betaTestersda 403 con clave de equipo. Lo que funciona es el recurso de primer nivel/v1/betaTesters?filter[apps]={id}. Y eldetaildel 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.
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.
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.
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.
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.
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.