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 |
+
+
+
+
+
+
+
+
+
+
+
+
—
+
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
+
+
+
+ | Ticket |
+ Resumen |
+ ⏱️ Tiempo en QA (hs) |
+ ⏳ Cola RTQA (hs) |
+ 🔄 Veces devuelto |
+ Estado |
+
+
+
+
+
+
+
+
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 += `
|
- ${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 += `
|
${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,
+ }
+ });
+ },
};
| |