# Changelog

Historial de cambios del proyecto. Formato basado en [Keep a Changelog](https://keepachangelog.com/es/).
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 1–4 completas; Etapas 5–6 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
