Files
inq-roi-simulador-web/sprints/sprint-5/ETAPA_3_PROMPT.md
Marcos Benítez a65bec45c3 feat(sprint-5/etapa-3): heatmap gradiente 4 stops + modo percentil + localStorage + fix zoom
- colors.ts: gradiente continuo verde→amarillo→naranja→rojo (interpolación N-stops genérica)
- Renombra modos: relative→percentile (rank-based), absolute→minmax
- Hook useHeatmapMode(): persiste selección en localStorage con clave inq-roi:heatmap-mode
- HeatmapLegend: barra de 4 stops, labels P0/P50/P100 en modo percentil
- HeatmapModeToggle: etiquetas "Percentil" / "Min-max"
- pdf-sections.ts: leyenda PDF con 4 stops interpolados
- BpmnCanvas: controles de zoom movidos a esquina inferior izquierda (evita logo bpmn.io)
- Tests: 528 unitarios pasan; E2E recalibrados con vecino más cercano (distancia euclidiana)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 18:28:10 -03:00

9.7 KiB

Sprint 5 — Etapa 3: Heatmap gradiente continuo + fix posición zoom

Contexto

InQ ROI — React 18 + Vite + TypeScript + TanStack Router + Zustand + shadcn/ui + Tailwind + Supabase. Etapas 1 y 2 completadas con OK formal. Esta etapa tiene un fix carry-over de Etapa 2 y la mejora principal del heatmap.


Fix 0 — Posición controles de zoom (carry-over Etapa 2)

En BpmnCanvas.tsx, los controles de zoom-in/zoom-out quedaron en la esquina inferior derecha (bottom: 12, right: 12), solapando el logo bpmn.io que bpmn-js renderiza en esa esquina por licencia.

Fix: cambiar la posición del grupo de controles de zoom a la esquina inferior izquierda.

// Antes:
style={{ position: 'absolute', bottom: 12, right: 12, ... }}

// Después:
style={{ position: 'absolute', bottom: 12, left: 12, ... }}

El botón de encuadre (fit-viewport, esquina superior derecha) no se mueve — solo el grupo zoom-in/zoom-out.


Mejora principal — Heatmap gradiente con percentil

Contexto del sistema actual

El sistema de heatmap ya existe y está centralizado en src/lib/colors.ts. Tiene:

  • Dos modos: 'relative' (normalización por máximo de porcentaje) y 'absolute' (min-max sobre costo)
  • Gradiente de 3 stops: verde #10b981 → ámbar #f59e0b → rojo #ef4444
  • Función central activityColor() usada por todos los consumidores
  • Compuerta de varianza: si varianza < 5%, todos reciben color neutro #cbd5e1
  • El toggle de modo ya existe en ReportPage.tsx como estado local (no persistente)

El PDF captura el canvas coloreado con html2canvas, pero tiene una leyenda hardcodeada con los mismos 3 stops en src/lib/export/pdf-sections.ts (función drawHeatmapLegend()). Hay que mantenerla sincronizada.

Cambio 1 — Cuarto stop en el gradiente (naranja)

Modificar colors.ts para usar 4 stops en lugar de 3:

0.00 → #10b981  (verde)
0.33 → #f59e0b  (amarillo)
0.67 → #f97316  (naranja)  ← nuevo
1.00 → #ef4444  (rojo vivo)

La función de interpolación actual usa lerp sobre 3 stops. Extenderla para que soporte N stops genéricos:

function interpolateColor(t: number, stops: [number, string][]): string {
  // t está clampeado a [0, 1] antes de entrar
  const clamped = Math.max(0, Math.min(1, t))
  
  for (let i = 0; i < stops.length - 1; i++) {
    const [t0, c0] = stops[i]
    const [t1, c1] = stops[i + 1]
    if (clamped <= t1) {
      const localT = (clamped - t0) / (t1 - t0)
      return lerpColor(c0, c1, localT)  // interpolación RGB lineal
    }
  }
  return stops[stops.length - 1][1]
}

