Rediseño de la app de fitness con presencia humana del trainer, sincronización en vivo del plan y operación estable en gimnasios sin señal.
01 · Contexto y problema
Skarfit conecta personal trainers (CREF + CRN) con alumnos en Brasil. La promesa del producto: «el trainer parece presente sin necesitar estar» — el alumno entrena con un plan publicado por su coach, ve su voz/foto/mensajes durante la sesión, y el trainer monitorea desde un dashboard B2B sin estar físicamente en el gym.
Recibí el proyecto con deuda heredada que se chocaba: un onboarding con rutas hardcoded (10 de 16 grupos musculares caían silenciosamente en «Espalda»), un dashboard del alumno acoplado en un archivo de ~3.500 líneas, y una vertical de alimentación que mezclaba el día de hoy con un histórico no inmutable.
El objetivo fue sostener la app en producción mientras se reconstruía la base — sin reescribir todo de golpe, con cobertura de tests y migración idempotente desde el schema legacy.
Restricciones
02 · Decisiones técnicas
Arquitectura
Problema
El estado del alumno vivía disperso entre setState, SharedPreferences global (filtraba datos entre usuarios al cambiar de cuenta) y providers ad-hoc; los alumnos legacy caían en estados imposibles.
Opciones
setState + InheritedWidget · Provider · BLoC · Riverpod 3
Decisión ✓
Riverpod 3 + Clean Architecture con DDD pragmático: 6 bounded contexts, 3 capas, value objects manuales y un SAD canónico con 13 ADRs vivos dentro del repo.
Trade-off
Curva de aprendizaje en VOs y aggregates. Mitigado con el SAD + tests por aggregate (310+ solo en Alimentación).
Sincronización trainer ↔ alumno
Problema
Publicar el plan desde el dashboard debía reflejarse en el alumno sin interrumpir una sesión activa y con la garantía de que «publicado» solo lo escribe el servidor.
Opciones
Polling 5s · streams + cliente escribe · streams + Cloud Function única · FCM push
Decisión ✓
Firestore real-time streams + Cloud Function publishPlan como único writer del estado publicado, gateada por CREF verificado y reglas Firestore. Lecturas Source.server en rutas críticas (splash/login/onboarding).
Trade-off
+1 hop y ~60 ms al publicar. A cambio: cumplimiento CREF, cero race conditions y routing determinístico.
Offline en el gym + gama baja
Problema
Sesiones de 45 min sin red, con foreground service para que el battery manager no mate el proceso; datos sensibles que no pueden perderse y listas largas que tiraban los FPS.
Opciones
Offline persistence nativo · SharedPreferences directa · Hive · Cola idempotente
Decisión ✓
SharedPreferences UID-scoped (sin clave global jamás), Firestore offline persistence, WorkoutForegroundService y RestAlertService con escalación declarativa. Catálogo de ejercicios cacheado en memoria + listas virtualizadas.
Trade-off
Invalidación compleja → checklist obligatorio de persistencia pre-PR. Frenó ~15 % el desarrollo pero cortó los bugs de «se perdió el peso anotado».
Arquitectura
03 · Migración de Alimentación a DDD per-weekday + histórico inmutable
El schema previo guardaba comidas como una lista global con flags «done»: servía para «lo que como hoy», pero se rompía al querer planear por día de la semana, ver el histórico de las últimas 4 semanas y marcar días libres dentro del límite semanal.
La migración debía dejar la app en producción sin downtime, preservar lo que el alumno ya había planeado y mantener una boundary limpia (otras tabs leen el progreso sin tocar las internas del módulo).
Solución: 4 aggregate roots + 5 value objects, una migración one-shot idempotente del schema legacy, y una boundary cross-tab vía un único api.dart. 310+ tests por aggregate y 7 fases en 34 commits granulares, mergeado en producción sin un bug reportado.
El criterio de éxito fue duro: que el alumno legacy abriera la nueva pantalla y no se diera cuenta de la migración. Se logró.
Demo interactiva
Próximamente: ejecuta el código y míralo correr en vivo, sin instalar nada.
Editor en vivo (DartPad) — próximamente
04 · Resultados · antes / después
05 · Retrospectiva
Introduciría el checklist de persistencia desde el día 1: apareció en el mes 4 tras dos incidentes de pérdida de datos; tenerlo antes habría ahorrado ~3 semanas de retrabajo.
Cerraría antes la boundary cross-tab: Alimentación quedó limpio exportando solo api.dart, pero otras tabs aún se importan entre sí — deuda asumida conscientemente.
Conservaría el SAD único con ADRs internos y documentar el porqué de cada decisión en memoria persistente: no es código, pero ahorró horas entre sesiones.
Código destacado · Panel admin · búsqueda de usuario
Endpoint del panel (Next.js) que consolida en una sola respuesta el estado de un usuario en Firebase Auth y Firestore (trainers + alunos), sin importar su estado de aprobación. Tres fuentes en paralelo, distinción «no encontrado» vs error real, narrowing seguro de errores del SDK y normalización de timestamps de Firestore.
export async function GET(req: NextRequest) {
try {
await requireSession();
const { searchParams } = new URL(req.url);
const email = (searchParams.get('email') ?? '').trim().toLowerCase();
const uid = (searchParams.get('uid') ?? '').trim();
if (!email && !uid) {
return NextResponse.json(
{ error: 'Informe ?email=... ou ?uid=...' },
{ status: 400 },
);
}
// 1) Auth: user-not-found NO es 500, es resultado vacío
let authUser;
try {
authUser = uid
? await adminAuth().getUser(uid)
: await adminAuth().getUserByEmail(email);
} catch (e: unknown) {
const code = (e as { code?: string } | null)?.code;
if (code === 'auth/user-not-found') {
return NextResponse.json({ found: false, query: email || uid });
}
throw e;
}
// 2) Firestore: 2 reads en paralelo
const [trainerSnap, alunoSnap] = await Promise.all([
adminDb().collection('trainers').doc(authUser.uid).get(),
adminDb().collection('alunos').doc(authUser.uid).get(),
]);
const trainer = trainerSnap.exists ? trainerSnap.data() : null;
const aluno = alunoSnap.exists ? alunoSnap.data() : null;
// 3) Tipo principal: trainer / aluno / both / solo-auth
let userType: 'trainer' | 'aluno' | 'both' | 'auth-only' = 'auth-only';
if (trainer && aluno) userType = 'both';
else if (trainer) userType = 'trainer';
else if (aluno) userType = 'aluno';
return NextResponse.json({ found: true, query: email || uid, userType /* ... */ });
} catch (e) {
return errResponse(e);
}
}En entrevista se nota: diferenciar «no encontrado» de un error real, poner llamadas independientes en paralelo, tratar errores del SDK como unknown con narrowing y normalizar timestamps propietarios — todo en ~60 líneas.