From d7f7339fde89acea42ffccc56a378d334ff982ad Mon Sep 17 00:00:00 2001 From: kvilches1410 Date: Mon, 6 Apr 2026 18:59:47 -0300 Subject: [PATCH] Muchos cambios --- .gitignore | 3 ++ css/styles.css | 3 +- deno.lock | 11 ++++++ index.html | 63 +++++++++++++++++++++++++++----- js/app.js | 18 ++++++++- netlify/functions/data.js | 77 ++++++++++++++++++++++++++++++++++++--- 6 files changed, 156 insertions(+), 19 deletions(-) create mode 100644 deno.lock diff --git a/.gitignore b/.gitignore index 013f752..eba8314 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ .vercel .env ex.env + +# Local Netlify folder +.netlify diff --git a/css/styles.css b/css/styles.css index 95b88c6..e24d6cc 100644 --- a/css/styles.css +++ b/css/styles.css @@ -86,7 +86,7 @@ select:focus { border-color: #0052cc; } .chart-card .sub { font-size: 12px; color: #5e6c84; margin-bottom: 20px; } /* Tables */ -.table-card { background: white; border-radius: 8px; padding: 24px; box-shadow: 0 1px 3px rgba(0,0,0,.06); margin-bottom: 24px; overflow-x: auto; } +.table-card { background: white; border-radius: 8px; padding: 24px; box-shadow: 0 1px 3px rgba(0,0,0,.06); margin-bottom: 24px; } .table-card h3 { font-size: 14px; font-weight: 700; margin-bottom: 16px; } table { width: 100%; border-collapse: collapse; font-size: 13px; } th { text-align: left; padding: 10px 14px; background: #f4f5f7; color: #5e6c84; font-weight: 600; font-size: 11px; text-transform: uppercase; letter-spacing: .5px; border-bottom: 1px solid #dfe1e6; } @@ -158,4 +158,5 @@ footer { text-align: center; padding: 24px; font-size: 11px; color: #97a0af; } .main { padding: 16px; } .section-header { gap: 8px; flex-direction: column; align-items: flex-start;} .filter-group { margin-left: 0; } + .table-card { overflow-x: auto; } } diff --git a/deno.lock b/deno.lock new file mode 100644 index 0000000..dc56a3f --- /dev/null +++ b/deno.lock @@ -0,0 +1,11 @@ +{ + "version": "5", + "remote": { + "https://edge.netlify.com/": "fd941d61d88673d5f28aab283fb86fcc50f08a3bc80ee5470498fcfa88c65cfb", + "https://edge.netlify.com/bootstrap/config.ts": "6a2ce0e544e15e8f8883a5c18da5948e37fd0f2619f68cb31f3af53c51817025", + "https://edge.netlify.com/bootstrap/context.ts": "e97240232121e2f369f6546ce961490f34d961ea1ea54be3ff09633e3f08373f", + "https://edge.netlify.com/bootstrap/cookie.ts": "8b0baae708989ca183c6f3b4ab3d029e6abcbc2e43f93edeb0ff447b3bbc3a05", + "https://edge.netlify.com/bootstrap/edge_function.ts": "b8253e86aa83c67341f5cfedeba5049d77fbf84dcab7eceff7566b7728ae9b39", + "https://edge.netlify.com/bootstrap/globals/types.ts": "eaa6148ded3121d8dee62dd91c86e7fe76601df0f3ca8d7962243a30f4c8935f" + } +} diff --git a/index.html b/index.html index beef7d6..2b95407 100644 --- a/index.html +++ b/index.html @@ -147,21 +147,49 @@
-
+ +
+
+
—
+
En Cola Actual CR
+
— SP en espera
+
+
+
—
+
Tickets Atascados CR
+
Demorados actualmente (más de 2 días)
+
+
+ + +

Métricas del período seleccionado

+
—
Tickets en CR
Procesados en el período
-
-
—
-
Tiempo Prom. CR
-
Horas promedio por ticket
+
+
—
+
+
Tiempo P50 CR +
+
Percentil 50 (Mediana)
El 50% de las revisiones de código se completan en esta cantidad de horas o menos.