const HEATMAP_STOPS: [number, string][] = [
  [0.00, '#10b981'],
  [0.33, '#f59e0b'],
  [0.67, '#f97316'],
  [1.00, '#ef4444'],
]

Mantener NEUTRAL_HEATMAP_COLOR = '#cbd5e1' para el caso de varianza nula.

Cambio 2 — Modo 'percentile' reemplaza 'relative'

Renombrar 'relative''percentile' en el tipo HeatmapMode. Actualizar todos los usos (buscar con grep).

Implementar normalización por percentil real en computeActivityT():

function percentileNorm(
  current: ActivityWithCost,
  all: ActivityWithCost[]
): number {
  const n = all.length
  if (n <= 1) return 0.5
  
  const currentCost = current.expectedTotalCost
  // Rango: cantidad de actividades con costo estrictamente menor
  const rank = all.filter(a => a.expectedTotalCost < currentCost).length
  
  // Normalizar a [0, 1]: 0 = la más barata, 1 = la más cara
  // Ties reciben el mismo rango (densidad uniforme)
  return rank / (n - 1)
}

Edge cases obligatorios:

  • n === 1: retornar 0.5 (actividad única → color intermedio)
  • Todas iguales (max === min o todos los ranks son 0): la compuerta de varianza ya maneja esto con #cbd5e1 — verificar que siga funcionando con percentil
  • expectedTotalCost < 0: clampear t a 0 (costo negativo = "más barato posible")

Modo 'minmax' (renombrado de 'absolute'): mantener la lógica existente, solo renombrar el string.

Cambio 3 — Persistencia del modo en localStorage

El toggle hoy es estado local en ReportPage.tsx — se pierde al navegar o recargar.

Crear un hook useHeatmapMode() en src/lib/colors.ts o en src/lib/hooks/useHeatmapMode.ts:

const STORAGE_KEY = 'inq-roi:heatmap-mode'
const DEFAULT_MODE: HeatmapMode = 'percentile'

export function useHeatmapMode(): [HeatmapMode, (mode: HeatmapMode) => void] {
  const [mode, setModeState] = useState<HeatmapMode>(() => {
    try {
      const stored = localStorage.getItem(STORAGE_KEY)
      // Validar que el valor guardado sea un modo válido
      return (stored === 'percentile' || stored === 'minmax') ? stored : DEFAULT_MODE
    } catch {
      return DEFAULT_MODE
    }
  })

  const setMode = (newMode: HeatmapMode) => {
    try {
      localStorage.setItem(STORAGE_KEY, newMode)
    } catch {
      // localStorage no disponible — continuar sin persistir
    }
    setModeState(newMode)
  }

  return [mode, setMode]
}

Reemplazar el useState local en ReportPage.tsx por useHeatmapMode(). Si el heatmap también se controla desde el workspace (canvas BPMN), usar el mismo hook allí.

Nota: el valor guardado en localStorage debe validarse antes de usarse (el hook ya lo hace con el check de stored === 'percentile' || stored === 'minmax'). Nunca confiar en strings crudos de localStorage sin validación.

Cambio 4 — Actualizar leyenda del PDF

En src/lib/export/pdf-sections.ts, la función drawHeatmapLegend() tiene los colores hardcodeados en RGB con 3 stops. Actualizar a 4 stops para mantener sincronía visual con la web:

// Reemplazar los stops hardcodeados por los mismos 4:
const heatStops = [
  { t: 0.00, r: 16,  g: 185, b: 129 },  // #10b981 verde
  { t: 0.33, r: 245, g: 158, b: 11  },  // #f59e0b amarillo
  { t: 0.67, r: 249, g: 115, b: 22  },  // #f97316 naranja
  { t: 1.00, r: 239, g: 68,  b: 68  },  // #ef4444 rojo
]

La lógica de 40 segmentos puede mantenerse — solo actualizan los puntos de referencia.

El PDF siempre usa el modo activo al momento de exportar (lo que está en localStorage). No hace falta cambiar la lógica de exportación — el canvas ya está coloreado antes de que html2canvas lo capture.

