From 76edc176f95b03d254e4edb6df85acc1cac1b87f Mon Sep 17 00:00:00 2001 From: kvilches1410 Date: Tue, 7 Apr 2026 17:32:33 -0300 Subject: [PATCH] refactor: implement dashboard UI enhancements, add health alert tooltips, and introduce modular JS application structure with Netlify data functions. --- index.html | 35 ++++++++++++------ js/app.js | 76 +++++++++++++++++++++++++++------------ netlify/functions/data.js | 16 +++++++-- 3 files changed, 91 insertions(+), 36 deletions(-) diff --git a/index.html b/index.html index aafc409..0def4ba 100644 --- a/index.html +++ b/index.html @@ -71,7 +71,18 @@
-

📢 Alertas de Salud

+

📢 Alertas de Salud +
ℹ️ +
+ Tipos de Alertas:

+ ESTANCADO › +14 días en curso.

+ VENCIDO › Fecha de entrega caducada.

+ SIN SP › Completado sin estimación.

+ HUÉRFANO › Ticket con SP sin asignar.

+ DEV LIBRE › Dev sin tickets pendientes ni en curso. +
+
+

Tickets estancados o sin estimación
-

SP Completados — Tabla Resumen

+

+ SP Completados — Tabla Resumen + +

- +
Desarrollador✅ SP Completados🎟️ Tickets% del impacto total
Desarrollador✅ SP Total🎟️ Tickets📉 Media SP/Ticket% del impacto total vs Equipo
@@ -262,10 +276,10 @@
-
+
—
-
Tickets Devueltos
-
Volvieron a En Curso
+
Tickets con Retrabajo
+
Sufrieron rechazos
—
@@ -299,8 +313,8 @@
-

🔄 Devueltos vs. Aprobados

-
Tickets que volvieron a En Curso al menos una vez
+

🔄 Flujo Directo vs Retrabajo

