From a2763ab7552e9b92ea741d1defaa83610d68d595 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcos=20Ben=C3=ADtez?= Date: Fri, 19 Jun 2026 15:31:03 -0300 Subject: [PATCH] Sprint 5. Etapa 1. --- BACKLOG.md | 26 +- OBSERVATIONS.md | 24 +- TECH_DEBT.md | 12 + .../InQ ROI - Simulador de procesos/CLAUDE.md | 31 ++- sprints/sprint-5/BRIEF.md | 249 ++++++++++++++++++ sprints/sprint-5/ETAPA_1_PROMPT.md | 192 ++++++++++++++ src/auth/SupabaseAuthService.ts | 4 + src/components/ErrorBoundary.tsx | 30 +++ src/features/report/useReportData.ts | 6 +- src/features/workspace/WorkspacePage.tsx | 60 +++-- src/features/workspace/WorkspaceSkeleton.tsx | 45 ++++ src/router.tsx | 4 +- src/store/process-store.ts | 9 +- 13 files changed, 612 insertions(+), 80 deletions(-) create mode 100644 sprints/sprint-5/BRIEF.md create mode 100644 sprints/sprint-5/ETAPA_1_PROMPT.md create mode 100644 src/components/ErrorBoundary.tsx create mode 100644 src/features/workspace/WorkspaceSkeleton.tsx diff --git a/BACKLOG.md b/BACKLOG.md index d0f326d..960e8b6 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -20,8 +20,14 @@ Al cerrar un sprint, los ítems `[SPRINT-N]` se promocionan a `[SPRINT-(N+1)]` o ## Features de alta prioridad -### [BACKLOG] Indicador de simulación desactualizada -Cuando el usuario guarda cambios en una actividad (costos, recursos, automatización) pero no vuelve a simular, el reporte muestra resultados del estado anterior sin ningún aviso. Agregar un indicador sutil ("Hay cambios sin simular" o badge en el botón Simular) que desaparezca al ejecutar la simulación. Detectado en uso real durante Sprint 2 — genera confusión al no ver los nuevos costos reflejados en el reporte. +### [BACKLOG] Ver procesos asociados a un recurso + navegar a ellos +Desde el catálogo de recursos (`/recursos`), cada recurso debería mostrar cuántos procesos lo usan y permitir navegar a esos procesos directamente. Detectado en validación visual Sprint 4 Etapa 8. Valor: evitar eliminar accidentalmente recursos en uso (hoy hay protección, pero sin visibilidad de qué proceso los usa) y facilitar auditoría del catálogo. + +### [BACKLOG] Campo "empresa" en perfil de usuario +El perfil del usuario hoy muestra nombre y email. Agregar campo `company` o `organization` en la tabla `users` para mostrar a qué empresa pertenece el consultor. Útil cuando haya múltiples consultores de distintas organizaciones (guests). Detectado en validación visual Sprint 4 Etapa 8. + +### [RESOLVED] Indicador de simulación desactualizada +Implementado en Sprint 3. Confirmado funcionando en validación visual Sprint 4 (muestra "Hay cambios sin simular" junto al botón Simular). Cerrado Sprint 5 planning (2026-06-19). ### [BACKLOG] Múltiples simulaciones por proceso Historial de simulaciones con comparación de escenarios. @@ -29,8 +35,8 @@ Historial de simulaciones con comparación de escenarios. ### [BACKLOG] Recursos con costo por hora Link actividad ↔ recurso (rol, persona, sistema) con cálculo automático del costo agregado. -### [BACKLOG] Edición del nombre de proceso y cliente desde el workspace -Hoy requiere ir a configuración global. Mover edición inline al workspace. +### [RESOLVED] Edición del nombre de proceso y cliente desde el workspace +Implementado en Sprint 2. Confirmado funcionando en validación visual Sprint 4. Cerrado Sprint 5 planning (2026-06-19). ### [BACKLOG] Gateway OR inclusivo nativo Actualmente se simula como XOR. Implementar propagación OR real con probabilidades independientes. @@ -57,8 +63,8 @@ Manifest + service worker para uso offline. ### [BACKLOG] Zoom/pan del heatmap desde el reporte Hoy es visualización estática. Permitir interacción. -### [BACKLOG] Controles de zoom en canvas BPMN (fit-to-screen / reset zoom) -El canvas puede quedar muy zoomed out sin forma de volver al estado inicial. bpmn-js expone `canvas.zoom('fit-viewport')` — agregar botón "Encuadrar diagrama" o equivalente. Detectado en validación visual Sprint 2 Etapa 3. +### [SPRINT-5] Controles de zoom en canvas BPMN (fit-to-screen / zoom-in / zoom-out) +Fit-viewport implementado en Sprint 3 pero con bug: clip al borde superior sin centering. Sprint 5 Etapa 2 corrige el bug y agrega controles de zoom-in/zoom-out explícitos. ### [BACKLOG] Detalle de recursos por actividad en el PDF Tabla de costos por recurso asignado dentro de cada actividad. @@ -66,15 +72,15 @@ Tabla de costos por recurso asignado dentro de cada actividad. ### [BACKLOG] Importación por URL Cargar BPMN desde enlace directo (GitHub, Confluence, etc.). -### [BACKLOG] Columna "Eficiencia de automatización" en ComparisonTable -Métrica = `(ahorro$ × probabilidad) / inversión-prorrateada`. Permite rankear actividades por "qué tan valiosa es automatizar cada una". +### [SPRINT-5] Columna "Eficiencia de automatización" en ComparisonTable +Métrica = `ahorro$ × probabilidad` (ahorro esperado ponderado). Permite rankear actividades por valor de automatización. Ver Sprint 5 Etapa 5. --- ## Features Fase 2 (versión 2.0 del producto) -### [SPRINT-4] Login y multi-tenant -Persistencia en la nube, colaboración entre múltiples usuarios. Promovido a Sprint 4 — ver `sprints/sprint-4/BRIEF.md`. Implementación: Supabase + Google Workspace OAuth, PostgreSQL, modelo de privacidad private/team. +### [RESOLVED] Login y multi-tenant +Implementado en Sprint 4. Supabase + Google Workspace OAuth, PostgreSQL, RLS, catálogo de recursos global, identidad en UI. Ver `sprints/sprint-4/BRIEF.md`. Cerrado 2026-05-xx. ### [FASE-2] Versionado de procesos Historial de cambios con diff visual. diff --git a/OBSERVATIONS.md b/OBSERVATIONS.md index 1670fac..4bd0953 100644 --- a/OBSERVATIONS.md +++ b/OBSERVATIONS.md @@ -1,9 +1,9 @@ -# OBSERVATIONS — Sprint 4 +# OBSERVATIONS — Sprint 5 > Hallazgos descubiertos durante el sprint actual. > Este archivo se vacía al cerrar el sprint: los hallazgos se promocionan a TECH_DEBT.md o BACKLOG.md, o quedan resueltos. > -> **Sprint 2 cerrado el 2026-05-20.** Ver cierre en `methodology/CASE_STUDIES/inq-roi-sprint-2.md`. +> **Sprint 4 cerrado el 2026-06-18.** Observaciones archivadas — ítems migrados a BACKLOG.md y TECH_DEBT.md. --- @@ -18,29 +18,13 @@ ## Hallazgos activos -### [DEFERRED-BACKLOG] Compartir proceso — implementación real diferida hasta primer guest - -**Etapa:** 5 (UI placeholder), decisión confirmada post-Etapa 7B -**Estado actual:** `setShared()` en `process-repo.ts` es no-op con comentario explicativo. El botón "Compartir con equipo" en LibraryPage muestra toast de confirmación pero no escribe en `process_access`. Todos los usuarios actuales son `platform_admin` y ven todo por bypass de RLS — el sharing no tiene efecto observable hoy. -**Schema listo:** tabla `process_access (process_id, grantee_type, grantee_id, permission)` + `user_groups` + `group_memberships` + RLS policies que ya usan `process_access` para usuarios no-admin. -**Trigger para implementar:** cuando se agregue el primer usuario `guest` (cliente externo o consultor externo) a la plataforma. -**Pendiente al implementar:** (1) UX de selección — ¿compartir con usuario específico, grupo, o "todo el equipo"? (2) implementación real de `setShared()` con escritura en `process_access`, (3) UI de gestión de grupos. +*(vacío al inicio del sprint)* --- ## Hallazgos resueltos durante el sprint -### [RESOLVED] Web Locks API deadlock en supabase-js v2 — biblioteca colgaba indefinidamente - -**Etapa:** 5B (deploy Dokploy) -**Síntoma:** Biblioteca mostraba 0 procesos. Network tab sin llamadas a Supabase después de la primera carga. Import con timeout de 20s. Aparentemente aleatorio — "la primera vez funciona, después no". -**Diagnóstico inicial (asesor):** Race condition entre inicialización de sesión async y fetch de procesos. Correcto en dirección pero incompleto en causa raíz. -**Causa raíz real (Claude Code):** `supabase-js` v2 usa `navigator.locks` (Web Locks API) para coordinar refresh de sesión entre pestañas. Un lock atascado de una pestaña cerrada abruptamente bloqueaba para siempre cualquier llamada futura a `getSession()` o queries. El cliente quedaba en espera indefinida del lock — sin error, sin timeout. -**Fix aplicado:** -- `src/lib/supabase.ts`: cambiar a `processLock` (coordinación en memoria, sin Web Locks API) -- Timeouts con error visible y botón "Reintentar" como red de contención -**Lección para el método:** Síntomas de race condition (funciona la primera vez, no las siguientes) pueden esconder problemas de locks de sistema. Si el network tab no muestra ninguna llamada cuando debería haberlas, sospechar bloqueo a nivel de cliente antes del request, no solo timing de React state. -**Estado:** RESOLVED — Etapas 5 y 5B con OK formal del director (2026-06-18) +*(vacío)* --- diff --git a/TECH_DEBT.md b/TECH_DEBT.md index 18f6d28..5a55a62 100644 --- a/TECH_DEBT.md +++ b/TECH_DEBT.md @@ -47,6 +47,18 @@ en los 3 PDFs. Si hay truncamiento, evaluar: ## Deuda media — afecta DX o evolución del producto +### [BACKLOG] E2E specs con Dexie directo — reescribir para Supabase +**Estado actual:** `export-pdf.spec.ts` y `validate-dod-etapa4.spec.ts` inyectan datos vía Dexie/IndexedDB directamente. Dexie fue eliminado en Sprint 4 Etapa 4 — estos specs fallan en cualquier entorno donde no exista la DB local del sprint anterior. +**Impacto:** 2 specs permanentemente rojos. No bloquean build ni deploy, pero degradan la señal de la suite E2E. +**Solución propuesta:** reescribir los fixtures de datos para insertar vía Supabase (usando el test user `martin.bernal@inquality.com.py` y el `service_role_key` en el setup de Playwright). Limpiar datos de test después de cada corrida. +**Costo:** medio. Requiere diseño cuidadoso de fixtures para no interferir con datos de producción. + +### [BACKLOG] E2E flakiness — "Guardar cambios" button timing en ActivityPanel +**Estado actual:** 3 specs de PDF fallan intermitentemente porque Playwright no encuentra el botón "Guardar cambios" en estado habilitado. Validación manual confirma que el botón SÍ funciona correctamente — es un issue de timing en el test, no un bug real. +**Causa probable:** el test hace click en la actividad y espera el botón sin suficiente guard sobre el estado inicial de `isDirty`. +**Solución propuesta:** agregar `await page.waitForSelector('[data-testid="save-btn"]:not([disabled])')` o un `data-testid` explícito que permita espera determinística. +**Costo:** bajo. Una línea por spec afectado. + ### [INVESTIGATING] Dexie live queries (sincronización store ↔ db) **Estado actual:** la invalidación del resultado de simulación se garantiza vía ESLint rule (`no-restricted-imports`) + convención: todas las mutaciones pasan por `useProcessStore`. diff --git a/cowork/InQ ROI - Simulador de procesos/CLAUDE.md b/cowork/InQ ROI - Simulador de procesos/CLAUDE.md index faaf831..38c58f1 100644 --- a/cowork/InQ ROI - Simulador de procesos/CLAUDE.md +++ b/cowork/InQ ROI - Simulador de procesos/CLAUDE.md @@ -9,11 +9,13 @@ ## Estado actual del proyecto -**Sprint:** 4 (planificado — BRIEF listo en `sprints/sprint-4/BRIEF.md`) +**Sprint:** 5 (en curso — BRIEF listo en `sprints/sprint-5/BRIEF.md`) **Fase:** 1 — ROI y escenarios de automatización -**Deploy activo:** https://process-cost-platform.pages.dev +**Deploy activo:** https://inq-roi.inqualityhq.com (Dokploy) · https://process-cost-platform.pages.dev **Usuario objetivo actual:** equipo de InQuality — uso interno en talleres y con clientes en curso +**Sprint 4 cerrado el 2026-06-18.** Entregó: auth Supabase + Google Workspace OAuth, migración IndexedDB→PostgreSQL, RLS con `is_platform_admin()`, catálogo global de recursos (sin `processId`), identidad en UI (avatar/dropdown AppHeader, "Actualizado por"), ResourceCatalogPage + ResourceFormSheet, fix Web Locks API deadlock (`processLock`), fix `.maybeSingle()` en simulation-repo, fix `formatCostPerHour()` precisión dinámica, fix Google OAuth `prompt: 'select_account'`, catálogo con contexto de proceso (banner + query param `fromProcess`), batería E2E Sprint 4 + `docs/CHECKLIST_ENTREGA.md`. ~550+ tests. + **Sprint 3 cerrado el 2026-05-27.** Entregó: biblioteca de procesos con grupos/tags/búsqueda (Dexie v4), indicador stale de simulación, zoom BPMN fit-viewport, recursos TO-BE en escenario automatizado (motor + UI + reporte + PDF), branding InQ ROI en todas las páginas (AppHeader + favicon + logos), kit de socialización del equipo (guía de usuario, guía de pre-venta, presentación PPTX). 561 tests. **Sprint 2 cerrado el 2026-05-20.** Entregó: modelo de recursos `utilizationMinutes + units` (migración Dexie v3), ActivityPanel "wow" con cálculo en tiempo real, reporte web con filas expandibles de desglose de recursos, edición inline de proceso/cliente, tabla de recursos en PDF (página condicional), 548 tests. Ver case study: `simulador-web/methodology/CASE_STUDIES/inq-roi-sprint-2.md`. @@ -22,19 +24,22 @@ --- -## Sprint 4 — dirección estratégica +## Sprint 5 — dirección estratégica -Refactorización arquitectural completa. Sin features de producto nuevas. +UX polish + quick wins. Retorno al rebalanceo normal (60-70% web). Sin features de gran escala. -- **Auth:** Supabase + Google Workspace OAuth (`@inquality.com.py`). Abstracción `AuthService` para migración futura a Keycloak. -- **Base de datos:** reemplazo de IndexedDB/Dexie por PostgreSQL via Supabase. Datos existentes abandonados (eran solo pruebas). -- **Recursos globales:** catálogo compartido del equipo, sin `processId`. Rompe modelo actual — requiere mockup aprobado antes de Etapa 7. -- **Privacidad:** procesos con visibilidad `private | team`. Recursos, grupos y tags siempre compartidos. Simulaciones heredan del proceso. -- **Sin auditoría:** diferida a sprint dedicado. -- **Online-only:** aceptado. Offline en roadmap futuro. -- **Keycloak:** diferido. InQuality tiene VPS desa + prod. Keycloak será el auth server de la plataforma InQuality (multi-producto). +- **Fix crítico:** workspace loading — se queda cargando sin refresh +- **Fix + mejora:** zoom BPMN centering + controles zoom-in/zoom-out +- **Heatmap:** gradiente continuo (percentil default, toggle min-max) +- **Catálogo:** procesos por recurso + campo empresa en perfil +- **Reporte:** columna eficiencia de automatización en ComparisonTable +- **Deuda técnica:** E2E Dexie→Supabase + flakiness "Guardar" -Ver `sprints/sprint-4/BRIEF.md` para scope completo. +Ver `sprints/sprint-5/BRIEF.md` para scope completo. + +## Sprint 4 — dirección estratégica (cerrado) + +Refactorización arquitectural completa. Sin features de producto nuevas. Ver `sprints/sprint-4/BRIEF.md`. --- @@ -77,7 +82,7 @@ Historial de simulaciones y comparación de escenarios diferido a Sprint 3. - Stack tecnológico (React + Vite + TypeScript + stack definido en `simulador-web/CLAUDE.md`) - Modelo de escenarios: Modelo A (un BPMN, dos pares de campos por actividad) - ROI: cálculos nominales simples sin VPN ni Monte Carlo -- Heatmap: colores saturados `#10B981 / #F59E0B / #EF4444` (revisar Sprint 3-4) +- Heatmap: gradiente continuo 4 stops (#10b981→#f59e0b→#f97316→#ef4444), percentil default, toggle min-max (Sprint 5 Etapa 3) --- diff --git a/sprints/sprint-5/BRIEF.md b/sprints/sprint-5/BRIEF.md new file mode 100644 index 0000000..6d57cd6 --- /dev/null +++ b/sprints/sprint-5/BRIEF.md @@ -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 diff --git a/sprints/sprint-5/ETAPA_1_PROMPT.md b/sprints/sprint-5/ETAPA_1_PROMPT.md new file mode 100644 index 0000000..d445f6f --- /dev/null +++ b/sprints/sprint-5/ETAPA_1_PROMPT.md @@ -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 ( +
+

