Migración a Python/Flask y adaptación para proyecto Infra
- Reemplaza Netlify Functions con servidor Python/Flask (server.py) - Adapta dashboard para contar tickets en lugar de Story Points - Conecta al proyecto IO (Infra) de Jira - Elimina secciones de QA y Code Review - Agrega métricas de solicitudes semanales y reporters - Excluye devs inactivos y unifica nombres duplicados Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -70,8 +70,6 @@ async function loadAndRender(forceRefresh = false) {
|
||||
|
||||
renderCurrentSection();
|
||||
renderHistorySection();
|
||||
renderCRSection();
|
||||
renderQASection();
|
||||
|
||||
} catch(e) {
|
||||
errorBanner.style.display = 'block';
|
||||
@@ -188,10 +186,17 @@ function renderCurrentSection() {
|
||||
}
|
||||
|
||||
// Gráfico: Comparativa IP vs Todo (Stacked)
|
||||
const people = Object.keys(ad).sort((a,b) => (ad[b].in_progress+ad[b].todo)-(ad[a].in_progress+ad[a].todo));
|
||||
// "Sin asignar" siempre va último y con color gris
|
||||
const people = Object.keys(ad).sort((a, b) => {
|
||||
if (a === 'Sin asignar') return 1;
|
||||
if (b === 'Sin asignar') return -1;
|
||||
return (ad[b].in_progress + ad[b].todo) - (ad[a].in_progress + ad[a].todo);
|
||||
});
|
||||
const colorIP = people.map(p => p === 'Sin asignar' ? '#97a0af' : '#0065ff');
|
||||
const colorTodo = people.map(p => p === 'Sin asignar' ? '#c1c7d0' : '#ff8b00');
|
||||
chartsManager.drawBarChart('currentState', 'chartCurrentState', people.map(shortName), [
|
||||
{ label:'En Progreso', data:people.map(p=>ad[p].in_progress), backgroundColor:'#0065ff', borderRadius:3 },
|
||||
{ label:'Por Hacer', data:people.map(p=>ad[p].todo), backgroundColor:'#ff8b00', borderRadius:3 },
|
||||
{ label:'En Progreso', data:people.map(p=>ad[p].in_progress), backgroundColor:colorIP, borderRadius:3 },
|
||||
{ label:'Por Hacer', data:people.map(p=>ad[p].todo), backgroundColor:colorTodo, borderRadius:3 },
|
||||
], { plugins:{ legend:{ position:'bottom'} }, scales:{ x:{stacked:true,grid:{display:false}}, y:{stacked:true} } });
|
||||
|
||||
// Tabla detallada (Solo pendientes)
|
||||
@@ -199,10 +204,20 @@ function renderCurrentSection() {
|
||||
tbody.innerHTML = '';
|
||||
people.forEach(p => {
|
||||
const d = ad[p], total = d.in_progress + d.todo;
|
||||
const c = chartsManager.personColor(p);
|
||||
const c = p === 'Sin asignar' ? '#97a0af' : chartsManager.personColor(p);
|
||||
|
||||
const popoverIP = d.issues_ip.map(i => `<div class="pop-item"><a href="${getJiraUrl(i.key)}" target="_blank">${i.key}</a>: ${i.summary.slice(0,30)}... <strong>(${i.sp} SP)</strong></div>`).join('');
|
||||
const popoverTodo = d.issues_todo.map(i => `<div class="pop-item"><a href="${getJiraUrl(i.key)}" target="_blank">${i.key}</a>: ${i.summary.slice(0,30)}... <strong>(${i.sp} SP)</strong></div>`).join('');
|
||||
const hasIPAlert = d.issues_ip.some(i => i.warnings && i.warnings.some(w => w.type === 'expired' || w.type === 'stalled'));
|
||||
const hasTodoAlert = d.issues_todo.some(i => i.warnings && i.warnings.some(w => w.type === 'expired' || w.type === 'stalled'));
|
||||
|
||||
const genPopover = (issues) => issues.map(i => {
|
||||
let badge = '';
|
||||
if(i.warnings && i.warnings.some(w => w.type === 'expired')) badge = '<span style="color:#ff5630;font-size:11px;font-weight:bold;margin-left:4px;">(Vencido)</span>';
|
||||
else if(i.warnings && i.warnings.some(w => w.type === 'stalled')) badge = '<span style="color:#ffab00;font-size:11px;font-weight:bold;margin-left:4px;">(Estancado)</span>';
|
||||
return `<div class="pop-item" style="display:flex;align-items:center;white-space:nowrap;"><a href="${getJiraUrl(i.key)}" target="_blank" style="margin-right:6px;">${i.key}</a> <span style="overflow:hidden;text-overflow:ellipsis;max-width:180px;" title="${i.summary.replace(/"/g, '"')}">${i.summary}</span> <strong style="margin-left:auto;padding-left:8px;">(${i.sp} SP)</strong>${badge}</div>`;
|
||||
}).join('');
|
||||
|
||||
const popoverIP = genPopover(d.issues_ip);
|
||||
const popoverTodo = genPopover(d.issues_todo);
|
||||
|
||||
tbody.innerHTML += `<tr>
|
||||
<td>
|
||||
@@ -210,17 +225,17 @@ function renderCurrentSection() {
|
||||
</td>
|
||||
<td>
|
||||
<div class="pop-trigger">
|
||||
<span class="pill progress">${d.in_progress} SP</span>
|
||||
<span class="pill progress" ${hasIPAlert ? 'style="border-color:#ff5630;color:#ff5630;"' : ''}>${d.in_progress} tkts${hasIPAlert ? ' ⚠️' : ''}</span>
|
||||
<div class="pop-content">${popoverIP || 'No hay tickets'}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="pop-trigger">
|
||||
<span class="pill todo">${d.todo} SP</span>
|
||||
<span class="pill todo" ${hasTodoAlert ? 'style="border-color:#ff5630;color:#ff5630;"' : ''}>${d.todo} tkts${hasTodoAlert ? ' ⚠️' : ''}</span>
|
||||
<div class="pop-content">${popoverTodo || 'No hay tickets'}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td><strong>${total} SP</strong></td>
|
||||
<td><strong>${total} tkts</strong></td>
|
||||
<td><div class="pbar-wrap">
|
||||
<div class="pbar-ip" style="width:${pct(d.in_progress, total)}%"></div>
|
||||
<div class="pbar-todo" style="width:${pct(d.todo, total)}%"></div>
|
||||
@@ -266,9 +281,23 @@ function renderHistorySection() {
|
||||
labelEl.style.display = 'none';
|
||||
}
|
||||
|
||||
// Calcular semanas del período seleccionado
|
||||
const periodDays = state.selectedPeriod === 'all' ? 365
|
||||
: state.selectedPeriod === '1d' ? 1
|
||||
: parseInt(state.selectedPeriod.replace('d', ''));
|
||||
const periodWeeks = Math.max(periodDays / 7, 1);
|
||||
const totalTickets = Object.values(periodTicketCount).reduce((a,b) => a+b, 0);
|
||||
const avgPerWeek = (totalTickets / periodWeeks).toFixed(1);
|
||||
|
||||
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;
|
||||
document.getElementById('kpi-avg-period').textContent = avgPerWeek;
|
||||
|
||||
// Tickets solicitados (creados) por semana
|
||||
const ticketsCreated = data.ticketsCreated || 0;
|
||||
const avgCreatedPerWeek = (ticketsCreated / periodWeeks).toFixed(1);
|
||||
document.getElementById('kpi-created-week').textContent = avgCreatedPerWeek;
|
||||
const createdSubEl = document.getElementById('kpi-created-week-sub');
|
||||
if (createdSubEl) createdSubEl.textContent = `${ticketsCreated} tkts en el período`;
|
||||
|
||||
// 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]);
|
||||
@@ -303,195 +332,26 @@ function renderHistorySection() {
|
||||
|
||||
tbody.innerHTML += `<tr>
|
||||
<td><div class="name-cell"><div class="avatar" style="background:${c}">${initials(p)}</div>${p}</div></td>
|
||||
<td><span class="pill done">${sp} SP</span></td>
|
||||
<td><strong>${tickets} tkts</strong></td>
|
||||
<td><span style="font-size:13px;color:#172b4d;">${avgPerTicket} SP/tkt</span></td>
|
||||
<td><span class="pill done">${tickets} tkts</span></td>
|
||||
<td>${badgeHtml}</td>
|
||||
</tr>`;
|
||||
});
|
||||
}
|
||||
|
||||
// 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])))];
|
||||
|
||||
// Si hay filtro de dev, solo mostrar ese dev en el gráfico de velocidad
|
||||
const visibleDevs = state.selectedDev === 'all' ? allDevsInHistory : [state.selectedDev];
|
||||
|
||||
chartsManager.drawBarChart('velocity', 'chartVelocity', months, visibleDevs.map(p => ({
|
||||
label: shortName(p),
|
||||
data: months.map(m => data.monthlyDone[m][p] || 0),
|
||||
backgroundColor: chartsManager.personColor(p), borderRadius: 3
|
||||
})), { 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;
|
||||
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-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();
|
||||
chartsManager.drawLineChart(
|
||||
'crMonthly', 'chartCrMonthly',
|
||||
crMonths,
|
||||
// Gráfico de Solicitudes por persona (reporter)
|
||||
const reporterData = data.reporterData || {};
|
||||
const sortedReporters = Object.entries(reporterData).sort((a, b) => b[1] - a[1]);
|
||||
chartsManager.drawBarChart('velocity', 'chartVelocity',
|
||||
sortedReporters.map(([p]) => shortName(p)),
|
||||
[{
|
||||
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,
|
||||
label: 'Tickets solicitados',
|
||||
data: sortedReporters.map(([, v]) => v),
|
||||
backgroundColor: sortedReporters.map(([p]) => chartsManager.personColor(p)),
|
||||
borderRadius: 5
|
||||
}],
|
||||
{ plugins:{ legend:{ display:false } }, scales:{ x:{grid:{display:false}}, y:{ beginAtZero:true } } }
|
||||
{ plugins:{ legend:{ display:false } }, scales:{ x:{ grid:{ display:false } } } }
|
||||
);
|
||||
}
|
||||
|
||||
// ── QA SECTION ─────────────────────────────────────────────────────────────
|
||||
function renderQASection() {
|
||||
const data = api.data;
|
||||
if (!data || !data.qaData) return;
|
||||
|
||||
const qa = data.qaData;
|
||||
const cur = data.currentStates || { readyToQa: {count:0, sp:0}, inQa: {count:0, sp:0} };
|
||||
|
||||
// ── 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-returned').textContent = qa.ticketsReturned ?? '—';
|
||||
|
||||
document.getElementById('kpi-qa-p50').textContent = qa.p50Qa != null ? qa.p50Qa + 'hs' : '—';
|
||||
document.getElementById('kpi-qa-p90').textContent = qa.p90Qa != null ? qa.p90Qa + 'hs' : '—';
|
||||
document.getElementById('kpi-qa-p99-sub').textContent = qa.p99Qa != null ? 'P99: ' + qa.p99Qa + ' hs' : 'P99: — hs';
|
||||
|
||||
document.getElementById('kpi-qa-rtqa-p50').textContent = qa.p50Rtqa != null ? qa.p50Rtqa + 'hs' : '—';
|
||||
document.getElementById('kpi-qa-rtqa-p90').textContent = qa.p90Rtqa != null ? qa.p90Rtqa + 'hs' : '—';
|
||||
document.getElementById('kpi-qa-rtqa-p99-sub').textContent = qa.p99Rtqa != null ? 'P99: ' + qa.p99Rtqa + ' hs' : 'P99: — hs';
|
||||
|
||||
const pctRet = qa.tickets > 0 ? ((qa.ticketsReturned / qa.tickets) * 100).toFixed(1) : 0;
|
||||
document.getElementById('kpi-qa-returned-pct').textContent =
|
||||
`${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 };
|
||||
(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',
|
||||
['✅ Flujo directo (0 rechazos)', '🔄 Retrabajo (Rechazo en CR/QA)'],
|
||||
[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 = '<tr><td colspan="6" class="no-data">No hay tickets con datos de QA en este período</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
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}`;
|
||||
|
||||
const statusLabel = i.status === 'done'
|
||||
? '<span class="pill done">✅ Done</span>'
|
||||
: i.status === 'indeterminate'
|
||||
? '<span class="pill progress">🔵 En curso</span>'
|
||||
: '<span class="pill todo">⏳ Pendiente</span>';
|
||||
|
||||
const returnBadge = isReturned
|
||||
? `<span class="pill returned">🔄 ${i.timesReturned}x</span>`
|
||||
: `<span class="pill qa">1x</span>`;
|
||||
|
||||
tbody.innerHTML += `<tr class="${isCritical ? 'row-critical' : ''}">
|
||||
<td><a href="${jiraUrl}" target="_blank" class="jira-link">${i.key}</a></td>
|
||||
<td style="max-width:280px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="${i.summary}">${i.summary}</td>
|
||||
<td style="color:#0065ff;">${i.timeDev > 0 ? i.timeDev + 'hs' : '—'}</td>
|
||||
<td style="color:#00b8d9;">${i.timeCr > 0 ? i.timeCr + 'hs' : '—'}</td>
|
||||
<td><strong style="color:${isCritical ? '#ff5630' : '#36b37e'}">${i.timeQa}hs</strong>${isCritical ? ' ⚠️' : ''}</td>
|
||||
<td>${returnBadge}</td>
|
||||
<td>${statusLabel}</td>
|
||||
</tr>`;
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user