docs: cierre formal de Sprint 1.5

- OBSERVATIONS.md reseteado para Sprint 2
- STRATEGIC_DIRECTION.md con reflexiones del sprint
- TECH_DEBT.md curado: items resueltos archivados
- methodology/CASE_STUDIES/inq-roi-sprint-1-5.md generado

Sprint 1.5 cerrado:
- 11 etapas + 1 fix pre-Etapa 11
- 526 unit tests + 38 E2E al cierre
- Producto con identidad InQ aplicada en web + 3 PDFs
- Metodología documentada con casos reales (2 de mala
  clasificación de iniciativa, originaron AP.8 +
  refinamiento C.6)
- Material para transmisión al equipo de 5 técnicos
This commit is contained in:
2026-05-19 07:27:40 -03:00
parent 4f6c424d5c
commit 49364f4b18
20 changed files with 1448 additions and 52 deletions

View File

@@ -172,11 +172,19 @@ function _drawNarrativeBanner(
const tx = m + borderW + padH
let ty = y + padV
// Contexto (línea 1): peso 400, gris — información de fondo
doc.setFont('helvetica', 'normal')
doc.setFontSize(9)
doc.setTextColor(...PDF_COLORS.textSecondary)
doc.text(l1, tx, ty)
ty += l1.length * 4.2 + 3
ty += l1.length * 4.2 + 4
// Resultado (línea 2): peso 700, naranja oscuro — cifras y conclusión destacadas
// Refinamiento: contraste visual entre contexto y resultado
doc.setFont('helvetica', 'bold')
doc.setFontSize(9)
doc.setTextColor(...C.orangeDark)
doc.text(l2, tx, ty)
return y + boxH + 8
@@ -184,17 +192,41 @@ function _drawNarrativeBanner(
// ── 5.4 KPI Hero con gradiente ────────────────────────────────────────────────
function _createGradientDataUrl(wMm: number, hMm: number): string | null {
function _createGradientDataUrl(wMm: number, hMm: number, radiusMm = 0): string | null {
if (typeof document === 'undefined') return null
try {
const scale = 3
const w = Math.round(wMm * scale)
const h = Math.round(hMm * scale)
const r = Math.round(radiusMm * scale)
const canvas = document.createElement('canvas')
canvas.width = w
canvas.height = h
const ctx = canvas.getContext('2d')
if (!ctx) return null
// Fondo blanco opaco — las esquinas fuera del clip quedan blancas,
// lo que se ve igual que bordes redondeados sobre página blanca sin
// depender de la transparencia PNG en jsPDF.
ctx.fillStyle = '#FFFFFF'
ctx.fillRect(0, 0, w, h)
// Clip al rounded rect antes de pintar el gradiente
if (r > 0) {
ctx.beginPath()
ctx.moveTo(r, 0)
ctx.lineTo(w - r, 0)
ctx.arcTo(w, 0, w, r, r)
ctx.lineTo(w, h - r)
ctx.arcTo(w, h, w - r, h, r)
ctx.lineTo(r, h)
ctx.arcTo(0, h, 0, h - r, r)
ctx.lineTo(0, r)
ctx.arcTo(0, 0, r, 0, r)
ctx.closePath()
ctx.clip()
}
const grad = ctx.createLinearGradient(0, 0, w, h) // 135deg approx
grad.addColorStop(0, '#F59845')
grad.addColorStop(1, '#ED3E8F')
@@ -227,32 +259,28 @@ function _drawHeroCard(
): number {
const heroH = 46
// Gradiente de fondo
const dataUrl = _createGradientDataUrl(cW, heroH)
// Gradiente con esquinas redondeadas baked en el PNG via canvas clip
const dataUrl = _createGradientDataUrl(cW, heroH, 4)
if (dataUrl) {
doc.addImage(dataUrl, 'PNG', m, y, cW, heroH)
} else {
_drawGradientFallback(doc, m, y, cW, heroH)
}
// Esquinas redondeadas — simular con rect blanco en las esquinas (Nivel 1)
// jsPDF no soporta clip en addImage, así que el gradiente queda sin border-radius.
// Aceptable para esta etapa.
// Texto sobre el gradiente
const heroX = m + 14
let ty = y + 12
let ty = y + 11
// Label superior
doc.setFont('helvetica', 'bold')
doc.setFontSize(9)
doc.setTextColor(255, 255, 255)
doc.text(`AHORRO PROYECTADO A ${process.analysisHorizonYears} AÑOS`, heroX, ty)
ty += 8
ty += 11 // Refinamiento: más espacio label→cifra (era 8mm)
// Cifra principal 28pt
// Cifra principal — 26pt (Refinamiento: bajada de 28pt para mejor balance)
doc.setFont('helvetica', 'bold')
doc.setFontSize(28)
doc.setFontSize(26)
doc.setTextColor(...C.white)
doc.text(formatCurrency(roi.cumulativeSavingsHorizon, currency), heroX, ty)
ty += 9
@@ -275,8 +303,9 @@ function _drawSecondaryGrid(
doc: DocLike, roi: RoiResult, process: Process, currency: string,
m: number, cW: number, y: number
): number {
// Refinamiento: cardH aumentado de 24→28 para acomodar tipografía uniforme (9/18/9pt)
const cardW = (cW - 4) / 2
const cardH = 24
const cardH = 28
const gapX = 4, gapY = 4
const r = 2
@@ -294,7 +323,7 @@ function _drawSecondaryGrid(
{
label: 'AHORRO ANUAL',
value: formatCurrency(roi.annualSavings, currency),
delta: `× ${process.analysisHorizonYears} años`,
delta: `x ${process.analysisHorizonYears} años`,
col: 1, row: 0,
},
{
@@ -319,23 +348,23 @@ function _drawSecondaryGrid(
doc.setFillColor(...C.orangeLight)
doc.roundedRect(cx, cy, cardW, cardH, r, r, 'F')
// Label
// Label: 9pt uniforme en los 4 cards (Refinamiento: era 6.5pt variable)
doc.setFont('helvetica', 'bold')
doc.setFontSize(6.5)
doc.setFontSize(9)
doc.setTextColor(...C.orangeDarker)
doc.text(card.label, cx + 5, cy + 6)
doc.text(card.label, cx + 5, cy + 7)
// Valor
// Valor: 18pt fijo en los 4 cards (Refinamiento: era variable 9-12pt)
doc.setFont('helvetica', 'bold')
doc.setFontSize(card.value.length > 16 ? 9 : 12)
doc.setFontSize(18)
doc.setTextColor(...PDF_COLORS.textPrimary)
doc.text(card.value, cx + 5, cy + 14)
doc.text(card.value, cx + 5, cy + 17)
// Delta
// Delta: 9pt uniforme en los 4 cards (Refinamiento: era 7.5pt)
doc.setFont('helvetica', 'normal')
doc.setFontSize(7.5)
doc.setFontSize(9)
doc.setTextColor(...C.orangeDark)
doc.text(card.delta, cx + 5, cy + 20)
doc.text(card.delta, cx + 5, cy + 24)
maxY = Math.max(maxY, cy + cardH)
}
@@ -362,12 +391,12 @@ function _drawTop3Activities(
.sort((a, b) => b.savings - a.savings)
.slice(0, 3)
// Título
// Título: Refinamiento — 11pt, naranja oscuro (era 8pt, gris)
doc.setFont('helvetica', 'bold')
doc.setFontSize(8)
doc.setTextColor(...PDF_COLORS.textDisabled)
doc.setFontSize(11)
doc.setTextColor(...C.orangeDarker)
doc.text('TOP 3 ACTIVIDADES POR AHORRO', m, y)
y += 7
y += 10
if (top3.length === 0) {
doc.setFont('helvetica', 'normal')
@@ -395,9 +424,13 @@ function _drawTop3Activities(
doc.setTextColor(...PDF_COLORS.textPrimary)
doc.text(name, m, y + barH - 1)
// Barra de fondo
// Barra de fondo (track) con border naranja para definir el área completa
doc.setFillColor(...C.orangeBar)
doc.roundedRect(m + nameColW, y, barColW, barH, 1, 1, 'F')
doc.setDrawColor(...C.heroOrange)
doc.setLineWidth(0.3)
doc.roundedRect(m + nameColW, y, barColW, barH, 1, 1, 'S')
doc.setLineWidth(0.2)
// Barra de fill
if (barFill > 0) {
@@ -1011,11 +1044,19 @@ export function drawTrajectoryChart(
// Máximo
doc.text(_fmtAxisVal(yMax), chartLeft - 2, chartTop + 4, { align: 'right' } as Record<string, unknown>)
// Cero (solo si hay rango negativo)
// Cero y mínimo: cuando la línea cero queda casi en el borde inferior (inversión
// muy pequeña relativa al ahorro), los labels "0" y yMin se solapan con "Año 0".
// En ese caso mostramos "0" elevado 4mm dentro del área del gráfico y omitimos
// yMin (la zona negativa es visualmente invisible; el dato está en la anotación).
if (yMin < 0) {
doc.text('0', chartLeft - 2, zeroY + 1, { align: 'right' } as Record<string, unknown>)
// Mínimo
doc.text(_fmtAxisVal(yMin), chartLeft - 2, chartBottom, { align: 'right' } as Record<string, unknown>)
const minSepMm = 8
const zeroNearBottom = (chartBottom - zeroY) < minSepMm
if (!zeroNearBottom) {
doc.text('0', chartLeft - 2, zeroY + 1, { align: 'right' } as Record<string, unknown>)
doc.text(_fmtAxisVal(yMin), chartLeft - 2, chartBottom, { align: 'right' } as Record<string, unknown>)
} else {
doc.text('0', chartLeft - 2, chartBottom - 4, { align: 'right' } as Record<string, unknown>)
}
}
// 7. Marker de payback (línea vertical punteada, solo si hay payback en horizonte)