+
Tickets con 0 rechazos vs con devoluciones (CR/QA)
@@ -327,8 +341,9 @@ Ticket Resumen - ⏱️ Tiempo en QA (hs) - ⏳ Cola RTQA (hs) + ⏱️ Dev (hs) + ⏱️ CR (hs) + ⏱️ QA (hs) 🔄 Veces devuelto Estado diff --git a/js/app.js b/js/app.js index b840064..9c93d47 100644 --- a/js/app.js +++ b/js/app.js @@ -238,6 +238,12 @@ function renderHistorySection() { let periodData = {}; let periodTicketCount = {}; + Object.keys(data.assigneeData || {}).forEach(p => { + if (state.selectedDev !== 'all' && p !== state.selectedDev) return; + periodData[p] = 0; + periodTicketCount[p] = 0; + }); + data.issuesList.filter(i => i.status === 'done').forEach(i => { i.devs.forEach(p => { if (state.selectedDev !== 'all' && p !== state.selectedDev) return; @@ -247,37 +253,60 @@ function renderHistorySection() { }); const totalSP = Object.values(periodData).reduce((a,b) => a+b, 0); - const devCount = Object.keys(periodData).filter(p => periodData[p] > 0).length; + const devsWithActiveTickets = Object.keys(periodData).filter(p => periodData[p] > 0).length; + // Promedio global del equipo usando total de devs (incluyendo los de 0) para medir impacto real + const allDevsCount = Object.keys(periodData).length; + const teamAvgSP = allDevsCount > 0 ? (totalSP / allDevsCount) : 0; - document.getElementById('kpi-done-period').textContent = totalSP; - document.getElementById('kpi-devs-period').textContent = devCount; - document.getElementById('kpi-avg-period').textContent = devCount > 0 ? Math.round(totalSP / devCount) : 0; + const labelEl = document.getElementById('teamAvgLabel'); + if (labelEl && teamAvgSP > 0 && state.selectedDev === 'all') { + labelEl.textContent = `🎯 Media Equipo: ${teamAvgSP.toFixed(1)} SP por Dev`; + labelEl.style.display = 'inline-block'; + } else if (labelEl) { + labelEl.style.display = 'none'; + } - // Gráfico de impacto personal (Terminados) - const sorted = Object.entries(periodData).filter(([,sp]) => sp > 0).sort((a,b) => b[1]-a[1]); - chartsManager.drawBarChart('doneDev', 'chartDoneDev', sorted.map(([p]) => shortName(p)), [{ - label: 'SP entregados', data: sorted.map(([,sp]) => sp), - backgroundColor: sorted.map(([p]) => chartsManager.personColor(p)), borderRadius: 5 + document.getElementById('kpi-done-period').textContent = totalSP; + document.getElementById('kpi-devs-period').textContent = devsWithActiveTickets; + document.getElementById('kpi-avg-period').textContent = devsWithActiveTickets > 0 ? Math.round(totalSP / devsWithActiveTickets) : 0; + + // Gráfico de impacto personal (Terminados) (solo mayores a 0) + const sortedGraph = Object.entries(periodData).filter(([,sp]) => sp > 0).sort((a,b) => b[1]-a[1]); + chartsManager.drawBarChart('doneDev', 'chartDoneDev', sortedGraph.map(([p]) => shortName(p)), [{ + label: 'SP entregados', data: sortedGraph.map(([,sp]) => sp), + backgroundColor: sortedGraph.map(([p]) => chartsManager.personColor(p)), borderRadius: 5 }], { plugins:{ legend:{ display:false } }, scales:{ x:{grid:{display:false}} } }); // Tabla de Resultados + const sortedAll = Object.entries(periodData).sort((a,b) => b[1]-a[1]); const tbody = document.getElementById('tableHistory'); tbody.innerHTML = ''; - if (sorted.length === 0) { - tbody.innerHTML = 'No hay entregas en este período'; + if (sortedAll.length === 0) { + tbody.innerHTML = 'No hay entregas en este período'; } else { - sorted.forEach(([p, sp]) => { - const p_ = totalSP > 0 ? (sp / totalSP * 100).toFixed(1) : 0; + sortedAll.forEach(([p, sp]) => { + const p_vsTeam = teamAvgSP > 0 ? (sp / teamAvgSP * 100).toFixed(0) : 0; const c = chartsManager.personColor(p); const tickets = periodTicketCount[p] || 0; + const avgPerTicket = tickets > 0 ? (sp / tickets).toFixed(1) : 0; + + let badgeHtml = ''; + if (sp === 0) { + badgeHtml = `0 SP (SIN ENTREGAS)`; + } else if (p_vsTeam >= 150) { + badgeHtml = `🔥 +${p_vsTeam - 100}% sup. prom.`; + } else if (p_vsTeam <= 50) { + badgeHtml = `📉 -${100 - p_vsTeam}% del prom.`; + } else { + badgeHtml = `~ ${p_vsTeam}% del prom.`; + } + tbody.innerHTML += `
${initials(p)}
${p}
- ${sp} SP entregados - ${tickets} tickets -
-
-
-
${p_}%
+ ${sp} SP + ${tickets} tkts + ${avgPerTicket} SP/tkt + ${badgeHtml} `; }); } @@ -369,7 +398,7 @@ function renderQASection() { const pctRet = qa.tickets > 0 ? ((qa.ticketsReturned / qa.tickets) * 100).toFixed(1) : 0; document.getElementById('kpi-qa-returned-pct').textContent = - `${pctRet}% del total pasó por QA más de una vez`; + `${pctRet}% del total sufrió al menos 1 rechazo`; // ── GRÁFICO 1: Distribución de tiempos en QA (doughnut) ─────────────────── const buckets = { '<30min': 0, '30min–2hs': 0, '2–8hs': 0, '>8hs': 0 }; @@ -391,7 +420,7 @@ function renderQASection() { const aprobados = qa.tickets - qa.ticketsReturned; chartsManager.drawDoughnutChart( 'qaReturned', 'chartQaReturned', - ['✅ Aprobados directo', '🔄 Devueltos (>1 vez en curso)'], + ['✅ Flujo directo (0 rechazos)', '🔄 Retrabajo (Rechazo en CR/QA)'], [aprobados, qa.ticketsReturned], ['#36b37e', '#ff5630'] ); @@ -458,8 +487,9 @@ function renderQASection() { tbody.innerHTML += ` ${i.key} ${i.summary} - ${i.timeQa}hs${isCritical ? ' ⚠️' : ''} - ${i.timeRtqa > 0 ? i.timeRtqa + 'hs' : '—'} + ${i.timeDev > 0 ? i.timeDev + 'hs' : '—'} + ${i.timeCr > 0 ? i.timeCr + 'hs' : '—'} + ${i.timeQa}hs${isCritical ? ' ⚠️' : ''} ${returnBadge} ${statusLabel} `; diff --git a/netlify/functions/data.js b/netlify/functions/data.js index 58be3b5..f49519e 100644 --- a/netlify/functions/data.js +++ b/netlify/functions/data.js @@ -248,9 +248,19 @@ function processIssues(issues, period = '90d') { const timeQaMin = f[config.fields.qaTotalTimeQa] ?? 0; const timeRtqaMin = f[config.fields.qaTotalTimeRtqa] ?? 0; - const timesRet = f[config.fields.timesReturned] ?? 0; + let timesRet = f[config.fields.timesReturned] ?? 0; + + // Ignorar valores erróneos acumulados por la automatización vieja + const automationFixDate = new Date('2026-04-07T00:00:00-03:00'); + const issueCreated = f.created ? new Date(f.created) : null; + if (issueCreated && issueCreated < automationFixDate) { + timesRet = 1; // Lo forzamos a 1 (no devuelto) para que no rompa las métricas con data sucia + } const qaMonth = resMonth || creMonth; + const timeCrMin = f[config.fields.crTotalTime] ?? 0; + const timeDevMin = f[config.fields.enCursoTotalTime] ?? 0; + if (timeQaMin > 0) { qaData.tickets++; qaData.totalTimeQa += timeQaMin; @@ -262,7 +272,8 @@ function processIssues(issues, period = '90d') { } qaData.issues.push({ key: issue.key, summary: f.summary, timeQa: minsToHrs(timeQaMin), timeRtqa: minsToHrs(timeRtqaMin), - timesReturned: timesRet, status: statusKey, resMonth: qaMonth + timesReturned: timesRet, status: statusKey, resMonth: qaMonth, + timeCr: minsToHrs(timeCrMin), timeDev: minsToHrs(timeDevMin) }); } @@ -275,7 +286,6 @@ function processIssues(issues, period = '90d') { } } - const timeCrMin = f[config.fields.crTotalTime] ?? 0; if (timeCrMin > 0) { crData.tickets++; crData.totalTimeCr += timeCrMin;