{error}

+ +
+ ) +} +``` + +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 +
+``` + +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 { + state: State = { hasError: false, error: null } + + static getDerivedStateFromError(error: Error): State { + return { hasError: true, error } + } + + render() { + if (this.state.hasError) { + return this.props.fallback ?? ( +
+

Algo salió mal al cargar el workspace.

+

{this.state.error?.message}

+ +
+ ) + } + return this.props.children + } +} +``` + +Envolver `WorkspacePage` (o el contenido del workspace) con `` 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 diff --git a/src/auth/SupabaseAuthService.ts b/src/auth/SupabaseAuthService.ts index 2ac358f..1c125c2 100644 --- a/src/auth/SupabaseAuthService.ts +++ b/src/auth/SupabaseAuthService.ts @@ -20,6 +20,10 @@ export class SupabaseAuthService implements AuthService { if (error) throw error } + // Auditoría Sprint 5 Etapa 1: este getSession() NO se reemplaza por getSessionSafe() + // porque necesita el objeto session en sí (no es una barrera descartable) y getCurrentUser() + // no tiene ningún caller en la app hoy (AuthContext usa onAuthStateChange). Cambios de auth + // están fuera de scope de esta etapa — se documenta la decisión, no se toca el comportamiento. async getCurrentUser(): Promise { const { data: { session } } = await supabase.auth.getSession() if (!session?.user) return null diff --git a/src/components/ErrorBoundary.tsx b/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000..b71ad09 --- /dev/null +++ b/src/components/ErrorBoundary.tsx @@ -0,0 +1,30 @@ +import { Component, type ReactNode } from 'react' + +interface Props { children: ReactNode; fallback?: ReactNode } +interface State { hasError: boolean; error: Error | null } + +export class ErrorBoundary extends Component { + state: State = { hasError: false, error: null } + + static getDerivedStateFromError(error: Error): State { + return { hasError: true, error } + } + + render() { + if (this.state.hasError) { + return this.props.fallback ?? ( +
+

Algo salió mal al cargar el workspace.

+

{this.state.error?.message}

+ +
+ ) + } + return this.props.children + } +} diff --git a/src/features/report/useReportData.ts b/src/features/report/useReportData.ts index 8f6ba0e..a499c2a 100644 --- a/src/features/report/useReportData.ts +++ b/src/features/report/useReportData.ts @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react' -import { supabase } from '@/lib/supabase' +import { getSessionSafe } from '@/lib/supabase' import { supabaseActivityRepo } from '@/persistence/supabase/activity-repo' import { supabaseResourceRepo } from '@/persistence/supabase/resource-repo' import { supabaseProcessRepo } from '@/persistence/supabase/process-repo' @@ -30,8 +30,8 @@ export function useReportData(processId: string): ReportData { setIsLoading(true) setError(null) try { - // Barrera única: garantiza sesión restaurada antes del fan-out en paralelo - await supabase.auth.getSession() + // Barrera única con timeout: garantiza sesión restaurada antes del fan-out en paralelo + await getSessionSafe() const [proc, sim, acts, res] = await Promise.all([ supabaseProcessRepo.getById(processId), supabaseSimulationRepo.getLatestByProcess(processId), diff --git a/src/features/workspace/WorkspacePage.tsx b/src/features/workspace/WorkspacePage.tsx index 0d1fae3..d16e667 100644 --- a/src/features/workspace/WorkspacePage.tsx +++ b/src/features/workspace/WorkspacePage.tsx @@ -9,18 +9,19 @@ import { Button } from '@/components/ui/button' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Tooltip, TooltipContent, TooltipTrigger, TooltipProvider } from '@/components/ui/tooltip' import { Separator } from '@/components/ui/separator' -import { useToast } from '@/components/ui/use-toast' import { BpmnCanvas, type BpmnElementCategory } from './BpmnCanvas' import { ActivityPanel } from './ActivityPanel' import { GatewayPanel, isXorSumValid } from './GatewayPanel' import { ResourcesPanel } from './ResourcesPanel' import { GlobalSettingsPanel } from './GlobalSettingsPanel' +import { WorkspaceSkeleton } from './WorkspaceSkeleton' import { useProcessStore } from '@/store/process-store' import { useSimulationStore } from '@/store/simulation-store' import { useSimulate } from '../simulation/useSimulate' import { useLibraryStore } from '@/store/library-store' import { useAuth } from '@/auth/AuthContext' import { AppHeader } from '@/components/AppHeader' +import { ErrorBoundary } from '@/components/ErrorBoundary' type SelectedCategory = 'activity' | 'gateway' | null @@ -30,7 +31,6 @@ export function WorkspacePage() { const { currentProcess, activities, isLoading, error, loadProcess, gateways, updateProcess } = useProcessStore() const { isRunning, resultActual, error: simError, selectActivity, loadLatestForProcess, lastPersistedExecutedAt } = useSimulationStore() const { simulate } = useSimulate() - const { toast } = useToast() const { user } = useAuth() const [sidebarOpen, setSidebarOpen] = useState(true) @@ -50,31 +50,16 @@ export function WorkspacePage() { const [showTagSuggestions, setShowTagSuggestions] = useState(false) const getAllTags = useLibraryStore((state) => state.getAllTags) - // Usamos un ref para saber si loadProcess ya terminó al menos una vez. - // Esto evita que el redirect se dispare en el render inicial donde - // isLoading=false y currentProcess=null (antes del primer efecto). - const hasLoadedOnce = useRef(false) - useEffect(() => { loadProcess(processId) }, [processId, loadProcess]) - // Cargar el último executedAt persistido para comparar contra updatedAt (indicador stale) + // Cargar el último executedAt persistido para comparar contra updatedAt (indicador stale). + // Depende de currentProcess?.id (no del objeto) a propósito: evita re-disparar el efecto + // en cada edición de campos del proceso, solo cuando cambia de proceso. useEffect(() => { if (currentProcess) loadLatestForProcess(currentProcess.id) + // eslint-disable-next-line react-hooks/exhaustive-deps }, [currentProcess?.id, loadLatestForProcess]) - useEffect(() => { - if (isLoading) { hasLoadedOnce.current = true; return } - if (!hasLoadedOnce.current) return // carga no iniciada aún - if ((error || !currentProcess) && processId) { - toast({ - title: 'Proceso no encontrado', - description: 'El proceso que buscás no existe o fue eliminado.', - variant: 'destructive', - }) - navigate({ to: '/' }) - } - }, [isLoading, error, currentProcess, processId, toast, navigate]) - // Validación XOR: todos los gateways exclusivos deben sumar 1.0 const xorValidation = useMemo(() => { const invalidIds = gateways @@ -166,16 +151,27 @@ export function WorkspacePage() { - if (isLoading) { - return ( -
- -
- ) + if (isLoading && !currentProcess) { + return } if (error || !currentProcess) { - return null // el useEffect ya inició el redirect con toast + return ( +
+ +

+ {error ?? 'No se pudo cargar el proceso. Revisá tu conexión.'} +

+
+ + +
+
+ ) } // Label del tab "elemento" según lo seleccionado @@ -483,3 +479,11 @@ export function WorkspacePage() { ) } + +export function WorkspacePageWithBoundary() { + return ( + + + + ) +} diff --git a/src/features/workspace/WorkspaceSkeleton.tsx b/src/features/workspace/WorkspaceSkeleton.tsx new file mode 100644 index 0000000..d53c311 --- /dev/null +++ b/src/features/workspace/WorkspaceSkeleton.tsx @@ -0,0 +1,45 @@ +import { AppHeader } from '@/components/AppHeader' + +// Preserva el layout real del workspace (topbar + canvas + panel lateral) mientras +// loadProcess() está en curso, en vez de un spinner genérico sobre pantalla en blanco. +export function WorkspaceSkeleton() { + return ( +
+ + + {/* Topbar */} +
+
+
+
+
+
+
+
+
+ + {/* Main */} +
+ {/* Canvas BPMN */} +
+
+
+ + {/* Panel lateral */} +
+
+
+
+
+
+ {[1, 2, 3, 4].map((i) => ( +
+
+
+
+ ))} +
+
+
+ ) +} diff --git a/src/router.tsx b/src/router.tsx index 28c993c..7f61034 100644 --- a/src/router.tsx +++ b/src/router.tsx @@ -1,6 +1,6 @@ import { lazy, Suspense, useEffect } from 'react' import { createRouter, createRootRoute, createRoute, Outlet, useNavigate, useRouterState } from '@tanstack/react-router' -import { WorkspacePage } from '@/features/workspace/WorkspacePage' +import { WorkspacePageWithBoundary } from '@/features/workspace/WorkspacePage' import { ImportPage } from '@/features/import/ImportPage' import { LibraryPage } from '@/features/library/LibraryPage' import { SettingsPage } from '@/features/settings/SettingsPage' @@ -80,7 +80,7 @@ const importRoute = createRoute({ const workspaceRoute = createRoute({ getParentRoute: () => rootRoute, path: '/workspace/$processId', - component: WorkspacePage, + component: WorkspacePageWithBoundary, }) const reportRoute = createRoute({ diff --git a/src/store/process-store.ts b/src/store/process-store.ts index ff4b9e1..67fef81 100644 --- a/src/store/process-store.ts +++ b/src/store/process-store.ts @@ -1,5 +1,5 @@ import { create } from 'zustand' -import { supabase } from '@/lib/supabase' +import { getSessionSafe } from '@/lib/supabase' import { supabaseProcessRepo } from '@/persistence/supabase/process-repo' import { supabaseActivityRepo } from '@/persistence/supabase/activity-repo' import { supabaseGatewayRepo } from '@/persistence/supabase/gateway-repo' @@ -36,8 +36,9 @@ export const useProcessStore = create((set, get) => ({ loadProcess: async (processId) => { set({ isLoading: true, error: null }) try { - // Barrera única: garantiza sesión restaurada antes del fan-out en paralelo - await supabase.auth.getSession() + // Barrera única con timeout: garantiza sesión restaurada antes del fan-out en paralelo + // sin bloquear isLoading indefinidamente si el cliente Supabase se cuelga (ver Sprint 4 Etapa 5B) + await getSessionSafe() const [process, activities, gateways, resources] = await Promise.all([ supabaseProcessRepo.getById(processId), supabaseActivityRepo.getByProcess(processId), @@ -53,7 +54,7 @@ export const useProcessStore = create((set, get) => ({ setCurrentProcess: async (process, userId) => { await supabaseProcessRepo.save(process, userId) - await supabase.auth.getSession() + await getSessionSafe() const [activities, gateways, resources] = await Promise.all([ supabaseActivityRepo.getByProcess(process.id), supabaseGatewayRepo.getByProcess(process.id),