From a2d49b441e39156eacc49bb25499ec3e55680a42 Mon Sep 17 00:00:00 2001 From: kvilches1410 Date: Wed, 1 Apr 2026 11:34:43 -0300 Subject: [PATCH] tercer commit --- api/config.js | 9 +++ api/data.js | 92 ++++++++++++++++++++++++++++++- css/styles.css | 53 +++++++++++++++--- index.html | 86 ++++++++++++++++++++++++++++- js/app.js | 147 +++++++++++++++++++++++++++++++++++++++++++++---- js/charts.js | 39 ++++++++++++- 6 files changed, 400 insertions(+), 26 deletions(-) diff --git a/api/config.js b/api/config.js index a6d13e9..bb48db5 100644 --- a/api/config.js +++ b/api/config.js @@ -73,6 +73,15 @@ export const config = { spEstimate: getEnv('JIRA_FIELD_SP_ESTIMATE', 'customfield_10016'), spFinal: getEnv('JIRA_FIELD_SP_FINAL', 'customfield_11019'), developer: getEnv('JIRA_FIELD_DEVELOPER', 'customfield_10622'), + // ── QA Fields ──────────────────────────────────────────────────── + qaAssignee: 'customfield_10632', + qaEntryRtqa: 'customfield_10821', + qaTotalTimeRtqa: 'customfield_10822', + qaLastDurRtqa: 'customfield_10823', + qaEntryQa: 'customfield_10824', + qaTotalTimeQa: 'customfield_10825', + qaLastDurQa: 'customfield_10826', + timesReturned: 'customfield_11217', }, // ── JQL Query ────────────────────────────────────────────────────── diff --git a/api/data.js b/api/data.js index 5274c07..3d89aa9 100644 --- a/api/data.js +++ b/api/data.js @@ -97,12 +97,21 @@ function buildFieldsList() { 'summary', 'status', 'assignee', - config.fields.spEstimate, // Story points estimate - config.fields.spFinal, // Story points finales - config.fields.developer, // Desarrollador (multi-user) + config.fields.spEstimate, // Story points estimate + config.fields.spFinal, // Story points finales + config.fields.developer, // Desarrollador (multi-user) 'resolutiondate', 'created', 'updated', + // ── QA Fields ────────────────────────────────────── + config.fields.qaAssignee, // QA asignado + config.fields.qaEntryRtqa, // Fecha entrada READY TO QA + config.fields.qaTotalTimeRtqa, // Tiempo total en READY TO QA (min) + config.fields.qaLastDurRtqa, // Última duración en READY TO QA (min) + config.fields.qaEntryQa, // Fecha entrada QA + config.fields.qaTotalTimeQa, // Tiempo total en QA (min) + config.fields.qaLastDurQa, // Última duración en QA (min) + config.fields.timesReturned, // Veces que volvió a En Curso ].join(','); } @@ -112,12 +121,31 @@ function buildFieldsList() { * @param {Array} issues - Array de issues de JIRA * @returns {Object} Datos procesados */ +// Helper: convierte minutos a horas redondeadas a 1 decimal +function minsToHrs(mins) { + if (mins == null || mins === 0) return 0; + return Math.round((mins / 60) * 10) / 10; +} + function processIssues(issues) { const assigneeData = {}; const monthlyDone = {}; const issuesList = []; const warnings = []; const now = new Date(); + + // ── QA Aggregation ────────────────────────────────────────────────── + const qaData = { + tickets: 0, + ticketsReturned: 0, + totalTimeQa: 0, // minutos + totalTimeRtqa: 0, // minutos + monthlyTotalQa: {}, // { 'YYYY-MM': totalMin } + monthlyCountQa: {}, // { 'YYYY-MM': count } + monthlyTotalRtqa: {}, + monthlyCountRtqa: {}, + issues: [], + }; let processedCount = 0; let skippedCount = 0; @@ -197,6 +225,41 @@ function processIssues(issues) { creMonth }); + // ── Acumular datos de QA ──────────────────────────────────── + const timeQaMin = f[config.fields.qaTotalTimeQa] ?? 0; + const timeRtqaMin = f[config.fields.qaTotalTimeRtqa] ?? 0; + const lastDurQa = f[config.fields.qaLastDurQa] ?? 0; + const timesRet = f[config.fields.timesReturned] ?? 0; + const qaMonth = resMonth || creMonth; + + if (timeQaMin > 0) { + qaData.tickets++; + qaData.totalTimeQa += timeQaMin; + if (timesRet > 1) qaData.ticketsReturned++; + + if (qaMonth) { + qaData.monthlyTotalQa[qaMonth] = (qaData.monthlyTotalQa[qaMonth] || 0) + timeQaMin; + qaData.monthlyCountQa[qaMonth] = (qaData.monthlyCountQa[qaMonth] || 0) + 1; + } + + qaData.issues.push({ + key: issue.key, + summary: f.summary, + timeQa: minsToHrs(timeQaMin), + timeRtqa: minsToHrs(timeRtqaMin), + lastDurQa: minsToHrs(lastDurQa), + timesReturned: timesRet, + status: statusKey, + resMonth: qaMonth, + }); + } + + if (timeRtqaMin > 0 && qaMonth) { + qaData.totalTimeRtqa += timeRtqaMin; + qaData.monthlyTotalRtqa[qaMonth] = (qaData.monthlyTotalRtqa[qaMonth] || 0) + timeRtqaMin; + qaData.monthlyCountRtqa[qaMonth] = (qaData.monthlyCountRtqa[qaMonth] || 0) + 1; + } + // ── Detectar Warnings ─────────────────────────────────────── // 1. Tickets > 14 días in-progress if (isInProgress && f.updated) { @@ -225,6 +288,28 @@ function processIssues(issues) { console.log(`📊 Procesados: ${processedCount} | Saltados: ${skippedCount} | Warnings: ${warnings.length}`); + // ── Calcular promedios mensuales QA ───────────────────────────────── + const monthlyAvgQa = {}; + for (const m of Object.keys(qaData.monthlyTotalQa)) { + monthlyAvgQa[m] = minsToHrs(qaData.monthlyTotalQa[m] / qaData.monthlyCountQa[m]); + } + const monthlyAvgRtqa = {}; + for (const m of Object.keys(qaData.monthlyTotalRtqa)) { + monthlyAvgRtqa[m] = minsToHrs(qaData.monthlyTotalRtqa[m] / qaData.monthlyCountRtqa[m]); + } + qaData.monthlyAvgQa = monthlyAvgQa; + qaData.monthlyAvgRtqa = monthlyAvgRtqa; + qaData.avgTimeQa = qaData.tickets > 0 ? minsToHrs(qaData.totalTimeQa / qaData.tickets) : 0; + qaData.avgTimeRtqa = qaData.tickets > 0 ? minsToHrs(qaData.totalTimeRtqa / qaData.tickets) : 0; + + // Limpiar campos internos de acumulación + delete qaData.monthlyTotalQa; + delete qaData.monthlyCountQa; + delete qaData.monthlyTotalRtqa; + delete qaData.monthlyCountRtqa; + + console.log(`🧪 QA: ${qaData.tickets} tickets | ${qaData.ticketsReturned} devueltos | Avg QA: ${qaData.avgTimeQa}hs`); + return { lastUpdated: new Date().toISOString(), totalIssues: processedCount, @@ -233,6 +318,7 @@ function processIssues(issues) { monthlyDone, issuesList, warnings, + qaData, }; } diff --git a/css/styles.css b/css/styles.css index 34011dc..95b88c6 100644 --- a/css/styles.css +++ b/css/styles.css @@ -51,6 +51,10 @@ select:focus { border-color: #0052cc; } .kpi.todo { border-color: #ff8b00; } .kpi.members { border-color: #6554c0; } .kpi.warning { border-color: #ff5630; } +.kpi.qa { border-color: #6554c0; } +.kpi.qa-time { border-color: #00b8d9; } +.kpi.qa-queue { border-color: #36b37e; } +.kpi.returned { border-color: #ff5630; } .kpi-value { font-size: 36px; font-weight: 700; line-height: 1; } .kpi.done .kpi-value { color: #36b37e; } @@ -58,11 +62,23 @@ select:focus { border-color: #0052cc; } .kpi.todo .kpi-value { color: #ff8b00; } .kpi.members .kpi-value { color: #6554c0; } .kpi.warning .kpi-value { color: #ff5630; } +.kpi.qa .kpi-value { color: #6554c0; } +.kpi.qa-time .kpi-value { color: #00b8d9; } +.kpi.qa-queue .kpi-value { color: #36b37e; } +.kpi.returned .kpi-value { color: #ff5630; } .kpi-label { font-size: 12px; color: #5e6c84; margin-top: 6px; font-weight: 500; text-transform: uppercase; letter-spacing: .5px; } .kpi-sub { font-size: 11px; color: #97a0af; margin-top: 4px; } -/* Charts */ +/* Section header QA */ +.section-header.qa { border-color: #6554c0; } +.section-header.qa .dot { background: #6554c0; } + +/* Pill y row QA */ +.pill.qa { background: #f3f0ff; color: #403294; } +.pill.returned { background: #ffebe6; color: #bf2600; } +.row-critical td { background: #fff3f0; } + .charts-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-bottom: 24px; } .chart-card { background: white; border-radius: 8px; padding: 24px; box-shadow: 0 1px 3px rgba(0,0,0,.06); } .chart-card.full { grid-column: 1 / -1; } @@ -103,18 +119,37 @@ footer { text-align: center; padding: 24px; font-size: 11px; color: #97a0af; } .warning-item .title { font-weight: 600; font-size: 13px; color: #172b4d;} .warning-item .desc { font-size: 12px; color: #5e6c84; } +/* Popover Tooltips */ +.pop-trigger { position: relative; display: inline-block; cursor: help; } +.pop-content { + display: none; + position: absolute; + bottom: 120%; + left: 50%; + transform: translateX(-50%); + background: white; + border-radius: 8px; + box-shadow: 0 10px 30px rgba(23,43,77,.25); + border: 1px solid #dfe1e6; + padding: 12px; + width: 280px; + z-index: 100; +} +.pop-trigger:hover .pop-content { display: block; animation: popIn .2s ease-out; } +@keyframes popIn { + from { opacity: 0; transform: translateX(-50%) translateY(10px) scale(.95); } + to { opacity: 1; transform: translateX(-50%) translateY(0) scale(1); } +} + +.pop-item { font-size: 11px; padding: 6px 0; border-bottom: 1px solid #f4f5f7; line-height: 1.4; color: #5e6c84; } +.pop-item:last-child { border-bottom: none; } +.pop-item a { color: #0052cc; text-decoration: none; font-weight: 600; margin-right: 4px; } +.pop-item a:hover { text-decoration: underline; } + /* Jira Links */ .jira-link { color: #0052cc; text-decoration: none; font-weight: bold; border-bottom: 1px dashed rgba(0,82,204,.3); transition: color .2s; } .jira-link:hover { color: #0747a6; border-bottom-style: solid; text-decoration: underline; } -.jira-link-small { color: #5e6c84; text-decoration: none; font-size: 10px; background: #f4f5f7; padding: 1px 4px; border-radius: 3px; border: 1px solid #dfe1e6; transition: all .2s; margin-right: 4px; display: inline-block; margin-top: 4px; } -.jira-link-small:hover { background: #ebecf0; color: #172b4d; border-color: #c1c7d0; text-decoration: none; } - -.ticket-list-inline { margin-top: 6px; display: flex; flex-wrap: wrap; gap: 2px; } - -/* Tooltip improvements (native title) */ -[title] { cursor: help; } - /* Responsive */ @media (max-width: 768px) { .kpi-row, .kpi-row-3 { grid-template-columns: 1fr 1fr; } diff --git a/index.html b/index.html index 8abf5e6..413ebc8 100644 --- a/index.html +++ b/index.html @@ -124,13 +124,97 @@

SP Completados — Tabla Resumen

- +
Desarrollador✅ SP Completados% del impacto total
Desarrollador✅ SP Completados🎟️ Tickets% del impacto total
+ + + +
+
+

Control de Calidad (QA)

+ Métricas del proceso de QA · Tiempos, colas y devoluciones +
+ +
+
+ + +
+
+
—
+
Tickets QA
+
Procesados en el período
+
+
+
—
+
Tiempo Prom. QA
+
Horas promedio por ticket
+
+
+
—
+
Tiempo Prom. en Cola
+
Horas esperando READY TO QA
+
+
+
—
+
Tickets Devueltos
+
Volvieron a En Curso
+
+
+ + +
+
+

📊 Distribución de Tiempos en QA

+
Rangos: <30min · 30min–2hs · 2–8hs · >8hs
+
+
+
+

🔄 Devueltos vs. Aprobados

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

📈 Tiempo Promedio en QA — Mes a Mes

+
Evolución de la velocidad de revisión (horas)
+
+
+
+

⏳ Tiempo en Cola RTQA — Mes a Mes

+
Horas promedio esperando ser tomado por QA
+
+
+
+ + +
+

🔍 Detalle de Tickets — QA

+ + + + + + + + + + + + +
TicketResumen⏱️ Tiempo en QA (hs)⏳ Cola RTQA (hs)🔄 Veces devueltoEstado
+
+ +
+
diff --git a/js/app.js b/js/app.js index 693e7a0..1e013ad 100644 --- a/js/app.js +++ b/js/app.js @@ -70,6 +70,7 @@ async function loadAndRender(forceRefresh = false) { renderCurrentSection(); renderHistorySection(); + renderQASection(); } catch(e) { errorBanner.style.display = 'block'; @@ -197,19 +198,25 @@ function renderCurrentSection() { const d = ad[p], total = d.in_progress + d.todo; const c = chartsManager.personColor(p); - const tooltipIP = d.issues_ip.map(i => `${i.key} (${i.sp} SP)`).join('\n'); - const tooltipTodo = d.issues_todo.map(i => `${i.key} (${i.sp} SP)`).join('\n'); - const allKeys = [...d.issues_ip, ...d.issues_todo].map(i => - `${i.key}` - ).join(' '); + const popoverIP = d.issues_ip.map(i => `
${i.key}: ${i.summary.slice(0,30)}... (${i.sp} SP)
`).join(''); + const popoverTodo = d.issues_todo.map(i => `
${i.key}: ${i.summary.slice(0,30)}... (${i.sp} SP)
`).join(''); tbody.innerHTML += `
${initials(p)}
${p}
-
${allKeys}
- ${d.in_progress} SP - ${d.todo} SP + +
+ ${d.in_progress} SP +
${popoverIP || 'No hay tickets'}
+
+ + +
+ ${d.todo} SP +
${popoverTodo || 'No hay tickets'}
+
+ ${total} SP
@@ -226,10 +233,13 @@ function renderHistorySection() { // Consolidar todos los meses del rango actual let periodData = {}; - Object.values(data.monthlyDone).forEach(md => { - Object.entries(md).forEach(([p, sp]) => { + let periodTicketCount = {}; + + data.issuesList.filter(i => i.status === 'done').forEach(i => { + i.devs.forEach(p => { if (state.selectedDev !== 'all' && p !== state.selectedDev) return; - periodData[p] = (periodData[p] || 0) + sp; + periodData[p] = (periodData[p] || 0) + i.sp; + periodTicketCount[p] = (periodTicketCount[p] || 0) + 1; }); }); @@ -251,14 +261,16 @@ function renderHistorySection() { const tbody = document.getElementById('tableHistory'); tbody.innerHTML = ''; if (sorted.length === 0) { - tbody.innerHTML = 'No hay entregas en este período'; + tbody.innerHTML = 'No hay entregas en este período'; } else { sorted.forEach(([p, sp]) => { const p_ = totalSP > 0 ? (sp / totalSP * 100).toFixed(1) : 0; const c = chartsManager.personColor(p); + const tickets = periodTicketCount[p] || 0; tbody.innerHTML += `
${initials(p)}
${p}
${sp} SP entregados + ${tickets} tickets
@@ -280,3 +292,114 @@ function renderHistorySection() { backgroundColor: chartsManager.personColor(p), borderRadius: 3 })), { plugins:{ legend:{ position:'bottom'} }, scales:{ x:{stacked:true,grid:{display:false}}, y:{stacked:true} } }); } + +// ── QA SECTION ───────────────────────────────────────────────────────────── +function renderQASection() { + const data = api.data; + if (!data || !data.qaData) return; + + const qa = data.qaData; + + // ── KPIs ────────────────────────────────────────────────────────────────── + document.getElementById('kpi-qa-tickets').textContent = qa.tickets ?? '—'; + document.getElementById('kpi-qa-avg').textContent = qa.avgTimeQa != null ? qa.avgTimeQa + 'hs' : '—'; + document.getElementById('kpi-qa-rtqa').textContent = qa.avgTimeRtqa != null ? qa.avgTimeRtqa + 'hs' : '—'; + document.getElementById('kpi-qa-returned').textContent = qa.ticketsReturned ?? '—'; + + 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`; + + // ── GRÁFICO 1: Distribución de tiempos en QA (doughnut) ─────────────────── + const buckets = { '<30min': 0, '30min–2hs': 0, '2–8hs': 0, '>8hs': 0 }; + (qa.issues || []).forEach(i => { + const hs = i.timeQa; + if (hs < 0.5) buckets['<30min']++; + else if (hs < 2) buckets['30min–2hs']++; + else if (hs < 8) buckets['2–8hs']++; + else buckets['>8hs']++; + }); + chartsManager.drawDoughnutChart( + 'qaDist', 'chartQaDist', + Object.keys(buckets), + Object.values(buckets), + ['#36b37e', '#00b8d9', '#ff8b00', '#ff5630'] + ); + + // ── GRÁFICO 2: Devueltos vs Aprobados (doughnut) ────────────────────────── + const aprobados = qa.tickets - qa.ticketsReturned; + chartsManager.drawDoughnutChart( + 'qaReturned', 'chartQaReturned', + ['✅ Aprobados directo', '🔄 Devueltos (>1 vez en curso)'], + [aprobados, qa.ticketsReturned], + ['#36b37e', '#ff5630'] + ); + + // ── GRÁFICO 3: Tiempo promedio QA mes a mes (line) ──────────────────────── + const qaMonths = Object.keys(qa.monthlyAvgQa || {}).sort(); + chartsManager.drawLineChart( + 'qaMonthly', 'chartQaMonthly', + qaMonths, + [{ + label: 'Prom. tiempo en QA (hs)', + data: qaMonths.map(m => qa.monthlyAvgQa[m] || 0), + borderColor: '#6554c0', + backgroundColor: 'rgba(101,84,192,.12)', + fill: true, + pointBackgroundColor: '#6554c0', + borderWidth: 2, + }], + { plugins:{ legend:{ display:false } }, scales:{ x:{grid:{display:false}}, y:{ beginAtZero:true } } } + ); + + // ── GRÁFICO 4: Tiempo en cola RTQA mes a mes (bar) ──────────────────────── + const rtqaMonths = Object.keys(qa.monthlyAvgRtqa || {}).sort(); + chartsManager.drawBarChart( + 'rtqaMonthly', 'chartRtqaMonthly', + rtqaMonths, + [{ + label: 'Prom. tiempo en cola RTQA (hs)', + data: rtqaMonths.map(m => qa.monthlyAvgRtqa[m] || 0), + backgroundColor: '#00b8d9', + borderRadius: 4, + }], + { plugins:{ legend:{ display:false } }, scales:{ x:{grid:{display:false}}, y:{ beginAtZero:true } } } + ); + + // ── TABLA de tickets QA ─────────────────────────────────────────────────── + const host = api.data?.config?.host || 'invera.atlassian.net'; + const tbody = document.getElementById('tableQA'); + tbody.innerHTML = ''; + + const sorted = [...(qa.issues || [])].sort((a, b) => b.timeQa - a.timeQa); + + if (sorted.length === 0) { + tbody.innerHTML = 'No hay tickets con datos de QA en este período'; + return; + } + + sorted.forEach(i => { + const isCritical = i.timeQa > 4; + const isReturned = i.timesReturned > 1; + const jiraUrl = `https://${host}/browse/${i.key}`; + + const statusLabel = i.status === 'done' + ? '✅ Done' + : i.status === 'indeterminate' + ? '🔵 En curso' + : '⏳ Pendiente'; + + const returnBadge = isReturned + ? `🔄 ${i.timesReturned}x` + : `1x`; + + tbody.innerHTML += ` + ${i.key} + ${i.summary} + ${i.timeQa}hs${isCritical ? ' ⚠️' : ''} + ${i.timeRtqa > 0 ? i.timeRtqa + 'hs' : '—'} + ${returnBadge} + ${statusLabel} + `; + }); +} diff --git a/js/charts.js b/js/charts.js index e58ba85..ed6f4e0 100644 --- a/js/charts.js +++ b/js/charts.js @@ -43,5 +43,42 @@ export const chartsManager = { ...options, } }); - } + }, + + drawDoughnutChart(id, canvasId, labels, data, backgroundColors, options = {}) { + this.destroyChart(id); + const ctx = document.getElementById(canvasId); + if (!ctx) return; + + this.instances[id] = new Chart(ctx, { + type: 'doughnut', + data: { + labels, + datasets: [{ data, backgroundColor: backgroundColors, borderWidth: 2, borderColor: '#fff' }], + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { legend: { position: 'bottom', labels: { font: { size: 11 } } } }, + ...options, + } + }); + }, + + drawLineChart(id, canvasId, labels, datasets, options = {}) { + this.destroyChart(id); + const ctx = document.getElementById(canvasId); + if (!ctx) return; + + this.instances[id] = new Chart(ctx, { + type: 'line', + data: { labels, datasets }, + options: { + responsive: true, + maintainAspectRatio: false, + tension: 0.4, + ...options, + } + }); + }, };