Files
inq-roi-simulador-web/CHANGELOG.md

16 KiB
Raw Blame History

Changelog

Historial de cambios del proyecto. Formato basado en Keep a Changelog. Los cambios se describen desde la perspectiva del código: qué archivos cambiaron, por qué, y qué decisiones de diseño están implícitas — para que futuras modificaciones profundas tengan contexto suficiente.


[Unreleased] — Sprint 1: ROI y Escenarios de Automatización

Estado

En construcción. Etapas 14 completas; Etapas 56 pendientes.

Etapa 1 — Cimientos del dominio (completada)

Nuevos campos en el modelo de dominio (src/domain/types.ts):

  • Activity gana: automatable: boolean, automatedCostFixed: number, automatedTimeMinutes: number
  • Process gana: annualFrequency: number, analysisHorizonYears: number, automationInvestment: number
  • Nuevo tipo Scenario = 'actual' | 'automated'
  • SimulationInput acepta scenario?: Scenario (opcional, default 'actual' para retrocompatibilidad)
  • Simulation gana resultAutomated?: SimulationResult (opcional, para retrocompatibilidad con snapshots viejos)

Migración Dexie v1 → v2 (src/persistence/db.ts):

  • version(2).upgrade() popula defaults en registros existentes:
    • Activities: automatable=false, automatedCostFixed=0, automatedTimeMinutes=0
    • Processes: annualFrequency=1000, analysisHorizonYears=3, automationInvestment=0
  • Sin cambio de índices — solo nuevos campos con defaults seguros.

Nuevo módulo de ROI (src/domain/roi.ts):

  • Función pura calculateRoi(input: RoiInput): RoiResult
  • Fórmulas nominales simples (sin VPN ni TIR — decisión de diseño ADR-014)
  • Edge cases explicitados: inversión cero, ahorro negativo, costo actual cero (evitar div/0)
  • paybackMonths = Infinity cuando no hay ahorro, roiAnnualPercent = Infinity cuando inversión=0 y ahorro>0

Motor de simulación dual (src/domain/simulation.ts):

  • Acepta scenario en SimulationInput
  • En 'automated': actividades con automatable=true usan automatedCostFixed/automatedTimeMinutes
  • Las no-automatable mantienen sus costos originales en ambos escenarios
  • La probabilidad de ejecución NO cambia entre escenarios — solo costos y tiempos

Simulation store refactorizado (src/store/simulation-store.ts):

  • result (single) → resultActual + resultAutomated + lastSimulatedAt
  • Nueva acción persistResults(processId, actual, automated): guarda en Dexie + actualiza memoria en un solo paso atómico
  • Nueva acción invalidateResults(): pone ambos a null
  • Suscripción reactiva a process-store: cualquier cambio en activities o currentProcess invalida ambos resultados automáticamente
  • Dependencia intencional documentada: simulation-store importa process-store en una dirección (sin inversa → sin ciclo)

useSimulate refactorizado (src/features/simulation/useSimulate.ts):

  • Corre las dos simulaciones en el mismo ciclo y llama persistResults del store (eliminando el import directo de simulationRepo desde un hook de feature)
  • Retorna { resultActual, resultAutomated } en lugar de un único result

