Sprint 5. Etapa 1.
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled

This commit is contained in:
2026-06-19 15:31:03 -03:00
parent 84daf82e65
commit a2763ab755
13 changed files with 612 additions and 80 deletions

249
sprints/sprint-5/BRIEF.md Normal file
View File

@@ -0,0 +1,249 @@
# Sprint 5 — BRIEF
> InQ ROI — Sprint de UX polish + quick wins
> Inicio: 2026-06-19
> Foco: experiencia del usuario, mejoras visuales, features de alto impacto/bajo esfuerzo
---
## Contexto
Sprint 4 fue un refactor arquitectural completo (IndexedDB → Supabase, auth, recursos globales). El producto está estable y en uso interno. Sprint 5 vuelve al rebalanceo normal: **60-70% features de producto, 10-15% infra/deuda técnica**.
Sin features de gran escala (historial de simulaciones, compartir con guests). Solo mejoras que suman experiencia real.
---
## Etapas
### Etapa 1 — Fix crítico: workspace loading
**Problema:** Al navegar desde la biblioteca a un proceso, el workspace se queda cargando indefinidamente. La pantalla solo carga correctamente después de refrescar la página manualmente.
**Diagnóstico a confirmar:** probable race condition entre `useEffect` de carga del proceso y disponibilidad del usuario autenticado. El `processId` llega por route params antes de que el store tenga el usuario listo, y el query a Supabase se dispara con un contexto incompleto o se omite silenciosamente.
**Alcance:**
- Diagnosticar el flujo completo: `WorkspacePage``useProcess` hook → `process-repo.getById()` → render
- Identificar dónde el guard de auth interactúa mal con la carga del proceso
- Corregir sin introducir flickering ni doble render
- El workspace debe cargar al primer intento, sin refresh
**Criterios:**
- [ ] Navegar desde la biblioteca a cualquier proceso carga el workspace en el primer intento
- [ ] Sin errores en consola durante la navegación
- [ ] El estado de loading muestra un skeleton o spinner visible (no pantalla en blanco)
- [ ] Tests Vitest verdes (sin regresiones)
- [ ] Validación visual del director
---
### Etapa 2 — Fix + mejora: zoom BPMN
**Problema:** El botón de fit-viewport actual posiciona el diagrama pegado al borde superior, sin padding. Además no hay controles de zoom explícitos.
**Alcance:**
**Fix fit-viewport:**
- Corregir `canvas.zoom('fit-viewport')` para que el diagrama quede centrado con padding uniforme (sugerido: 40px en todos los bordes)
- bpmn-js expone `canvas.zoom('fit-viewport', true)` y opciones de padding — usar la API correcta
**Controles de zoom (versión wow):**
- Botón "+" (zoom in) y "" (zoom out) en el canvas
- Botón de reset/fit (ícono de encuadre) — corrige el bug actual + sirve de reset
- Posición: esquina inferior derecha del canvas, sin tapar el diagrama
- Estilo: botones cuadrados de 32px, fondo blanco semitransparente, borde sutil, ícono Tabler
- No interferir con el zoom por scroll/pinch que ya funciona
**Íconos sugeridos:** `ti-zoom-in`, `ti-zoom-out`, `ti-focus-2` (para fit)
**Criterios:**
- [ ] Fit-viewport centra el diagrama con padding uniforme (no clip al borde)
- [ ] Botones +//fit visibles en el canvas
- [ ] +/ hacen zoom in/out incremental (factor 1.2x por click)
- [ ] Fit resetea a la vista centrada
- [ ] Los controles no aparecen encima de los paneles laterales
- [ ] Tests Vitest verdes
- [ ] Validación visual del director
---
### Etapa 3 — Heatmap con gradiente continuo + toggle de modo
**Problema actual:** el heatmap usa 3 buckets discretos (verde/amarillo/rojo) con colores fijos. Sin matices intermedios, sin gradiente.
**Alcance:**
**Escala de color continua:**
Reemplazar la lógica de 3 buckets por una función de interpolación de 4 stops:
```
0.0 → #10b981 (verde)
0.33 → #f59e0b (amarillo)
0.67 → #f97316 (naranja)
1.0 → #ef4444 (rojo vivo)
```
Implementar en `src/lib/colors.ts` como `intensityToColor(normalized: number): string` usando interpolación lineal en RGB entre stops consecutivos.
**Dos modos de normalización:**
`percentile` (default): cada actividad se posiciona según su rango relativo entre todas las actividades del proceso. No sufre distorsión por outliers.
`minmax`: `(costo - mínimo) / (máximo - mínimo)`. Refleja valores absolutos pero se comprime con outliers.
**Persistencia del modo:** guardar la preferencia en `localStorage` (key: `heatmap-mode`). Default: `percentile`.
**Toggle en UI:**
- Un toggle compacto en el panel del reporte o en la barra de herramientas del workspace
- Label: "Escala de color: Relativa (percentil) / Absoluta (min-max)"
- Tamaño pequeño, no protagonista — es una preferencia técnica, no el flujo principal
**Dónde aplica el heatmap:**
- Nodos del canvas BPMN (overlay de color en actividades)
- Tabla del reporte web (fondo de celdas de costo)
- PDF: usa los mismos valores calculados pero sin el toggle (renderiza el modo activo al exportar)
**Recalibrar E2E de píxeles:** los tests que verifican colores del canvas BPMN necesitan actualizar sus umbrales/valores esperados para la nueva escala. Identificar los specs afectados y corregirlos.
**Criterios:**
- [ ] El gradiente muestra transiciones suaves (no saltos abruptos de verde a rojo)
- [ ] Modo percentil: actividades distribuidas a lo largo de la escala incluso con outliers
- [ ] Modo min-max: outlier extremo domina la escala (comportamiento esperado)
- [ ] Toggle funciona y persiste entre sesiones
- [ ] PDF exporta con el modo activo al momento de la exportación
- [ ] Tests E2E de heatmap recalibrados y verdes
- [ ] Tests Vitest verdes (nueva función `intensityToColor` con tests unitarios)
- [ ] Validación visual del director
---
### Etapa 4 — Ver procesos por recurso + campo empresa en perfil
Dos features pequeñas en una etapa.
**Feature A — Ver procesos asociados a un recurso:**
En `ResourceCatalogPage` (`/recursos`), cada fila de la tabla debe mostrar cuántos procesos usan ese recurso.
- Nueva columna "Procesos" en la tabla: muestra un badge con el número (ej: `3 procesos`)
- Si es 0: muestra "—" con color muted
- Si es > 0: el badge es clickeable y expande una lista de los procesos (nombre + link directo al workspace)
- La expansión puede ser inline (fila expandible) o un popover/tooltip al hover
- Dato: query a `activity_resource_assignments` agrupado por `resource_id`, con JOIN a `processes` para el nombre
- Esto complementa la protección de borrado existente — ahora el usuario ve *qué* proceso usa el recurso antes de intentar eliminarlo
**Feature B — Campo empresa en perfil de usuario:**
- Nueva columna `company text` en tabla `users` de Supabase (nullable, sin default)
- Migración: `ALTER TABLE users ADD COLUMN IF NOT EXISTS company text;`
- UI: en el dropdown del AppHeader (donde aparece nombre y email), agregar una línea con la empresa si está definida
- Edición: agregar un botón "Editar perfil" en el dropdown que abre un Sheet con campos editables: nombre y empresa (email es de solo lectura — viene de Google OAuth)
- La empresa se muestra también en la tabla de la biblioteca si hay espacio (opcional, decidir con validación visual)
**Criterios:**
- [ ] Columna "Procesos" visible en `/recursos` con conteo correcto
- [ ] Click en el badge con conteo > 0 muestra la lista de procesos con links funcionales
- [ ] Columna `company` creada en Supabase sin romper datos existentes
- [ ] El dropdown del AppHeader muestra la empresa si está definida
- [ ] Sheet de edición de perfil guarda nombre y empresa en Supabase
- [ ] Tests Vitest verdes
- [ ] Validación visual del director
---
### Etapa 5 — Columna "Eficiencia de automatización"
**Contexto:** la tabla de comparación (ComparisonTable) hoy muestra costo AS-IS, costo TO-BE, ahorro y probabilidad. Le falta una métrica que permita *rankear* actividades por valor de automatización — no solo quién ahorra más en valor absoluto, sino qué actividades ofrecen el mejor retorno por peso invertido.
**Métrica:**
```
eficiencia = (ahorro$ × probabilidad) / tiempo_ejecucion_minutos
```
Alternativa si no hay inversión por actividad: usar `ahorro$ × probabilidad` como proxy simple (ahorro esperado ponderado). Decidir en implementación cuál es más interpretable para un consultor.
**Alcance:**
- Nueva columna "Eficiencia" en `ComparisonTable` del reporte web
- Valor = ahorro esperado ponderado por probabilidad de automatización
- Ordenar la tabla por esta columna por defecto (mayor eficiencia primero) — con posibilidad de cambiar el orden haciendo click en el header
- Versión wow: barra de progreso inline de ancho relativo (la actividad más eficiente = 100%, las demás proporcionales). Mismo estilo que las barras del heatmap
- Tooltip en el header de la columna explicando la fórmula
- Si `isAutomatable = false`: mostrar "—" (em dash), igual que columna Ahorro
**En PDF:**
- Incluir la columna en la tabla de comparación del PDF si entra sin truncamiento
- Si no entra, omitirla (el PDF ya es denso) — decidir en validación visual
**Criterios:**
- [ ] Columna visible en el reporte web con valores correctos
- [ ] Actividades no automatizables muestran "—"
- [ ] Orden por defecto: mayor eficiencia primero
- [ ] Click en header reordena la tabla
- [ ] Barra de progreso proporcional visible
- [ ] Tooltip en header con explicación de la fórmula
- [ ] Cálculo tiene tests unitarios en Vitest
- [ ] Validación visual del director
---
### Etapa 6 — Limpieza técnica
**6a — E2E: reescribir specs Dexie → Supabase**
- `export-pdf.spec.ts` y `validate-dod-etapa4.spec.ts` inyectan datos vía Dexie/IndexedDB, que fue eliminado en Sprint 4
- Reescribir fixtures para usar el test user `martin.bernal@inquality.com.py` via Supabase
- Limpiar datos de test después de cada corrida (usar `afterAll` con `service_role_key`)
**6b — E2E: fix flakiness "Guardar cambios"**
- 3 specs fallan intermitentemente por timing: Playwright no encuentra el botón en estado habilitado
- Fix: `await page.waitForSelector('[data-testid="save-btn"]:not([disabled])')` o equivalente
- Agregar `data-testid="save-btn"` al botón en `ActivityPanel.tsx` si no existe
**6c — Limpieza de BACKLOG y documentación**
- Marcar como `[RESOLVED]` en `BACKLOG.md`: "Indicador de simulación desactualizada", "Edición inline de proceso/cliente", "Controles de zoom BPMN" (resueltos en Sprints 2/3 y Etapa 2 de este sprint)
- Actualizar `STRATEGIC_DIRECTION.md` con cierre de Sprint 4 (pendiente desde sesión anterior)
- Vaciar `OBSERVATIONS.md` y preparar encabezado para Sprint 5
- Marcar como `[RESOLVED]` en `TECH_DEBT.md` los ítems resueltos en esta etapa
**Criterios:**
- [ ] `export-pdf.spec.ts` y `validate-dod-etapa4.spec.ts` verdes en CI con Supabase real
- [ ] 3 specs flaky del botón "Guardar" estables
- [ ] `npm run test` — mantener o superar conteo anterior de Vitest
- [ ] `npx playwright test` — 0 specs permanentemente rojos
- [ ] BACKLOG, TECH_DEBT, STRATEGIC_DIRECTION y OBSERVATIONS actualizados
- [ ] Validación del director sobre el estado de los archivos
---
## Distribución del sprint
| Área | Etapas | % |
|---|---|---|
| Features de producto / UX | 1, 2, 3, 4, 5 | ~83% |
| Infraestructura / deuda técnica | 6 | ~17% |
| PDF | (toca en Etapa 3 y 5, sin etapa dedicada) | — |
Dentro del rango válido del rebalanceo (60-70% web, 10-15% infra). PDF sin etapa dedicada — aceptable este sprint dado que Sprint 4 no tocó PDF.
---
## Qué NO entra en Sprint 5
- Historial de simulaciones / múltiples simulaciones por proceso
- Implementación real de `setShared()` (diferida hasta primer guest)
- Keycloak / migración de auth
- Modo offline / PWA
- i18n
- Export Excel
- Gateway OR inclusivo
---
## Definition of Done del sprint
- [ ] 6 etapas con OK formal del director (validación visual de cada una)
- [ ] `npm run build` limpio
- [ ] `npm run lint` sin warnings
- [ ] Tests Vitest: mantener o superar conteo de Sprint 4
- [ ] Tests E2E: 0 specs permanentemente rojos
- [ ] CHECKLIST_ENTREGA.md ejecutado antes de cada entrega de etapa
- [ ] OBSERVATIONS.md con hallazgos del sprint al cierre
- [ ] STRATEGIC_DIRECTION.md actualizado con Sprint 4 y 5

