GO — Etapa 2: Helper formatPayback + aplicación Tu plan original tenía 7 it.todo listos. Esta etapa los convierte en tests reales, implementa el helper y lo aplica en 3 lugares. ═══════════════════════════════════════════════════════════════════════ PRE-TAREAS (actualizar artefactos de Etapa 1) ═══════════════════════════════════════════════════════════════════════ Antes de implementar formatPayback, hacé estas 3 actualizaciones rápidas: 1. Actualizar etapa-1-audit.md — fila de GatewayPanel.tsx: Cambiar la categoría de las 5 referencias blue-* de GatewayPanel de "Mantener azul funcional / Evaluar en Sprint 2" a: "Decisión arquitectónica: mantener azul informativo. No es color de marca, es semántica funcional (info banner, valor inmutable, patrón Bootstrap/Tailwind estándar). NO migrar." 2. Actualizar etapa-1-audit.md — agregar fila al final de la sección "Hallazgos — Hex hardcodes" para el donut chart: | chart-options.ts:123 | #3b82f6 → naranja | Composición directo/overhead | en donut. Decisión visual: el sector "directo" va en naranja InQ | #F59845. El sector "overhead" NO debe ser otro naranja (pierde | contraste) — usar slate-400 (#94A3B8) o naranja muy claro | (#FED7AA). Decisión final en Etapa 3 mirando el render. | 3. Verificar contraste WCAG de #F59845 sobre blanco. Ejecutá: node -e " const hex = '#F59845'; const r = parseInt(hex.slice(1,3), 16) / 255; const g = parseInt(hex.slice(3,5), 16) / 255; const b = parseInt(hex.slice(5,7), 16) / 255; const lin = (c) => c <= 0.03928 ? c/12.92 : Math.pow((c+0.055)/1.055, 2.4); const L1 = 0.2126*lin(r) + 0.7152*lin(g) + 0.0722*lin(b); const L2 = 1; const ratio = (L2 + 0.05) / (L1 + 0.05); console.log('Contrast ratio:', ratio.toFixed(2), ':1'); console.log('AA normal text (4.5):', ratio >= 4.5 ? 'PASS' : 'FAIL'); console.log('AA large text (3.0):', ratio >= 3.0 ? 'PASS' : 'FAIL'); " Anotá el resultado en etapa-1-audit.md como nueva sección "Accesibilidad — Contraste de marca". Si el resultado es FAIL para AA normal, agregá las 4 opciones de remediación al audit: A) Aceptar contraste sub-WCAG en CTAs (riesgo accesibilidad) B) Oscurecer naranja a #D97706 para CTAs (rompe marca parcialmente) C) Usar texto oscuro #451A03 en CTAs sobre naranja (cambia look) D) Forzar font-weight: 700 en CTAs primarios (cumple AA large) Marcá la opción D como propuesta a implementar en Etapa 3 cuando rediseñemos KPI cards y CTAs. NO modificar componentes en esta etapa. ═══════════════════════════════════════════════════════════════════════ DECISIONES YA TOMADAS (no consultar de nuevo) ═══════════════════════════════════════════════════════════════════════ Confirmadas con el usuario: Payback negativo → "Sin payback (costo aumenta)" (caso donde costo automatizado > costo actual, escenario matemáticamente posible pero indica error de configuración o automatización inviable) Texto del formato → usar caracteres compactos "<" y "~" (universales y legibles en KPI cards) Pluralización → siempre plural incluso con decimales ("1.0 meses", no "1.0 mes") ═══════════════════════════════════════════════════════════════════════ TAREA PRINCIPAL — IMPLEMENTAR formatPayback ═══════════════════════════════════════════════════════════════════════ 1. Implementar formatPayback en src/lib/format.ts: function formatPayback(months: number): string { if (Number.isNaN(months) || !isFinite(months)) { return "No recupera en horizonte"; } if (months < 0) { return "Sin payback (costo aumenta)"; } if (months === 0) { return "Inmediato"; } if (months < 0.1) { return "< 1 día"; } if (months < 1) { return `< 1 mes (${months.toFixed(2)} meses)`; } if (months < 12) { return `${months.toFixed(1)} meses`; } if (months < 24) { const years = (months / 12).toFixed(1); return `${months.toFixed(1)} meses (~${years} años)`; } return `${(months / 12).toFixed(1)} años`; } 2. Convertir los 7 it.todo existentes en tests reales: - formatPayback(0) === "Inmediato" - formatPayback(0.05) === "< 1 día" - formatPayback(0.5) === "< 1 mes (0.50 meses)" - formatPayback(6) === "6.0 meses" - formatPayback(15) === "15.0 meses (~1.3 años)" - formatPayback(30) === "2.5 años" - formatPayback(Infinity) === "No recupera en horizonte" 3. Agregar 5 tests de edge cases: - formatPayback(-1) === "Sin payback (costo aumenta)" // payback negativo: costo automatizado supera al actual - formatPayback(-100) === "Sin payback (costo aumenta)" // negativo grande: mismo comportamiento - formatPayback(NaN) === "No recupera en horizonte" // NaN cae en el primer guard - formatPayback(11.95) === "11.9 meses" // verificar que el borde con 12 no salta a "1.0 años" prematuramente - formatPayback(12.0) === "12.0 meses (~1.0 años)" // exactamente en el umbral Total: 12 tests verdes en tests/lib/format-payback.test.ts ═══════════════════════════════════════════════════════════════════════ APLICACIÓN EN 3 LUGARES ═══════════════════════════════════════════════════════════════════════ a) RoiKpiBar.tsx (web app): - Reemplazar el formato actual del KPI "PAYBACK" por formatPayback(months) - El KPI card debe mostrar el string completo - PROBLEMA POTENCIAL: strings largos como "15.0 meses (~1.3 años)" pueden desbordar la card. Soluciones: - Opción 1: reducir font-size del valor a 14px en el card de payback (solo en ese card, no en los otros KPIs) - Opción 2: usar 2 líneas — primera línea cifra principal, segunda línea texto secundario en font-size menor PROBÁ ambas y mostrame screenshot de cada una en Etapa 3. Por ahora implementá la opción 1 (más simple, mantiene grilla consistente). b) src/lib/export/pdf-sections-roi.ts → drawRoiKpiCards: - Aplicar formatPayback en la KPI card del PDF - Verificar ancho: el card actual mide ~120px en página A4. String "15.0 meses (~1.3 años)" en font 16px puede no entrar. Si es así, reducir font-size de esa card específica a 14px. - Probar con paybacks de distintos rangos: 0.5, 6, 15, 30 meses para ver el comportamiento. c) src/lib/export/csv-export.ts: - En la línea de metadata "# Payback (meses): X.XX" reemplazar el número crudo por formatPayback formateado - AGREGAR una segunda línea inmediatamente después: "# Payback (meses, raw): X.XXXX" Esto preserva compatibilidad con integraciones futuras de BI/Excel que necesiten el valor numérico crudo. d) BONUS — verificar TransparencySection: El detector de ROI inusual (Etapa 3 de Sprint 1) menciona payback en alguno de sus 4 casos. Verificá si el texto incluye payback formateado o crudo. Si es crudo, aplicar formatPayback también ahí. ═══════════════════════════════════════════════════════════════════════ ENTREGABLES ═══════════════════════════════════════════════════════════════════════ [ ] etapa-1-audit.md actualizado con 3 correcciones (GatewayPanel, donut chart, contraste WCAG) [ ] formatPayback implementado en src/lib/format.ts [ ] 12 tests verdes en tests/lib/format-payback.test.ts [ ] formatPayback aplicado en RoiKpiBar.tsx [ ] formatPayback aplicado en pdf-sections-roi.ts (drawRoiKpiCards) [ ] formatPayback aplicado en csv-export.ts (con doble línea: formateado + raw para compatibilidad) [ ] TransparencySection verificado y actualizado si aplica [ ] 443 tests previos + 12 nuevos = 455+ tests verdes [ ] npm run build limpio [ ] Screenshot del RoiKpiBar mostrando un payback de 10.6 meses (caso del PDF roi-realistic) [ ] PDF de muestra generado con setup realista del brief: - medium-with-gateways - 3 automatizables, $200/$20 costos - 5000 ejecuciones/año, 3 años, USD 80.000 inversión - Resultado esperado: payback ~10.6 meses (NO "0.0 meses") → guardar como tests/e2e/__output__/payback-formatted-sample.pdf ═══════════════════════════════════════════════════════════════════════ REPORTE FINAL A PRESENTAR ═══════════════════════════════════════════════════════════════════════ 1. Resultado del cálculo de contraste WCAG (PASS/FAIL para AA normal y AA large) 2. Confirmación de las 3 actualizaciones del audit 3. Tabla de tests con resultados (12 verdes esperados) 4. Screenshot del RoiKpiBar con payback formateado 5. PDF de muestra presentado con present_files para que el usuario abra en Preview y valide visualmente 6. Cantidad final de tests verdes 7. Cualquier desbordamiento de UI/PDF detectado y cómo se resolvió NO arranques Etapa 3 sin mi OK explícito. Go.