Infraestructura ESLint (eslint.config.js — archivo nuevo):

  • Primer config de ESLint del proyecto (antes no existía)
  • Regla no-restricted-imports: prohíbe importar persistence/repositories desde fuera de store/
    • Overrides para store/**, features/import/**, features/report/useReportData.ts, tests/**
  • Regla react-hooks/set-state-in-effect: 'off': patrón de sync local state con useEffect es válido en este codebase
  • Por qué importa: si alguien llama activityRepo.save() directamente desde una feature sin pasar por el store, los resultados de simulación quedan stale (la suscripción reactiva no dispara)

Tests nuevos (32 tests):

  • tests/domain/roi.test.ts: 16 casos cubriendo happy path, inversión cero, ahorro negativo, NaN
  • tests/integration/dexie-migration.test.ts: 6 casos de persistencia de los nuevos campos con valores default y no-default
  • tests/store/simulation-store-invalidation.test.ts: 7 casos de la invariante de invalidación dual
  • tests/integration/sprint1-flow.test.ts: 11 casos del flujo integrado BPMN → sim actual → sim automated → calculateRoi → KPIs
  • tests/domain/simulation.test.ts: +4 casos de scenario=automated con costos en cero, sin NaN, probabilidad invariante
  • tests/integration/bpmn-collaboration.test.ts: 3 casos para BPMNs de colaboración con múltiples pools

Archivos de test actualizados (fixtures con nuevos campos):

  • tests/integration/simulation-engine.test.ts
  • tests/integration/gateway-validation.test.ts
  • tests/integration/indexeddb-persistence.test.ts
  • tests/features/report/components.test.tsx
  • tests/features/report/derivations.test.ts
  • tests/lib/export/measure-sizes.test.ts
  • tests/lib/export/csv-export.test.ts
  • tests/lib/export/pdf-export.test.ts
  • tests/setup.ts: mocks globales ResizeObserver y PointerEvent para jsdom (necesario desde Sprint 1 para tests con Slider de Radix)

Etapa 2 — UI de Configuración (completada)

Switch component (src/components/ui/switch.tsx — nuevo):

  • Implementación Tailwind pura sin @radix-ui/react-switch
  • role="switch" + aria-checked + aria-label para accesibilidad
  • Transiciones CSS: transition-colors duration-200 en el fondo, transition-transform duration-200 en el knob
  • Decisión: no instalar @radix-ui/react-switch para no agregar dependencia — el comportamiento visual es idéntico

ActivityPanel ampliado (src/features/workspace/ActivityPanel.tsx):

  • Nueva sección "Automatización" al pie del panel, separada con <Separator>
  • Toggle Switch (automatable) oculta/muestra los campos con transition-all duration-200 overflow-hidden max-h-{0|80}
    • Limitación conocida: el max-height trick no anima a la altura real del contenido. Ver TODO.md para mejora con useRef + scrollHeight
  • Inputs numéricos automatedCostFixed y automatedTimeMinutes con mismo patrón que los existentes (placeholder cuando valor=0)
  • Helper ámbar de transparencia metodológica:
    • Aparece cuando automatable=true && cost===0 && time===0
    • Siempre está en el DOM (no conditional render) — la visibilidad se controla con opacity-0/100 + max-h-0/20 + transition-all duration-150
    • aria-hidden refleja el estado de visibilidad
    • Por qué importa: con conditional render (&&) el helper desaparece instantáneamente al tipear — sin transición. Al mantenerlo en el DOM, el fade de 150ms se aplica correctamente

GlobalSettingsPanel ampliado (src/features/workspace/GlobalSettingsPanel.tsx):

  • Nueva sección "Análisis de Impacto" al pie del panel
  • annualFrequency: input numérico, min=1, sugerencias de referencia en texto helper
  • analysisHorizonYears: Slider de 1 a 10 años con valor en texto ("3 años")
  • automationInvestment: input numérico, default placeholder vacío cuando valor=0
  • La validación de rango de analysisHorizonYears (clamp 1-10) ocurre en handleSave, no en onChange

BpmnCanvas ampliado (src/features/workspace/BpmnCanvas.tsx):

  • Nueva prop automatableElementIds?: string[]
  • syncAutomationOverlays(): callback memoizado con useCallback([automatableElementIds])
    • Limpia overlays anteriores con overlays.remove({ type: 'automation-indicator' })
    • Re-agrega un badge por cada elementId en la lista
    • Se llama después de importXML (en el .then()) y en un useEffect separado cuando cambia la lista
  • Posicionamiento del badge: { top: -8, left: element.width - 12 }
    • Obtenido via elementRegistry.get(elementId) con fallback width ?? 100
    • No usar position.right de diagram-js: con right: -8, la fórmula interna left = right * -1 + width da left = 8 + width, colocando el badge enteramente fuera del nodo sin overlap de esquina
    • Con left = width - 12: badge left está 12px antes del borde derecho, su borde derecho queda 8px fuera → overlap visible en la esquina
  • Badge HTML: círculo 20×20px, fondo #fffbeb (amber-50), borde #e2e8f0, sombra 0 1px 3px rgba(0,0,0,0.12), ícono Bot Lucide en #f59e0b como SVG inline
    • pointer-events: none para no interferir con clicks en el nodo
    • title nativo del browser como tooltip
    • Por qué #fffbeb y no #ffffff: el fondo de los nodos BPMN también es blanco, el fondo ámbar le da identidad cromática propia y hace visible el círculo por contraste

WorkspacePage (src/features/workspace/WorkspacePage.tsx):

  • Destructura activities del process-store
  • Calcula automatableElementIds con useMemo
  • Pasa useDeferredValue(automatableElementIds) al BpmnCanvas (evita cascada de re-renders al editar el panel)

AutomationScenarioNote (src/features/report/AutomationScenarioNote.tsx — nuevo):

  • Componente de leyenda para el tab "Automatizado" del reporte
  • Preparado y listo para ser colocado por Etapa 3 — no tiene lógica propia

Etapa 3 — Motor dual y reporte con tabs (completada)

ReportPage ampliada (src/features/report/ReportPage.tsx):

  • La página ahora tiene 3 tabs: Actual | Automatizado | Comparación + ROI
  • El contenido existente (KpiBar, heatmap, 3 gráficos, tabla de actividades) pasa intacto al tab "Actual"
  • Los tabs "Automatizado" y "ROI" se deshabilitan si la simulación no tiene resultAutomated (snapshot viejo) — en ese caso se muestra NeedsResimulate que guía al usuario a re-simular
  • useMemo para automatableIds y roi se declaran antes de los early returns (cumple reglas de hooks)
  • Los refs de heatmap son independientes por tab (heatmapRef / heatmapRefAuto) — el tab "Actual" sigue siendo el fuente de imagen para el export PDF
  • Export PDF/CSV sin cambios — Etapa 4 los ampliará

Tab "Automatizado":

  • Mismo layout que "Actual": KpiBar → heatmap con cross-linking → 3 gráficos → tabla
  • Usa simulation.resultAutomated como fuente de datos
  • AutomationScenarioNote aparece al pie del heatmap (componente preparado en Etapa 2)
  • Heatmap y tabla tienen su propio estado selectedIdAuto — cross-linking funciona independientemente del tab "Actual"

Tab "Comparación + ROI":

  • RoiKpiBar (src/features/report/RoiKpiBar.tsx — nuevo): 5 KPI cards en grid 2×5 (lg)
    • Ahorro por ejecución (con % sobre costo actual)
    • Ahorro anual
    • Payback (meses o años según magnitud; "Inmediato" si inversión=0; "No se recupera" si Infinity)
    • Ahorro acumulado neto a N años
    • ROI anual % ("∞%" si inversión=0 y ahorro>0)
    • Los valores positivos se muestran en text-emerald-700, negativos en text-red-600
  • ComparisonTable (src/features/report/ComparisonTable.tsx — nuevo):
    • Tabla actividad por actividad: costo actual | costo automatizado | ahorro | % ahorro
    • Ícono Bot ámbar en la primera columna para actividades marcadas automatable
    • Ordenable por ahorro (↕) — default descendente
    • Usa Set<bpmnElementId> para el lookup de automatable en O(1)
  • CumulativeSavingsChart (src/features/report/CumulativeSavingsChart.tsx — nuevo):
    • Gráfico de línea + área (ECharts): X=años 0..N, Y=ahorro neto acumulado
    • Línea de break-even en y=0 siempre visible
    • Marker vertical dashed verde en x=paybackYears (solo si finite y dentro del horizonte)
    • Tooltip muestra valor del año exacto con color según signo
  • TopSavingsChart (src/features/report/TopSavingsChart.tsx — nuevo):
    • Top 5 actividades por ahorro positivo — barras horizontales verdes
    • Excluye actividades con ahorro ≤ 0
    • Altura dinámica: max(180, n * 48) px — no hay espacio vacío si hay menos de 5 actividades
  • TransparencySection (src/features/report/TransparencySection.tsx — nuevo):
    • Condicional — solo se renderiza si hay issues metodológicos detectados
    • Issue 1: actividades con automatable=true pero automatedCostFixed=0 y automatedTimeMinutes=0 → el ahorro puede estar inflado. Lista los nombres de cada actividad
    • Issue 2: automationInvestment=0 → payback "inmediato" e ROI "infinito" no reflejan la realidad
    • Fondo amber-50, borde amber-200 — consistente con el helper del ActivityPanel

Por qué ROI inline y no en useReportData: El calculateRoi toma simulation.result.totalCost y simulation.resultAutomated.totalCost — valores ya disponibles en el componente. Moverlo al hook agregaría un useEffect extra sin beneficio. Se mantiene en el componente como useMemo.

Tests nuevos (17 tests):

  • tests/features/workspace/ActivityPanel.test.tsx: 6 casos — toggle, helper ámbar con aria-hidden, dispatch de updateActivity
  • tests/features/workspace/GlobalSettingsPanel.test.tsx: 7 casos — defaults, edición de campos, dispatch de updateProcess
  • tests/features/workspace/BpmnCanvas.test.tsx: 4 casos — overlay add/remove, posición left=width-12, HTML con #fffbeb y #f59e0b

Patrón de mocks para tests de componentes (documentado para referencia futura):

  • Los mocks del store con vi.hoisted() devuelven referencias estables
  • Sin vi.hoisted: cada llamada a useProcessStore() devuelve un nuevo objeto → el useEffect([activities]) de ActivityPanel dispara en loop infinito → "Maximum update depth exceeded"
  • simulation-store.ts importa process-store.ts al nivel de módulo: los mocks deben ser compatibles o el store no carga

[0.1.0-mvp] — MVP Fase 0 — 2026-05-13

Contexto

Primera entrega funcional. Construida en una sesión de trabajo. Objetivo: demostrar en ≤15 minutos que se puede transformar un BPMN en un reporte de costos.

Funcionalidades

  • Importar BPMN 2.0 por drag & drop o desde procesos de ejemplo
  • Renderizar diagrama en canvas read-only (bpmn-js Viewer)
  • Configurar actividades: costo fijo, tiempo, recursos asignados con utilización
  • CRUD de recursos (rol/persona/sistema/equipo/insumo + costo/hora)
  • Configuración global: moneda, overhead %, nombre proceso, cliente
  • Motor de simulación determinístico con propagación de probabilidades por gateways XOR, AND, inclusivo
  • Detección de loops (back-edges via DFS): se advierte pero no bloquea
  • Reporte visual: mapa de calor en dos modos (relativo/absoluto), KPIs, top actividades, composición, costo por recurso
  • Export PDF: 3 páginas (header+KPIs+heatmap, análisis, tabla detalle), con jsPDF-autotable
  • Export CSV: con BOM UTF-8 y metadatos del proceso
  • Persistencia en IndexedDB (Dexie v1)
  • Deploy estático en Cloudflare Pages

Decisiones de diseño relevantes para el futuro

Parser BPMN multi-pool (bpmn-parser.ts):

  • Itera todos los <bpmn:process> hijos directos de <definitions> (no solo el primero)
  • Fix crítico: el parser original usaba querySelector que retorna solo el primero → todos los pools de una colaboración excepto el primero eran ignorados
  • Pools de soporte (SAP, bandeja manual) pueden no tener StartEvent propio — la validación exige al menos uno entre todos los pools

Heatmap sobre bpmn-js (HeatmapCanvas.tsx):

  • bpmn-js rechaza la promesa de importXML pero el diagrama se renderiza parcialmente → usar eventBus.on('import.done', ...) en lugar de .then()
  • element.style.fill = color (NO setAttribute('fill', color)): bpmn-js aplica CSS que tiene mayor especificidad que atributos SVG
  • captureImage() espera 500ms después de importar para que la transición CSS fill 0.35s ease complete antes de la captura con html2canvas

Validación XOR en WorkspacePage (WorkspacePage.tsx):

  • hasLoadedOnce ref: evita que el redirect "proceso no encontrado" dispare en el render inicial donde isLoading=false y currentProcess=null (antes del primer efecto de carga)

Tests

268 tests unitarios + integración al cierre de MVP. 5 tests E2E con Playwright (import → simulate → export PDF/CSV × 3 BPMNs + acentos + gradiente de color). El test de gradiente verifica con sharp que el heatmap del PDF contiene píxeles verdes, ámbar y rojos — evita regresiones en la lógica de coloreo.


Convenciones de este changelog

  • Decisiones de diseño: se documenta el por qué, no solo el qué
  • Gotchas: comportamientos contrarios a la intuición se marcan explícitamente
  • Edge cases: si un edge case motivó un test específico, se menciona
  • Retrocompatibilidad: cuando un campo es opcional por retrocompat, se indica
  • Las entradas son para humanos que modificarán el código, no para usuarios finales