Cambio 5 — Toggle en UI

El toggle ya existe en ReportPage.tsx. Solo actualizar:

  • Strings del label: 'relative''percentile', 'absolute''minmax'
  • Texto visible: "Relativo" → "Percentil", "Absoluto" → "Min-max"
  • Conectar al hook useHeatmapMode() en lugar del estado local

Si el toggle también existe o debe existir en el workspace (para ver el heatmap en el canvas), agregar el mismo toggle allí usando el mismo hook.


Tests obligatorios

Tests unitarios en Vitest — colors.ts

Agregar o actualizar tests para:

// Normalización percentil
test('percentile: actividad más barata → t = 0', ...)
test('percentile: actividad más cara → t = 1', ...)
test('percentile: actividades con mismo costo → mismo t', ...)
test('percentile: n=1 → t = 0.5', ...)

// Interpolación de color
test('intensityToColor(0) → #10b981', ...)
test('intensityToColor(1) → #ef4444', ...)
test('intensityToColor(0.5) → color naranja intermedio (entre #f59e0b y #f97316)', ...)
test('intensityToColor(-0.1) → mismo que intensityToColor(0)  [clamp]', ...)
test('intensityToColor(1.5) → mismo que intensityToColor(1)  [clamp]', ...)

// Compuerta de varianza
test('todas las actividades con mismo costo → color neutro #cbd5e1', ...)

Tests E2E — recalibrar specs de píxeles

Buscar en tests/e2e/ los specs que verifican colores del canvas BPMN (probablemente etapa-*.spec.ts o heatmap*.spec.ts). Actualizarlos para los nuevos valores de color del gradiente de 4 stops.

Si un spec verifica que "la actividad más costosa es roja" — sigue siendo válido. Si verifica un color RGB exacto con los 3 stops anteriores — actualizar al nuevo gradiente.


Qué NO entra en esta etapa

  • Cambios en el motor de simulación o cálculos de ROI
  • Cambios en el esquema de Supabase
  • Nuevas columnas en tablas o paneles
  • Exportación a Excel
  • Cualquier feature fuera del heatmap y el fix de posición zoom

Orden de ejecución

  1. Fix 0 — mover controles zoom a left: 12 en BpmnCanvas.tsx
  2. Agregar cuarto stop naranja al gradiente en colors.ts
  3. Implementar percentileNorm() y renombrar modos en colors.ts
  4. Crear useHeatmapMode() hook con persistencia localStorage
  5. Actualizar ReportPage.tsx para usar useHeatmapMode() (y workspace si aplica)
  6. Actualizar drawHeatmapLegend() en pdf-sections.ts con 4 stops
  7. Escribir/actualizar tests unitarios en Vitest para las funciones de color
  8. Buscar y recalibrar specs E2E que verifiquen colores del heatmap
  9. npm run build — 0 errores TypeScript
  10. npm run lint — 0 warnings nuevos
  11. npm run test — 521+ Vitest verdes
  12. npx playwright test — sin nuevos rojos
  13. Commit: feat(sprint-5/etapa-3): heatmap gradiente 4 stops + percentil + localStorage + fix zoom position

Criterios de validación

  • Controles de zoom en esquina inferior izquierda — sin solapamiento con logo bpmn.io
  • El heatmap del canvas BPMN muestra transiciones suaves (verde → amarillo → naranja → rojo)
  • Con outlier extremo en modo percentil: las actividades restantes tienen colores variados (no todas verde)
  • Con outlier extremo en modo min-max: las actividades restantes se comprimen hacia el verde
  • Toggle "Percentil / Min-max" visible en el reporte — cambia el heatmap en tiempo real
  • El modo persiste entre recargas (verificar con localStorage en DevTools)
  • PDF: la leyenda del heatmap muestra el gradiente de 4 stops
  • Tests unitarios de colors.ts todos verdes
  • E2E specs de heatmap recalibrados y verdes
  • Validación visual del director antes del OK formal