A diferencia del promedio tradicional, el P50 muestra un valor "habitual" sin ser distorsionado por tickets que queden atascados durante meses.
+
+
+
+
Horas (Mediana)
-
-
—
-
Tickets Atascados
-
Demorados actualmente en revisión
+
+
—
+
+
Tiempos P90 y P99 +
+
Métricas de Cuello de Botella
Muestran los "peores escenarios" posibles.

P90: El 90% de los tickets logra pasar en este tiempo. El 10% restante demoró aún más.
P99: El 99% pasa en este tiempo. Ideal para medir los bloqueos máximos.
+
+
+
+
P99: — hs
@@ -187,7 +215,22 @@
- + +
+
+
—
+
En Cola Actual RTQA
+
— SP esperando
+
+
+
—
+
En Cola Actual QA
+
— SP en prueba
+
+
+ + +

Métricas del período seleccionado

—
diff --git a/js/app.js b/js/app.js index 79b6e0f..09c9104 100644 --- a/js/app.js +++ b/js/app.js @@ -129,6 +129,7 @@ function renderHealthWarnings(warnings = []) { const link = w.key ? `${w.key}` : ''; if(w.type === 'stalled') { badge = 'ESTANCADO'; text = `🎟️ [${link}] ${w.summary.slice(0,50)}... (${w.devs.join(', ')})`; } + if(w.type === 'expired') { badge = 'VENCIDO'; text = `🗓️ [${link}] ${w.summary.slice(0,50)}... (Vencido hace ${w.days} días)`; } if(w.type === 'done_no_sp') { badge = 'SIN SP'; text = `🎟️ [${link}] ${w.summary.slice(0,50)}... (Completado sin estimación)`; } if(w.type === 'unassigned_sp') { badge = 'HUÉRFANO'; text = `🎟️ [${link}] ${w.summary.slice(0,50)}... (${w.sp} SP sin dueño)`; } if(w.type === 'free_dev') { badge = 'DEV LIBRE'; text = `👤 ${w.summary}`; } @@ -312,9 +313,15 @@ function renderCRSection() { if (!data || !data.crData) return; const cr = data.crData; + const cur = data.currentStates || { codeReview: {count:0, sp:0} }; + + document.getElementById('kpi-cr-queue-now').textContent = cur.codeReview.count; + document.getElementById('kpi-cr-queue-sp').textContent = cur.codeReview.sp + ' SP en espera'; document.getElementById('kpi-cr-tickets').textContent = cr.tickets ?? '—'; - document.getElementById('kpi-cr-avg').textContent = cr.avgTimeCr != null ? cr.avgTimeCr + 'hs' : '—'; + document.getElementById('kpi-cr-p50').textContent = cr.p50Cr != null ? cr.p50Cr + 'hs' : '—'; + document.getElementById('kpi-cr-p90').textContent = cr.p90Cr != null ? cr.p90Cr + 'hs' : '—'; + document.getElementById('kpi-cr-p99-sub').textContent = cr.p99Cr != null ? 'P99: ' + cr.p99Cr + ' hs' : 'P99: — hs'; document.getElementById('kpi-cr-stalled').textContent = (cr.stalledIssues?.length) || '0'; const crMonths = Object.keys(cr.monthlyAvgCr || {}).sort(); @@ -340,8 +347,15 @@ function renderQASection() { if (!data || !data.qaData) return; const qa = data.qaData; + const cur = data.currentStates || { readyToQa: {count:0, sp:0}, inQa: {count:0, sp:0} }; - // ── KPIs ────────────────────────────────────────────────────────────────── + // ── COLAS ACTUALES ───────────────────────────────────────────────────────── + document.getElementById('kpi-qa-queue-now').textContent = cur.readyToQa.count; + document.getElementById('kpi-qa-queue-now-sp').textContent = cur.readyToQa.sp + ' SP esperando'; + document.getElementById('kpi-qa-active-now').textContent = cur.inQa.count; + document.getElementById('kpi-qa-active-now-sp').textContent = cur.inQa.sp + ' SP en prueba'; + + // ── KPIs DEL PERÍODO ────────────────────────────────────────────────────── 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' : '—'; diff --git a/netlify/functions/data.js b/netlify/functions/data.js index 4da5fe4..6a08d61 100644 --- a/netlify/functions/data.js +++ b/netlify/functions/data.js @@ -100,7 +100,7 @@ async function fetchJiraIssues(email, token, period) { function buildFieldsList() { return [ - 'summary', 'status', 'assignee', 'resolutiondate', 'created', 'updated', + 'summary', 'status', 'assignee', 'resolutiondate', 'created', 'updated', 'duedate', config.fields.spEstimate, config.fields.spFinal, config.fields.developer, config.fields.qaAssignee, config.fields.qaEntryRtqa, config.fields.qaTotalTimeRtqa, config.fields.qaLastDurRtqa, config.fields.qaEntryQa, config.fields.qaTotalTimeQa, @@ -115,13 +115,26 @@ function minsToHrs(mins) { return Math.round((mins / 60) * 10) / 10; } -function processIssues(issues) { +function processIssues(issues, period = '90d') { const assigneeData = {}; const monthlyDone = {}; const issuesList = []; const warnings = []; const now = new Date(); + let cutoffDate = null; + if (period !== 'all') { + const periodDays = period === '1d' ? 1 : parseInt(period.replace('d', '')); + if (!isNaN(periodDays)) { + if (period === '1d') { + cutoffDate = new Date(); + cutoffDate.setHours(0,0,0,0); + } else { + cutoffDate = new Date(Date.now() - (periodDays * 24 * 60 * 60 * 1000)); + } + } + } + const qaData = { tickets: 0, ticketsReturned: 0, totalTimeQa: 0, totalTimeRtqa: 0, monthlyTotalQa: {}, monthlyCountQa: {}, monthlyTotalRtqa: {}, monthlyCountRtqa: {}, issues: [], @@ -133,7 +146,13 @@ function processIssues(issues) { }; const crData = { - tickets: 0, totalTimeCr: 0, issues: [], monthlyTotalCr: {}, monthlyCountCr: {}, stalledIssues: [] + tickets: 0, totalTimeCr: 0, issues: [], monthlyTotalCr: {}, monthlyCountCr: {}, stalledIssues: [], times: [] + }; + + const currentStates = { + codeReview: { count: 0, sp: 0 }, + readyToQa: { count: 0, sp: 0 }, + inQa: { count: 0, sp: 0 } }; for (const issue of issues) { @@ -154,8 +173,30 @@ function processIssues(issues) { const sp = (f[config.fields.spFinal] != null ? f[config.fields.spFinal] : f[config.fields.spEstimate]) ?? 0; const statusKey = f.status?.statusCategory?.key ?? 'new'; + const statusName = f.status?.name?.toLowerCase() ?? ''; const isDone = statusKey === 'done'; + + if (isDone && f.resolutiondate && cutoffDate) { + const resDate = new Date(f.resolutiondate); + if (resDate < cutoffDate) { + continue; // Ticket resuelto antes del período (actualizado recientemente por otro motivo) + } + } + const isInProgress = statusKey === 'indeterminate'; + + if (!isDone) { + if (statusName === 'code review' || statusName.includes('code review') || statusName === 'cr') { + currentStates.codeReview.count++; + currentStates.codeReview.sp += sp; + } else if (statusName === 'ready to qa' || statusName.includes('ready to qa') || statusName === 'listo para qa') { + currentStates.readyToQa.count++; + currentStates.readyToQa.sp += sp; + } else if (statusName === 'qa' || statusName.includes('in qa') || statusName === 'en qa') { + currentStates.inQa.count++; + currentStates.inQa.sp += sp; + } + } const resMonth = f.resolutiondate ? f.resolutiondate.slice(0, 7) : null; const creMonth = f.created ? f.created.slice(0, 7) : null; @@ -233,6 +274,7 @@ function processIssues(issues) { if (timeCrMin > 0) { crData.tickets++; crData.totalTimeCr += timeCrMin; + crData.times.push(timeCrMin); if (qaMonth) { crData.monthlyTotalCr[qaMonth] = (crData.monthlyTotalCr[qaMonth] || 0) + timeCrMin; crData.monthlyCountCr[qaMonth] = (crData.monthlyCountCr[qaMonth] || 0) + 1; @@ -252,6 +294,13 @@ function processIssues(issues) { const days = (now - new Date(f.updated)) / (1000 * 60 * 60 * 24); if (days > 14) warnings.push({ type: 'stalled', key: issue.key, summary: f.summary, devs, sp, days: Math.round(days) }); } + if (!isDone && f.duedate) { + const dueDate = new Date(f.duedate); + if (dueDate < now) { + const daysOverdue = Math.floor((now - dueDate) / (1000 * 60 * 60 * 24)); + warnings.push({ type: 'expired', key: issue.key, summary: f.summary, devs, sp, days: daysOverdue }); + } + } if (isDone && sp === 0) warnings.push({ type: 'done_no_sp', key: issue.key, summary: f.summary, devs, sp: 0 }); if (!isDone && devs.includes('Sin asignar') && sp > 0) warnings.push({ type: 'unassigned_sp', key: issue.key, summary: f.summary, devs, sp }); @@ -276,9 +325,25 @@ function processIssues(issues) { const monthlyAvgCr = {}; for (const m of Object.keys(crData.monthlyTotalCr)) monthlyAvgCr[m] = minsToHrs(crData.monthlyTotalCr[m] / crData.monthlyCountCr[m]); + const crTimesSorted = crData.times.sort((a, b) => a - b); + function getPercentile(sortedArr, p) { + if (sortedArr.length === 0) return 0; + if (p <= 0) return sortedArr[0]; + if (p >= 1) return sortedArr[sortedArr.length - 1]; + const index = (sortedArr.length - 1) * p; + const lower = Math.floor(index); + const upper = lower + 1; + const weight = index % 1; + if (upper >= sortedArr.length) return sortedArr[lower]; + return sortedArr[lower] * (1 - weight) + sortedArr[upper] * weight; + } + const p50Cr = minsToHrs(getPercentile(crTimesSorted, 0.5)); + const p90Cr = minsToHrs(getPercentile(crTimesSorted, 0.9)); + const p99Cr = minsToHrs(getPercentile(crTimesSorted, 0.99)); + return { lastUpdated: new Date().toISOString(), - assigneeData, monthlyDone, issuesList, warnings, + assigneeData, monthlyDone, issuesList, warnings, currentStates, deliveryData: { avgLeadTime: deliveryData.doneTicketsLead > 0 ? Math.round(deliveryData.totalLeadTimeDays / deliveryData.doneTicketsLead) : 0, avgCycleTime: deliveryData.doneTicketsCycle > 0 ? Math.round(deliveryData.totalCycleTimeDays / deliveryData.doneTicketsCycle) : 0, @@ -287,7 +352,7 @@ function processIssues(issues) { spDeviationPct: deliveryData.doneSpOriginal > 0 ? ((deliveryData.doneSpFinal - deliveryData.doneSpOriginal) / deliveryData.doneSpOriginal * 100).toFixed(1) : 0 }, crData: { - ...crData, monthlyAvgCr, + ...crData, monthlyAvgCr, p50Cr, p90Cr, p99Cr, avgTimeCr: crData.tickets > 0 ? minsToHrs(crData.totalTimeCr / crData.tickets) : 0 }, qaData: { @@ -316,7 +381,7 @@ exports.handler = async (event) => { if (!config.jira.email || !config.jira.token) throw new Error('Faltan credenciales JIRA en variables de entorno'); const jiraData = await fetchJiraIssues(config.jira.email, config.jira.token, period); - const data = processIssues(jiraData.issues); + const data = processIssues(jiraData.issues, period); data.isTruncated = jiraData.isTruncated; data.jiraTotal = jiraData.isTruncated ? "1500+" : jiraData.issues.length;