View File

@@ -0,0 +1,192 @@
# Sprint 5 — Etapa 1: Fix crítico workspace loading
## Contexto
InQ ROI — React 18 + Vite + TypeScript + TanStack Router + Zustand + shadcn/ui + Tailwind + Supabase.
Sprint 4 completado. La app tiene auth (Google OAuth + Supabase), PostgreSQL, RLS, catálogo de recursos global.
**Bug en producción:** al navegar desde la biblioteca (`/`) a un proceso (`/workspace/:processId`), la pantalla del workspace queda cargando indefinidamente. Solo carga correctamente después de un refresh manual de la página.
---
## Causa raíz — ya diagnosticada, no requiere investigación adicional
El diagnóstico es preciso. No es necesario "investigar el flujo" — el problema está localizado.
**`src/store/process-store.ts`** llama a `supabase.auth.getSession()` directamente como barrera de sesión antes del fetch. Esta llamada no tiene timeout: si Supabase tarda en responder (token refresh en curso, red lenta, lock interno), `getSession()` se bloquea indefinidamente → `loadProcess` nunca resuelve → `isLoading` queda `true` para siempre.
**`src/store/library-store.ts`** hace lo mismo pero usa `getSessionSafe()` — una función en `src/lib/supabase.ts` que hace race entre `getSession()` y un timeout de 4 segundos, garantizando que siempre resuelve. Por eso la biblioteca carga y el workspace no.
**La inconsistencia es sistémica:** el mismo patrón puede existir en otros stores. Esta etapa lo audita y corrige en todos.
---
## Parte 1 — Fix en process-store.ts
En `src/store/process-store.ts`, localizar la llamada a `supabase.auth.getSession()` (usada como barrera antes del query principal) y reemplazarla por `getSessionSafe()`:
```typescript
// Antes:
await supabase.auth.getSession()
// Después:
await getSessionSafe()
```
Agregar el import si no está:
```typescript
import { supabase, getSessionSafe } from '@/lib/supabase'
```
Verificar que `getSessionSafe` ya existe en `src/lib/supabase.ts`. Si por alguna razón no existe, crearla siguiendo el mismo patrón que se usa en `library-store.ts`.
---
## Parte 2 — Auditar todos los stores
Buscar en `src/store/` y `src/persistence/` cualquier archivo que llame a `supabase.auth.getSession()` directamente (sin pasar por `getSessionSafe`). Reportar qué archivos tienen este patrón.
Para **cada archivo** que lo use:
- Si es una barrera de sesión antes de un query → reemplazar por `getSessionSafe()`
- Si es otro uso (ej: verificar si hay sesión activa para tomar una decisión, no como barrera) → evaluar caso por caso y documentar la decisión
---
## Parte 3 — Estado de error en el workspace
**Problema adicional:** actualmente, si `getSessionSafe()` agota su timeout, `loadProcess` lanza un error, pero el workspace no tiene un estado `error` propio. El resultado es que `isLoading` puede quedar `true` o la pantalla queda vacía sin mensaje al usuario.
**Fix requerido:** en `process-store.ts` (y el hook/componente que lo consume), agregar manejo de estado de error:
```typescript
// En el store, el estado debe incluir:
{
isLoading: boolean
error: string | null // 🆕
process: Process | null
}
// En loadProcess, al catch:
set({ isLoading: false, error: 'No se pudo cargar el proceso. Revisá tu conexión.' })
```
**En WorkspacePage.tsx** (o el componente raíz del workspace), manejar el estado `error`:
```tsx
if (error) {
return (
<div className="flex flex-col items-center justify-center h-full gap-4">
<p className="text-sm text-muted-foreground">{error}</p>
<Button variant="outline" onClick={() => loadProcess(processId)}>
Reintentar
</Button>
</div>
)
}
```
No usar `alert()` ni logs crudos. El error debe ser visible, accionable, y en español.
---
## Parte 4 — Skeleton screen en el workspace
**Estado actual:** el workspace muestra un spinner genérico mientras carga. En una app moderna esto es insuficiente — el usuario no sabe qué está cargando ni cuánto ocupa la pantalla.
**Requerimiento:** reemplazar el spinner de carga del workspace por un skeleton screen que preserve el layout de la página.
El workspace tiene una estructura aproximada de: barra superior + panel BPMN (zona grande central/izquierda) + panel lateral (ActivityPanel/ResourcesPanel).
Crear un componente `WorkspaceSkeleton` que muestre:
- Header con altura igual al real (sin contenido)
- Zona del canvas BPMN: rectángulo con `animate-pulse` en gris claro, misma altura aproximada
- Panel lateral: 3-4 filas de skeleton (títulos y texto en gris)
Usar `shadcn/ui Skeleton` si ya está disponible en el proyecto, o implementar con Tailwind `animate-pulse`:
```tsx
<div className="animate-pulse bg-muted rounded-md h-full w-full" />
```
El skeleton se muestra cuando `isLoading === true` y no hay `process` todavía. Una vez que `process` está disponible, el skeleton desaparece y el workspace real se monta.
---
## Parte 5 — React Error Boundary en el workspace
Una app de nueva generación no deja que un error de renderizado en el workspace blankee la pantalla completa. Agregar un Error Boundary al nivel del workspace.
Si ya existe un `ErrorBoundary` en el proyecto (`src/components/ErrorBoundary.tsx` o similar), usarlo. Si no existe, crear uno mínimo:
```tsx
// src/components/ErrorBoundary.tsx
import { Component, ReactNode } from 'react'
interface Props { children: ReactNode; fallback?: ReactNode }
interface State { hasError: boolean; error: Error | null }
export class ErrorBoundary extends Component<Props, State> {
state: State = { hasError: false, error: null }
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error }
}
render() {
if (this.state.hasError) {
return this.props.fallback ?? (
<div className="flex flex-col items-center justify-center h-full gap-2 p-8">
<p className="text-sm font-medium text-destructive">Algo salió mal al cargar el workspace.</p>
<p className="text-xs text-muted-foreground">{this.state.error?.message}</p>
<button
className="text-xs underline text-muted-foreground"
onClick={() => window.location.reload()}
>
Recargar la página
</button>
</div>
)
}
return this.props.children
}
}
```
Envolver `WorkspacePage` (o el contenido del workspace) con `<ErrorBoundary>` en el router o en `App.tsx`.
---
## Qué NO entra en esta etapa
- Cambios en el diseño visual del workspace (paneles, BPMN canvas, ActivityPanel)
- Cambios en la lógica de simulación
- Cambios en recursos, reporte, o PDF
- Nuevas features de producto
- Cambios en auth (login, logout, OAuth)
---
## Orden de ejecución
1. Abrir `src/store/process-store.ts` — reemplazar `getSession()` por `getSessionSafe()`
2. Grep en `src/store/` y `src/persistence/` para `getSession()` — auditar y corregir
3. Agregar campo `error` al store y manejo en `WorkspacePage`
4. Crear `WorkspaceSkeleton` y usarlo mientras `isLoading === true`
5. Crear `ErrorBoundary` (si no existe) y envolver el workspace
6. `npm run build` — 0 errores TypeScript
7. `npm run lint` — 0 warnings en archivos tocados
8. `npm run test` — mantener conteo anterior de tests Vitest (sin regresiones)
9. Commit: `fix(sprint-5/etapa-1): workspace loading — getSessionSafe + error state + skeleton`
---
## Criterios de validación
- [ ] Navegar desde la biblioteca a cualquier proceso carga el workspace en el primer intento (sin refresh)
- [ ] Si el proceso no carga en ~5s, se muestra mensaje de error con botón "Reintentar"
- [ ] El estado de carga muestra un skeleton screen que preserva el layout (no spinner en blanco)
- [ ] Si hay un error de renderizado en el workspace, la app muestra el Error Boundary (no pantalla en blanco)
- [ ] Todos los stores auditados — ninguno usa `getSession()` como barrera de sesión sin timeout
- [ ] `npm run build` limpio
- [ ] `npm run test` — sin regresiones
- [ ] **Validación visual del director** antes del OK formal