refactor: implement dashboard UI enhancements, add health alert tooltips, and introduce modular JS application structure with Netlify data functions.

This commit is contained in:
kvilches1410
2026-04-07 17:32:33 -03:00
parent 72050df789
commit 76edc176f9
3 changed files with 91 additions and 36 deletions
+25 -10
View File
@@ -71,7 +71,18 @@
<div style="position:relative;height:260px"><canvas id="chartCurrentState"></canvas></div>
</div>
<div class="chart-card">
<h3>📢 Alertas de Salud</h3>
<h3 style="display:flex;align-items:center;">📢 Alertas de Salud
<div class="pop-trigger" style="margin-left:8px; font-size:14px; cursor:help; border:none;">ℹ️
<div class="pop-content" style="text-transform:none; font-weight:normal; letter-spacing:normal; text-align:left; font-size:12px; width:260px;">
<strong>Tipos de Alertas:</strong><br><br>
<span class="badge" style="background:#ffab00;color:white;font-size:10px;padding:2px 4px;">ESTANCADO</span> &rsaquo; +14 días en curso.<br><br>
<span class="badge" style="background:#ff5630;color:white;font-size:10px;padding:2px 4px;">VENCIDO</span> &rsaquo; Fecha de entrega caducada.<br><br>
<span class="badge" style="background:#6554c0;color:white;font-size:10px;padding:2px 4px;">SIN SP</span> &rsaquo; Completado sin estimación.<br><br>
<span class="badge" style="background:#ff8b00;color:white;font-size:10px;padding:2px 4px;">HUÉRFANO</span> &rsaquo; Ticket con SP sin asignar.<br><br>
<span class="badge" style="background:#36b37e;color:white;font-size:10px;padding:2px 4px;">DEV LIBRE</span> &rsaquo; Dev sin tickets pendientes ni en curso.
</div>
</div>
</h3>
<div class="sub">Tickets estancados o sin estimación</div>
<div id="warningsSection" style="display:none; overflow-y:auto; max-height:230px; margin-top:10px;">
<ul class="warning-list" id="warningsList" style="padding:0; margin:0;"></ul>
@@ -127,9 +138,12 @@
</div>
<div class="table-card">
<h3>SP Completados — Tabla Resumen</h3>
<h3 style="display:flex; justify-content:space-between; align-items:center;">
<span>SP Completados — Tabla Resumen</span>
<span id="teamAvgLabel" style="font-size:12px; font-weight:normal; background:#e6fcff; color:#0065ff; padding:4px 8px; border-radius:4px; display:none;"></span>
</h3>
<table>
<thead><tr><th>Desarrollador</th><th>✅ SP Completados</th><th>🎟️ Tickets</th><th>% del impacto total</th></tr></thead>
<thead><tr><th>Desarrollador</th><th>✅ SP Total</th><th>🎟️ Tickets</th><th>📉 Media SP/Ticket</th><th>% del impacto total vs Equipo</th></tr></thead>
<tbody id="tableHistory"></tbody>
</table>
</div>
@@ -262,10 +276,10 @@
</div>
<div class="kpi-row-3">
<div class="kpi returned" title="Tickets que rebotaron en QA y el desarrollador tuvo que retomar ('Reaperturas' o regresos al ciclo En Curso)">
<div class="kpi returned" title="Tickets que sufrieron rechazos en revisiones (Code Review, Ready to QA o QA) y volvieron a desarrollo.">
<div class="kpi-value" id="kpi-qa-returned">—</div>
<div class="kpi-label">Tickets Devueltos</div>
<div class="kpi-sub" id="kpi-qa-returned-pct">Volvieron a En Curso</div>
<div class="kpi-label">Tickets con Retrabajo</div>
<div class="kpi-sub" id="kpi-qa-returned-pct">Sufrieron rechazos</div>
</div>
<div class="kpi qa-queue">
<div class="kpi-value" id="kpi-qa-rtqa-p50">—</div>
@@ -299,8 +313,8 @@
<div style="position:relative;height:240px"><canvas id="chartQaDist"></canvas></div>
</div>
<div class="chart-card">
<h3>🔄 Devueltos vs. Aprobados</h3>
<div class="sub">Tickets que volvieron a En Curso al menos una vez</div>
<h3>🔄 Flujo Directo vs Retrabajo</h3>
<div class="sub">Tickets con 0 rechazos vs con devoluciones (CR/QA)</div>
<div style="position:relative;height:240px"><canvas id="chartQaReturned"></canvas></div>
</div>
</div>
@@ -327,8 +341,9 @@
<tr>
<th>Ticket</th>
<th>Resumen</th>
<th>⏱️ Tiempo en QA (hs)</th>
<th>⏳ Cola RTQA (hs)</th>
<th>⏱️ Dev (hs)</th>
<th>⏱️ CR (hs)</th>
<th>⏱️ QA (hs)</th>
<th>🔄 Veces devuelto</th>
<th>Estado</th>
</tr>
+53 -23
View File
@@ -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 = '<tr><td colspan="4" class="no-data">No hay entregas en este período</td></tr>';
if (sortedAll.length === 0) {
tbody.innerHTML = '<tr><td colspan="5" class="no-data">No hay entregas en este período</td></tr>';
} 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 = `<span class="badge" style="background:#ff5630;color:white;font-size:10px;">0 SP (SIN ENTREGAS)</span>`;
} else if (p_vsTeam >= 150) {
badgeHtml = `<span style="font-size:12px;color:#36b37e;font-weight:bold;">🔥 +${p_vsTeam - 100}% sup. prom.</span>`;
} else if (p_vsTeam <= 50) {
badgeHtml = `<span style="font-size:12px;color:#ff8b00;font-weight:bold;">📉 -${100 - p_vsTeam}% del prom.</span>`;
} else {
badgeHtml = `<span style="font-size:12px;color:#5e6c84;">~ ${p_vsTeam}% del prom.</span>`;
}
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 entregados</span></td>
<td><strong>${tickets} tickets</strong></td>
<td><div style="display:flex;align-items:center;gap:8px;">
<div style="background:#f0f2f5;border-radius:4px;height:8px;width:120px;overflow:hidden;">
<div style="background:#36b37e;height:100%;width:${p_}%"></div>
</div><span style="font-size:12px;color:#5e6c84;">${p_}%</span></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>${badgeHtml}</td>
</tr>`;
});
}
@@ -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 += `<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><strong style="color:${isCritical ? '#ff5630' : '#172b4d'}">${i.timeQa}hs</strong>${isCritical ? ' ⚠️' : ''}</td>
<td>${i.timeRtqa > 0 ? i.timeRtqa + 'hs' : '—'}</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>`;
+13 -3
View File
@@ -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;