# Sprint 3 — Etapa 4: Indicador stale + zoom BPMN

## Contexto

Sos Claude Code trabajando en **InQ ROI**. Las reglas de implementación están en `simulador-web/CLAUDE.md`. El brief del sprint está en `sprints/sprint-3/BRIEF.md`.

**Estado al iniciar esta etapa:**
- Etapas 1-3 completadas. Biblioteca de procesos operativa. Tags y búsqueda funcionando. `SettingsPage` disponible.
- `process-store.ts` expone: `updateActivity`, `updateGateway`, `updateResource`, `addResource`, `deleteResource`, `updateProcess`. Ninguna de estas acciones actualiza `process.updatedAt` excepto `updateProcess`.
- `simulation-store.ts` tiene `lastSimulatedAt: Date | null` (in-memory, se pierde al recargar) y `persistResults` (guarda en IndexedDB con `executedAt: Date.now()`).
- `BpmnCanvas.tsx`: viewer bpmn-js puro, sin toolbar, retorna un `<div>` que es el contenedor del canvas. Ya llama `canvas.zoom('fit-viewport', 'auto')` al importar el XML.

**Esta etapa agrega dos features independientes:**
1. **Indicador stale** — aviso persistente cuando el proceso tiene cambios sin simular.
2. **Botón fit-viewport** — encuadrar el diagrama BPMN en cualquier momento.

---

## Objetivo

**D.1 — Indicador de simulación desactualizada**

Cuando el usuario modifica actividades, recursos o gateways sin volver a simular, el workspace muestra un indicador visible y persistente (sobrevive a recargas de página).

**D.2 — Botón fit-viewport en BpmnCanvas**

Un botón flotante sobre el canvas que ejecuta `canvas.zoom('fit-viewport')`. Detectado en validación Sprint 2 como necesidad real en talleres con diagramas grandes.

---

## Arquitectura — leer antes de implementar

### Dependencias entre stores (no crear dependencias circulares)

```
process-store.ts
  ↑ importado por
simulation-store.ts  (excepción documentada en el archivo)
```

Para evitar dependencia circular, **`process-store.ts` NO debe importar `simulation-store.ts`**. La coordinación entre stores la hace `WorkspacePage`.

### Flujo correcto para el indicador stale

1. `process-store.ts` — bump `process.updatedAt` en mutaciones de actividades/gateways/recursos.
2. `simulation-store.ts` — agregar `lastPersistedExecutedAt: number | null` (cargado desde IndexedDB, sobrevive recargas) y la acción `loadLatestForProcess(processId)`.
3. `WorkspacePage` — orquesta: llama `loadLatestForProcess` cuando el proceso está cargado, y computa `isStale` comparando los dos valores.

---

## Cambios autorizados y arquitectura

### `src/store/process-store.ts`

Agregar bump de `process.updatedAt` en todas las acciones que mutan datos del proceso sin ser `updateProcess`:

```typescript
// Patrón a aplicar en: updateActivity, updateGateway, updateResource, addResource, deleteResource
// (updateProcess ya actualiza updatedAt correctamente — no modificar)

// Ejemplo — updateActivity (aplicar el mismo patrón a las demás):
updateActivity: async (activity) => {
  await activityRepo.save(activity)
  const current = get().currentProcess
  if (current) {
    const updatedProcess = { ...current, updatedAt: Date.now() }
    await processRepo.save(updatedProcess)
    set((state) => ({
      activities: state.activities.map((a) => (a.id === activity.id ? activity : a)),
      currentProcess: updatedProcess,
    }))
  } else {
    set((state) => ({
      activities: state.activities.map((a) => (a.id === activity.id ? activity : a)),
    }))
  }
},
```

**Nota:** la suscripción en `simulation-store.ts` ya llama `invalidateResults()` cuando `currentProcess` cambia. Esto es correcto y esperado — no modificar esa suscripción.

### `src/store/simulation-store.ts`

Agregar campo y acción para la comparación persistente:

