-
-
-
+
+
+
—
SP En Progreso
Trabajo en curso
—
SP Por Hacer
Backlog del periodo
—
Devs con Carga
Con tickets pendientes
—
SP En Progreso
Trabajo en curso
—
SP Por Hacer
Backlog del periodo
—
Devs con Carga
Con tickets pendientes
@@ -103,9 +103,14 @@
-
-
-
+
+
+
+
+ —
SP Completados
en el período
—
Desarrolladores
que completaron SP
—
SP promedio / dev
en el período
—
SP Completados
en el período
—
Desarrolladores
que completaron SP
—
SP promedio / dev
en el período
+
+
+
—
Promedio Lead Time
Días (Creación a Fin)
—
Promedio Cycle Time
Días (En Curso a Fin)
—
Desviación SP
Original vs Final
@@ -133,6 +138,45 @@
+
+
+
+
+
+Revisión de Código (Code Review)
+ Métricas de revisión entre pares antes de QA +
+
+
+
+
+
+
+
+
+
+
+ —
+ Tickets en CR
+ Procesados en el período
+
+
+ —
+ Tiempo Prom. CR
+ Horas promedio por ticket
+
+
+ —
+ Tickets Atascados
+ Demorados actualmente en revisión
+
+
+
+
+ 📈 Tiempo Promedio en Code Review — Mes a Mes
+Evolución de la velocidad de revisión de pares (horas)
+
+
@@ -145,22 +189,22 @@
-
+
-
—
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
diff --git a/js/app.js b/js/app.js
index 1ce4068..79b6e0f 100644
--- a/js/app.js
+++ b/js/app.js
@@ -70,6 +70,7 @@ async function loadAndRender(forceRefresh = false) {
renderCurrentSection();
renderHistorySection();
+ renderCRSection();
renderQASection();
} catch(e) {
@@ -280,6 +281,17 @@ function renderHistorySection() {
});
}
+ // Delivery KPIs
+ if (data.deliveryData) {
+ const { avgLeadTime, avgCycleTime, spOriginal, spFinal, spDeviationPct } = data.deliveryData;
+ document.getElementById('kpi-lead-time').textContent = avgLeadTime;
+ document.getElementById('kpi-cycle-time').textContent = avgCycleTime;
+
+ const deviationPrefix = spDeviationPct > 0 ? '+' : '';
+ document.getElementById('kpi-sp-deviation').textContent = `${deviationPrefix}${spDeviationPct}%`;
+ document.getElementById('kpi-sp-deviation-sub').textContent = `${spOriginal} orig. → ${spFinal} fin.`;
+ }
+
// Gráfico de Velocidad Mensual
const months = Object.keys(data.monthlyDone).sort();
const allDevsInHistory = [...new Set(months.flatMap(m => Object.keys(data.monthlyDone[m])))];
@@ -294,6 +306,34 @@ function renderHistorySection() {
})), { plugins:{ legend:{ position:'bottom'} }, scales:{ x:{stacked:true,grid:{display:false}}, y:{stacked:true} } });
}
+// ── CODE REVIEW SECTION ────────────────────────────────────────────────────
+function renderCRSection() {
+ const data = api.data;
+ if (!data || !data.crData) return;
+
+ const cr = data.crData;
+
+ document.getElementById('kpi-cr-tickets').textContent = cr.tickets ?? '—';
+ document.getElementById('kpi-cr-avg').textContent = cr.avgTimeCr != null ? cr.avgTimeCr + 'hs' : '—';
+ document.getElementById('kpi-cr-stalled').textContent = (cr.stalledIssues?.length) || '0';
+
+ const crMonths = Object.keys(cr.monthlyAvgCr || {}).sort();
+ chartsManager.drawLineChart(
+ 'crMonthly', 'chartCrMonthly',
+ crMonths,
+ [{
+ label: 'Prom. tiempo en CR (hs)',
+ data: crMonths.map(m => cr.monthlyAvgCr[m] || 0),
+ borderColor: '#00b8d9',
+ backgroundColor: 'rgba(0,184,217,.12)',
+ fill: true,
+ pointBackgroundColor: '#00b8d9',
+ borderWidth: 2,
+ }],
+ { plugins:{ legend:{ display:false } }, scales:{ x:{grid:{display:false}}, y:{ beginAtZero:true } } }
+ );
+}
+
// ── QA SECTION ─────────────────────────────────────────────────────────────
function renderQASection() {
const data = api.data;
@@ -379,7 +419,8 @@ function renderQASection() {
return;
}
- sorted.forEach(i => {
+ const top5 = sorted.slice(0, 5);
+ top5.forEach(i => {
const isCritical = i.timeQa > 4;
const isReturned = i.timesReturned > 1;
const jiraUrl = `https://${host}/browse/${i.key}`;
diff --git a/netlify/functions/data.js b/netlify/functions/data.js
index 0c01c0d..4da5fe4 100644
--- a/netlify/functions/data.js
+++ b/netlify/functions/data.js
@@ -24,6 +24,11 @@ const config = {
qaTotalTimeQa: 'customfield_10825',
qaLastDurQa: 'customfield_10826',
timesReturned: 'customfield_11217',
+ spFinalReal: 'customfield_11216',
+ crEntry: 'customfield_10818',
+ crTotalTime: 'customfield_10819',
+ enCursoEntry: 'customfield_10815',
+ enCursoTotalTime: 'customfield_10816',
},
jql: process.env.JIRA_JQL || 'project = ${PROJECT} AND ${FIELD_SP_ESTIMATE} is not EMPTY ORDER BY updated DESC',
api: {
@@ -99,7 +104,9 @@ function buildFieldsList() {
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,
- config.fields.qaLastDurQa, config.fields.timesReturned,
+ config.fields.qaLastDurQa, config.fields.timesReturned, config.fields.spFinalReal,
+ config.fields.crEntry, config.fields.crTotalTime, config.fields.enCursoEntry,
+ config.fields.enCursoTotalTime,
].join(',');
}
@@ -116,15 +123,17 @@ function processIssues(issues) {
const now = new Date();
const qaData = {
- tickets: 0,
- ticketsReturned: 0,
- totalTimeQa: 0,
- totalTimeRtqa: 0,
- monthlyTotalQa: {},
- monthlyCountQa: {},
- monthlyTotalRtqa: {},
- monthlyCountRtqa: {},
- issues: [],
+ tickets: 0, ticketsReturned: 0, totalTimeQa: 0, totalTimeRtqa: 0,
+ monthlyTotalQa: {}, monthlyCountQa: {}, monthlyTotalRtqa: {}, monthlyCountRtqa: {}, issues: [],
+ };
+
+ const deliveryData = {
+ totalLeadTimeDays: 0, totalCycleTimeDays: 0, doneTicketsLead: 0, doneTicketsCycle: 0,
+ doneSpOriginal: 0, doneSpFinal: 0
+ };
+
+ const crData = {
+ tickets: 0, totalTimeCr: 0, issues: [], monthlyTotalCr: {}, monthlyCountCr: {}, stalledIssues: []
};
for (const issue of issues) {
@@ -138,6 +147,10 @@ function processIssues(issues) {
} else {
devs = ['Sin asignar'];
}
+
+ const INACTIVE_DEVS = ['Felipe Esposito', 'heiner', 'Ciro Bruno'];
+ devs = devs.filter(d => !INACTIVE_DEVS.includes(d));
+ if (devs.length === 0) continue;
const sp = (f[config.fields.spFinal] != null ? f[config.fields.spFinal] : f[config.fields.spEstimate]) ?? 0;
const statusKey = f.status?.statusCategory?.key ?? 'new';
@@ -167,6 +180,26 @@ function processIssues(issues) {
monthlyDone[month][dev] = (monthlyDone[month][dev] ?? 0) + sp;
}
}
+
+ // Delivery Metrics
+ const createdDate = f.created ? new Date(f.created) : null;
+ const resolvedDate = f.resolutiondate ? new Date(f.resolutiondate) : null;
+ const enCursoDate = f[config.fields.enCursoEntry] ? new Date(f[config.fields.enCursoEntry]) : null;
+ const spOriginal = f[config.fields.spEstimate] || 0;
+ const spReal = f[config.fields.spFinalReal] != null ? f[config.fields.spFinalReal] : spOriginal;
+
+ if (resolvedDate) {
+ if (createdDate) {
+ deliveryData.totalLeadTimeDays += (resolvedDate - createdDate) / (1000 * 60 * 60 * 24);
+ deliveryData.doneTicketsLead++;
+ }
+ if (enCursoDate) {
+ deliveryData.totalCycleTimeDays += (resolvedDate - enCursoDate) / (1000 * 60 * 60 * 24);
+ deliveryData.doneTicketsCycle++;
+ }
+ deliveryData.doneSpOriginal += spOriginal;
+ deliveryData.doneSpFinal += spReal;
+ }
}
issuesList.push({ id: issue.id, key: issue.key, summary: f.summary, status: statusKey, sp, devs, creMonth });
@@ -195,6 +228,25 @@ function processIssues(issues) {
qaData.monthlyTotalRtqa[qaMonth] = (qaData.monthlyTotalRtqa[qaMonth] || 0) + timeRtqaMin;
qaData.monthlyCountRtqa[qaMonth] = (qaData.monthlyCountRtqa[qaMonth] || 0) + 1;
}
+
+ const timeCrMin = f[config.fields.crTotalTime] ?? 0;
+ if (timeCrMin > 0) {
+ crData.tickets++;
+ crData.totalTimeCr += timeCrMin;
+ if (qaMonth) {
+ crData.monthlyTotalCr[qaMonth] = (crData.monthlyTotalCr[qaMonth] || 0) + timeCrMin;
+ crData.monthlyCountCr[qaMonth] = (crData.monthlyCountCr[qaMonth] || 0) + 1;
+ }
+ crData.issues.push({ key: issue.key, summary: f.summary, timeCr: minsToHrs(timeCrMin), status: statusKey });
+ }
+
+ const crEntryDate = f[config.fields.crEntry];
+ if (crEntryDate && !isDone && !f[config.fields.qaEntryRtqa]) {
+ const daysInCr = (now - new Date(crEntryDate)) / (1000 * 60 * 60 * 24);
+ if (daysInCr > 2) {
+ crData.stalledIssues.push({ key: issue.key, summary: f.summary, days: Math.round(daysInCr * 10)/10 });
+ }
+ }
if (isInProgress && f.updated) {
const days = (now - new Date(f.updated)) / (1000 * 60 * 60 * 24);
@@ -221,9 +273,23 @@ function processIssues(issues) {
}
}
+ const monthlyAvgCr = {};
+ for (const m of Object.keys(crData.monthlyTotalCr)) monthlyAvgCr[m] = minsToHrs(crData.monthlyTotalCr[m] / crData.monthlyCountCr[m]);
+
return {
lastUpdated: new Date().toISOString(),
assigneeData, monthlyDone, issuesList, warnings,
+ deliveryData: {
+ avgLeadTime: deliveryData.doneTicketsLead > 0 ? Math.round(deliveryData.totalLeadTimeDays / deliveryData.doneTicketsLead) : 0,
+ avgCycleTime: deliveryData.doneTicketsCycle > 0 ? Math.round(deliveryData.totalCycleTimeDays / deliveryData.doneTicketsCycle) : 0,
+ spOriginal: deliveryData.doneSpOriginal,
+ spFinal: deliveryData.doneSpFinal,
+ spDeviationPct: deliveryData.doneSpOriginal > 0 ? ((deliveryData.doneSpFinal - deliveryData.doneSpOriginal) / deliveryData.doneSpOriginal * 100).toFixed(1) : 0
+ },
+ crData: {
+ ...crData, monthlyAvgCr,
+ avgTimeCr: crData.tickets > 0 ? minsToHrs(crData.totalTimeCr / crData.tickets) : 0
+ },
qaData: {
...qaData, monthlyAvgQa, monthlyAvgRtqa,
avgTimeQa: qaData.tickets > 0 ? minsToHrs(qaData.totalTimeQa / qaData.tickets) : 0,