diff --git a/index.html b/index.html
index aafc409..0def4ba 100644
--- a/index.html
+++ b/index.html
@@ -71,7 +71,18 @@
-
+
—
-
Tickets Devueltos
-
Volvieron a En Curso
+
Tickets con Retrabajo
+
Sufrieron rechazos
-
🔄 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 += `
|
- ${sp} SP entregados |
- ${tickets} tickets |
- |
+ ${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;