/**
* 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 += `
|
|
${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 += `
|
${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} |
`;
});
}