```typescript
interface SimulationState {
  // ... campos existentes ...
  lastPersistedExecutedAt: number | null  // 🆕 executedAt de la última sim guardada en IndexedDB
  loadLatestForProcess: (processId: string) => Promise<void>  // 🆕
}
```

Implementación:

```typescript
lastPersistedExecutedAt: null,

loadLatestForProcess: async (processId) => {
  const sim = await simulationRepo.getLatestByProcess(processId)
  set({ lastPersistedExecutedAt: sim?.executedAt ?? null })
},
```

Actualizar también `persistResults` para mantener el campo sincronizado:

```typescript
persistResults: async (processId, actual, automated) => {
  const executedAt = Date.now()
  await simulationRepo.save({
    id: uuidv4(),
    processId,
    executedAt,
    result: actual,
    resultAutomated: automated,
  })
  set({
    resultActual: actual,
    resultAutomated: automated,
    lastSimulatedAt: new Date(),
    lastPersistedExecutedAt: executedAt,  // 🆕
    error: null,
  })
},
```

Y limpiar en `reset`:
```typescript
reset: () => set({ ..., lastPersistedExecutedAt: null })
```

### `src/features/workspace/WorkspacePage.tsx`

**1 — Orquestación (nuevo `useEffect`)**

Después de que el proceso se carga, cargar el último `executedAt` persistido:

```typescript
const { loadLatestForProcess, lastPersistedExecutedAt } = useSimulationStore()

useEffect(() => {
  if (currentProcess) {
    loadLatestForProcess(currentProcess.id)
  }
}, [currentProcess?.id, loadLatestForProcess])
```

**2 — Cómputo del indicador**

```typescript
const isStale = Boolean(
  currentProcess &&
  currentProcess.updatedAt > (lastPersistedExecutedAt ?? 0)
)
```

**3 — UI del indicador**

Mostrar un badge sutil **junto al botón Simular** cuando `isStale === true`. El botón Simular ya existe en el header del workspace.

Diseño del indicador:
- Chip naranja claro: fondo `#fff3e6`, borde `#F59845`, texto `#7a3e00`, ícono `AlertCircle` (Lucide, `h-3 w-3`).
- Texto: `"Hay cambios sin simular"`.
- Ubicación: inline junto al botón Simular, o como fila adicional debajo del header — a criterio del agente según el layout actual (Nivel 1).
- Visible solo cuando `isStale === true`. Desaparece al completar la simulación (porque `persistResults` actualiza `lastPersistedExecutedAt`).

### `src/features/workspace/BpmnCanvas.tsx`

Agregar botón flotante de fit-viewport. El canvas actualmente retorna:

```tsx
return <div ref={containerRef} className={className} style={{ width: '100%', height: '100%' }} />
```

Cambiar a wrapper con botón superpuesto:

```tsx
return (
  <div className={className} style={{ position: 'relative', width: '100%', height: '100%' }}>
    <div ref={containerRef} style={{ width: '100%', height: '100%' }} />
    <button
      onClick={() => {
        const canvas = viewerRef.current?.get('canvas') as any
        canvas?.zoom('fit-viewport')
      }}
      title="Encuadrar diagrama"
      style={{
        position: 'absolute',
        bottom: 12,
        right: 12,
        width: 32,
        height: 32,
        borderRadius: 6,
        background: 'hsl(var(--background))',
        border: '1px solid hsl(var(--border))',
        display: 'flex',
        alignItems: 'center',
        justifyContent: 'center',
        cursor: 'pointer',
        boxShadow: '0 1px 3px rgba(0,0,0,0.1)',
        zIndex: 10,
      }}
    >
      <Maximize2 className="h-4 w-4 text-muted-foreground" />
    </button>
  </div>
)
```

Importar `Maximize2` desde `lucide-react`.

---

## Versión con wow (recomendada si costo < 40% del mínimo)

**Banner en ReportPage:**

