/** * js/app.js * Controlador principal de la UI. Lee desde api.js y actualiza el DOM + charts.js */ import { api } from './api.js'; import { chartsManager } from './charts.js'; // utils function shortName(name) { const p = name.split(' '); return p[0] + (p[1] ? ' ' + p[1] : ''); } function initials(name) { return name.split(' ').map(w=>w[0]).join('').slice(0,2).toUpperCase(); } function pct(val, total) { return total > 0 ? (val / total * 100).toFixed(1) : 0; } const state = { selectedDev: 'all', selectedPeriod: '90d' }; // ── INIT ─────────────────────────────────────────────────────────────────── document.addEventListener('DOMContentLoaded', () => { // Load from localStorage const savedDev = localStorage.getItem('dash_selectedDev'); const savedPeriod = localStorage.getItem('dash_selectedPeriod'); if (savedDev) state.selectedDev = savedDev; if (savedPeriod) state.selectedPeriod = savedPeriod; document.getElementById('refreshBtn').addEventListener('click', () => loadAndRender(true)); document.getElementById('devFilter').addEventListener('change', (e) => { state.selectedDev = e.target.value; localStorage.setItem('dash_selectedDev', state.selectedDev); renderCurrentSection(); renderHistorySection(); }); document.getElementById('timeRangeFilter').addEventListener('change', (e) => { state.selectedPeriod = e.target.value; localStorage.setItem('dash_selectedPeriod', state.selectedPeriod); loadAndRender(false); }); // Set initial value in UI from state (which might come from localStorage) const periodFilter = document.getElementById('timeRangeFilter'); if (periodFilter) periodFilter.value = state.selectedPeriod; loadAndRender(); }); async function loadAndRender(forceRefresh = false) { const loading = document.getElementById('loadingOverlay'); const errorBanner = document.getElementById('errorBanner'); loading.style.display = 'flex'; errorBanner.style.display = 'none'; document.getElementById('refreshBtn').disabled = true; try { const data = await api.fetchDashboardData(forceRefresh, state.selectedPeriod); const limitBanner = document.getElementById('limitBanner'); if (data.isTruncated) { limitBanner.style.display = 'block'; limitBanner.textContent = `⚠️ Advertencia de límite: Solo se procesaron 1500 tickets en este período debido a las políticas de seguridad (Total real encontrado: ${data.jiraTotal}). Reducí el rango de fechas para ver el detalle exacto.`; } else { limitBanner.style.display = 'none'; } buildFilters(data); renderHeader(data); renderHealthWarnings(data.warnings); renderCurrentSection(); renderHistorySection(); renderCRSection(); renderQASection(); } catch(e) { errorBanner.style.display = 'block'; errorBanner.textContent = '⚠️ Error al cargar datos: ' + e.message; } finally { loading.style.display = 'none'; document.getElementById('refreshBtn').disabled = false; } } // ── FILTERS & HEADER ─────────────────────────────────────────────────────── function buildFilters(data) { const devsSet = new Set(); data.issuesList.forEach(issue => { issue.devs.forEach(d => devsSet.add(d)); }); const devSel = document.getElementById('devFilter'); devSel.innerHTML = '' + [...devsSet].sort().map(d => ``).join(''); devSel.value = state.selectedDev; } function renderHeader(data) { const d = new Date(data.lastUpdated); document.getElementById('lastUpdatedLabel').textContent = 'Actualizado: ' + d.toLocaleString('es-AR', { day:'2-digit', month:'2-digit', year:'numeric', hour:'2-digit', minute:'2-digit' }); document.getElementById('cacheBadge').textContent = data.fromCache ? '(desde caché)' : '(CACHE)'; } function getJiraUrl(key) { const host = api.data?.config?.host || 'invera.atlassian.net'; return `https://${host}/browse/${key}`; } // ── HEALTH WARNINGS ──────────────────────────────────────────────────────── function renderHealthWarnings(warnings = []) { const container = document.getElementById('warningsSection'); const list = document.getElementById('warningsList'); const noMsg = document.getElementById('noWarningsMsg'); if(!warnings || warnings.length === 0) { container.style.display = 'none'; if (noMsg) noMsg.style.display = 'block'; return; } container.style.display = 'block'; if (noMsg) noMsg.style.display = 'none'; list.innerHTML = warnings.map(w => { let text = ''; let badge = w.type; 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}`; } return `
  • ${badge} ${text}
  • `; }).join(''); } // ── CURRENT SECTION (Interactive filtered) ───────────────────────────────── function renderCurrentSection() { const data = api.data; if (!data) return; // 1. Filtrar solo pendientes (En progreso o To Do) + Filtro de Dev let allPending = data.issuesList.filter(i => i.status !== 'done'); // Para el label "Mostrando X de Y", filtramos por Dev let filtered = allPending; if (state.selectedDev !== 'all') { filtered = allPending.filter(i => i.devs.includes(state.selectedDev)); } // Agrupar métricas para los KPIs (basado en el filtro de Dev) const ad = {}; let totalDevs = new Set(); filtered.forEach(i => { i.devs.forEach(dev => { if(state.selectedDev !== 'all' && dev !== state.selectedDev) return; if (!ad[dev]) ad[dev] = { in_progress: 0, todo: 0, issues_ip: [], issues_todo: [] }; if (i.status === 'indeterminate') { ad[dev].in_progress += i.sp; ad[dev].issues_ip.push(i); } else { ad[dev].todo += i.sp; ad[dev].issues_todo.push(i); } totalDevs.add(dev); }); }); let ip = 0, todo = 0; Object.values(ad).forEach(d => { ip += d.in_progress; todo += d.todo; }); document.getElementById('kpi-ip').textContent = ip; document.getElementById('kpi-todo').textContent = todo; document.getElementById('kpi-members').textContent = totalDevs.size; // Conteo de tickets const label = document.getElementById('currentTicketsLabel'); if (label) { label.textContent = state.selectedDev === 'all' ? `Hay ${allPending.length} tickets pendientes en total` : `Mostrando ${filtered.length} tickets pendientes para ${state.selectedDev}`; } // 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)); 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 }, ], { plugins:{ legend:{ position:'bottom'} }, scales:{ x:{stacked:true,grid:{display:false}}, y:{stacked:true} } }); // Tabla detallada (Solo pendientes) const tbody = document.getElementById('tableCurrentState'); tbody.innerHTML = ''; people.forEach(p => { const d = ad[p], total = d.in_progress + d.todo; const c = chartsManager.personColor(p); const popoverIP = d.issues_ip.map(i => `
    ${i.key}: ${i.summary.slice(0,30)}... (${i.sp} SP)
    `).join(''); const popoverTodo = d.issues_todo.map(i => `
    ${i.key}: ${i.summary.slice(0,30)}... (${i.sp} SP)
    `).join(''); tbody.innerHTML += `
    ${initials(p)}
    ${p}
    ${d.in_progress} SP
    ${popoverIP || 'No hay tickets'}
    ${d.todo} SP
    ${popoverTodo || 'No hay tickets'}
    ${total} SP
    `; }); } // ── HISTORY SECTION ──────────────────────────────────────────────────────── function renderHistorySection() { const data = api.data; if(!data) return; // Consolidar todos los meses del rango actual 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; periodData[p] = (periodData[p] || 0) + i.sp; periodTicketCount[p] = (periodTicketCount[p] || 0) + 1; }); }); const totalSP = Object.values(periodData).reduce((a,b) => a+b, 0); 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; 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'; } 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 (sortedAll.length === 0) { tbody.innerHTML = 'No hay entregas en este período'; } else { 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 += `
    ${initials(p)}
    ${p}
    ${sp} SP ${tickets} tkts ${avgPerTicket} SP/tkt ${badgeHtml} `; }); } // 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, [{ 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; 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 = 'No hay tickets con datos de QA en este período'; 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' ? '✅ Done' : i.status === 'indeterminate' ? '🔵 En curso' : '⏳ Pendiente'; const returnBadge = isReturned ? `🔄 ${i.timesReturned}x` : `1x`; tbody.innerHTML += ` ${i.key} ${i.summary} ${i.timeDev > 0 ? i.timeDev + 'hs' : '—'} ${i.timeCr > 0 ? i.timeCr + 'hs' : '—'} ${i.timeQa}hs${isCritical ? ' ⚠️' : ''} ${returnBadge} ${statusLabel} `; }); }