—
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;