En `src/features/report/ReportPage.tsx`, si el proceso tiene cambios sin simular, mostrar un banner sutil en la parte superior del reporte:

```
⚠️  Los resultados pueden estar desactualizados — hay cambios sin simular.
    [Ir al workspace →]
```

Diseño: fondo `#fffbeb`, borde `#f59e0b`, texto `#92400e` (amber). Full-width, 40px de alto máximo. Link "Ir al workspace →" navega a `/workspace/$processId`.

`ReportPage` ya usa `useProcessStore` para leer `currentProcess`. Necesita también `useSimulationStore().lastPersistedExecutedAt` para el cálculo de `isStale`.

---

## ❌ Lo que NO entra en esta etapa

- Cambios en `ActivityPanel.tsx`, `ResourcesPanel.tsx`, `GatewayPanel.tsx` — no tocar
- `automatedAssignedResources` — sigue siendo opcional, no promover (Etapa 5)
- Cambios en el motor de simulación (`domain/simulation.ts`)
- Export PDF/CSV
- Biblioteca o library-store
- Tags, settings

---

## Verificación antes de entregar

```bash
# 1. Build limpio
npm run build

# 2. Tests (todos los previos deben seguir verdes)
npm run test

# 3. Control de scope
git diff --name-only HEAD
```

El diff debe mostrar únicamente:
- `src/store/process-store.ts`
- `src/store/simulation-store.ts`
- `src/features/workspace/WorkspacePage.tsx`
- `src/features/workspace/BpmnCanvas.tsx`
- `src/features/report/ReportPage.tsx` (solo si se implementó wow)

Si aparece cualquier otro archivo de `src/features/` que no sea workspace/ o report/: revertir y reportar.

### Verificación funcional mandatoria

Antes de reportar, verificar manualmente:

1. **Stale persiste en recarga:** modificar una actividad → recargar página → el indicador sigue visible → simular → desaparece.
2. **Sin sim previa:** proceso recién importado sin simular nunca → indicador visible (ya que `updatedAt > 0 > null ?? 0`).
3. **Fit-viewport:** abrir un diagrama complejo → click en el botón → el canvas se encuadra.
4. **Sin falsos positivos:** proceso recién simulado sin cambios posteriores → indicador ausente.

---

## Criterios de aceptación

- [ ] Modificar actividad/recurso/gateway actualiza `process.updatedAt` en IndexedDB.
- [ ] Indicador "Hay cambios sin simular" aparece en WorkspacePage al detectar `isStale`.
- [ ] Indicador desaparece al simular exitosamente.
- [ ] Indicador persiste al recargar la página (comparación contra IndexedDB, no solo memoria).
- [ ] Proceso sin historial de simulación → indicador visible.
- [ ] Botón fit-viewport en BpmnCanvas encuadra el diagrama correctamente.
- [ ] Build sin errores TypeScript.
- [ ] Todos los tests previos siguen verdes.
- [ ] Validación visual del director aprobada.

---

## Reporte esperado al terminar

1. Resultado de `git diff --name-only HEAD`.
2. Resultado de `npm run build` y `npm run test`.
3. Confirmación de la verificación funcional (4 casos).
4. Si se implementó el banner en ReportPage (wow): declararlo.
5. Cualquier decisión de iniciativa tomada (Nivel 1/2/3).
6. Si hubo archivo fuera del scope: declararlo con justificación.

---

## Referencias

- `sprints/sprint-3/BRIEF.md` — sección 4, Feature D
- `src/store/process-store.ts` — acciones a modificar: `updateActivity`, `updateGateway`, `updateResource`, `addResource`, `deleteResource`
- `src/store/simulation-store.ts` — estado actual, suscripción a process-store
- `src/features/workspace/BpmnCanvas.tsx` — canvas actual, sin toolbar
- `methodology/ANTI_PATTERNS.md` — AP.8 (adelanto silencioso)
- `methodology/PATTERNS.md` — C.7 (git diff como control preventivo)
