first commit

This commit is contained in:
kvilches1410
2026-03-31 12:13:19 -03:00
commit 4183bb8313
6 changed files with 598 additions and 0 deletions
+456
View File
@@ -0,0 +1,456 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dashboard LABS — Story Points</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.1/chart.umd.min.js"></script>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f0f2f5; color: #172b4d; }
header { background: #0052cc; color: white; padding: 20px 32px; display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 10px; }
header h1 { font-size: 22px; font-weight: 700; }
.header-meta { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.header-meta span { font-size: 12px; opacity: 0.75; }
.refresh-btn { background: rgba(255,255,255,.15); border: 1px solid rgba(255,255,255,.3); color: white; padding: 6px 14px; border-radius: 5px; cursor: pointer; font-size: 13px; transition: background .15s; }
.refresh-btn:hover { background: rgba(255,255,255,.25); }
.refresh-btn:disabled { opacity: .5; cursor: not-allowed; }
.cache-badge { font-size: 11px; opacity: .7; }
/* Loading */
.loading-overlay { position: fixed; inset: 0; background: rgba(240,242,245,.9); display: flex; flex-direction: column; align-items: center; justify-content: center; z-index: 100; gap: 16px; }
.spinner { width: 40px; height: 40px; border: 3px solid #dfe1e6; border-top-color: #0052cc; border-radius: 50%; animation: spin .7s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.loading-overlay p { color: #5e6c84; font-size: 14px; }
/* Error */
.error-banner { background: #ffebe6; border: 1px solid #ff5630; color: #bf2600; padding: 14px 32px; font-size: 14px; display: none; }
.section-header { padding: 14px 32px; display: flex; align-items: center; gap: 12px; border-bottom: 2px solid #dfe1e6; background: white; flex-wrap: wrap; }
.section-header.current { border-color: #0065ff; }
.section-header.history { border-color: #36b37e; }
.section-header h2 { font-size: 14px; font-weight: 700; }
.section-header .dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
.section-header.current .dot { background: #0065ff; }
.section-header.history .dot { background: #36b37e; }
.section-header label { font-size: 13px; font-weight: 600; color: #5e6c84; margin-left: auto; }
.section-header select { padding: 6px 12px; border: 1px solid #dfe1e6; border-radius: 4px; font-size: 14px; color: #172b4d; background: #fafbfc; cursor: pointer; outline: none; }
.section-header select:focus { border-color: #0052cc; }
.badge { background: #e3f2fd; color: #0052cc; padding: 4px 10px; border-radius: 12px; font-size: 12px; font-weight: 600; }
.main { padding: 24px 32px; max-width: 1400px; margin: 0 auto; }
.section-block { margin-bottom: 36px; }
.kpi-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-bottom: 24px; }
.kpi-row-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; margin-bottom: 24px; }
.kpi { background: white; border-radius: 8px; padding: 20px 24px; border-left: 4px solid #dfe1e6; box-shadow: 0 1px 3px rgba(0,0,0,.06); }
.kpi.done { border-color: #36b37e; }
.kpi.progress{ border-color: #0065ff; }
.kpi.todo { border-color: #ff8b00; }
.kpi.members { border-color: #6554c0; }
.kpi-value { font-size: 36px; font-weight: 700; line-height: 1; }
.kpi.done .kpi-value { color: #36b37e; }
.kpi.progress .kpi-value { color: #0065ff; }
.kpi.todo .kpi-value { color: #ff8b00; }
.kpi.members .kpi-value { color: #6554c0; }
.kpi-label { font-size: 12px; color: #5e6c84; margin-top: 6px; font-weight: 500; text-transform: uppercase; letter-spacing: .5px; }
.kpi-sub { font-size: 11px; color: #97a0af; margin-top: 4px; }
.charts-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-bottom: 24px; }
.chart-card { background: white; border-radius: 8px; padding: 24px; box-shadow: 0 1px 3px rgba(0,0,0,.06); }
.chart-card.full { grid-column: 1 / -1; }
.chart-card h3 { font-size: 14px; font-weight: 700; color: #172b4d; margin-bottom: 4px; }
.chart-card .sub { font-size: 12px; color: #5e6c84; margin-bottom: 20px; }
.table-card { background: white; border-radius: 8px; padding: 24px; box-shadow: 0 1px 3px rgba(0,0,0,.06); margin-bottom: 24px; }
.table-card h3 { font-size: 14px; font-weight: 700; margin-bottom: 16px; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
th { text-align: left; padding: 10px 14px; background: #f4f5f7; color: #5e6c84; font-weight: 600; font-size: 11px; text-transform: uppercase; letter-spacing: .5px; border-bottom: 1px solid #dfe1e6; }
td { padding: 12px 14px; border-bottom: 1px solid #f4f5f7; vertical-align: middle; }
tr:last-child td { border-bottom: none; }
tr:hover td { background: #f8f9fa; }
.pill { display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 11px; font-weight: 600; }
.pill.done { background: #e3fcef; color: #006644; }
.pill.progress { background: #deebff; color: #0052cc; }
.pill.todo { background: #fffae6; color: #7a4b00; }
.pbar-wrap { background: #f4f5f7; border-radius: 4px; height: 8px; width: 140px; overflow: hidden; display: flex; }
.pbar-done { background: #36b37e; height: 100%; }
.pbar-ip { background: #0065ff; height: 100%; }
.pbar-todo { background: #ff8b00; height: 100%; }
.avatar { width: 28px; height: 28px; border-radius: 50%; color: white; font-size: 11px; font-weight: 700; display: inline-flex; align-items: center; justify-content: center; margin-right: 8px; flex-shrink: 0; }
.name-cell { display: flex; align-items: center; }
.no-data { text-align: center; color: #97a0af; padding: 40px; font-size: 14px; }
footer { text-align: center; padding: 24px; font-size: 11px; color: #97a0af; }
@media (max-width: 768px) {
.kpi-row, .kpi-row-3 { grid-template-columns: 1fr 1fr; }
.charts-grid { grid-template-columns: 1fr; }
.chart-card.full { grid-column: 1; }
.main { padding: 16px; }
.section-header { gap: 8px; }
.section-header label { margin-left: 0; }
}
</style>
</head>
<body>
<!-- Loading overlay -->
<div class="loading-overlay" id="loadingOverlay">
<div class="spinner"></div>
<p>Consultando Jira en tiempo real…</p>
</div>
<!-- Error banner -->
<div class="error-banner" id="errorBanner"></div>
<header>
<div>
<h1>📊 Dashboard LABS — Story Points</h1>
<div style="font-size:13px;opacity:.7;margin-top:4px;">
Proyecto: <strong>LABS</strong> · invera.atlassian.net · Campo: Desarrollador
</div>
</div>
<div class="header-meta">
<span id="lastUpdatedLabel">—</span>
<span class="cache-badge" id="cacheBadge"></span>
<button class="refresh-btn" id="refreshBtn" onclick="loadData(true)">↻ Actualizar</button>
</div>
</header>
<!-- ── SECCIÓN 1: Estado Actual ── -->
<div class="section-header current">
<div class="dot"></div>
<h2>Estado Actual del Equipo</h2>
<span style="font-size:12px;color:#5e6c84;margin-left:8px;">Tickets activos en el tablero ahora mismo</span>
</div>
<div class="main">
<div class="section-block">
<div class="kpi-row">
<div class="kpi progress"><div class="kpi-value" id="kpi-ip">—</div><div class="kpi-label">SP En Progreso</div><div class="kpi-sub">En curso · Code Review · QA</div></div>
<div class="kpi todo"> <div class="kpi-value" id="kpi-todo">—</div><div class="kpi-label">SP Por Hacer</div><div class="kpi-sub">Backlog asignado</div></div>
<div class="kpi done"> <div class="kpi-value" id="kpi-done-total">—</div><div class="kpi-label">SP Completados (total)</div><div class="kpi-sub">Histórico acumulado</div></div>
<div class="kpi members"><div class="kpi-value" id="kpi-members">—</div><div class="kpi-label">Devs activos</div><div class="kpi-sub">Con SP asignados</div></div>
</div>
<div class="charts-grid">
<div class="chart-card">
<h3>🔵 SP en Progreso — por Desarrollador</h3>
<div class="sub">Estado actual · En curso / Code Review / QA</div>
<div style="position:relative;height:260px"><canvas id="chartInProgress"></canvas></div>
</div>
<div class="chart-card">
<h3>📦 Estado actual por Desarrollador</h3>
<div class="sub">Completados · En Progreso · Por Hacer (acumulado)</div>
<div style="position:relative;height:260px"><canvas id="chartCurrentState"></canvas></div>
</div>
</div>
<div class="table-card">
<h3>Detalle por Desarrollador — Estado Actual</h3>
<table>
<thead><tr><th>Desarrollador</th><th>✅ Completados</th><th>🔵 En Progreso</th><th>⏳ Por Hacer</th><th>Total SP</th><th>Progreso</th></tr></thead>
<tbody id="tableCurrentState"></tbody>
</table>
</div>
</div>
</div>
<!-- ── SECCIÓN 2: Completados por período ── -->
<div class="section-header history">
<div class="dot"></div>
<h2>Completados por Período</h2>
<span style="font-size:12px;color:#5e6c84;margin-left:8px;">Filtrar por mes de resolución</span>
<label>Ver mes:</label>
<select id="monthFilter" onchange="updateHistory()"></select>
<span class="badge" id="filterBadge">Todos los meses</span>
</div>
<div class="main">
<div class="section-block">
<div class="kpi-row-3">
<div class="kpi done"> <div class="kpi-value" id="kpi-done-period">—</div><div class="kpi-label">SP Completados</div><div class="kpi-sub" id="kpi-done-period-sub">en el período</div></div>
<div class="kpi members"><div class="kpi-value" id="kpi-devs-period">—</div><div class="kpi-label">Desarrolladores</div><div class="kpi-sub">que completaron SP</div></div>
<div class="kpi progress"><div class="kpi-value" id="kpi-avg-period">—</div><div class="kpi-label">SP promedio / dev</div><div class="kpi-sub">en el período</div></div>
</div>
<div class="charts-grid">
<div class="chart-card">
<h3>✅ SP Completados — por Desarrollador</h3>
<div class="sub" id="subtitle-history-dev">Período seleccionado</div>
<div style="position:relative;height:260px"><canvas id="chartDoneDev"></canvas></div>
</div>
<div class="chart-card">
<h3>📈 Velocidad del Equipo</h3>
<div class="sub">SP completados por mes · cada color = un dev</div>
<div style="position:relative;height:260px"><canvas id="chartVelocity"></canvas></div>
</div>
</div>
<div class="charts-grid">
<div class="chart-card full">
<h3>🗂 SP por Épica — Estado Actual</h3>
<div class="sub">Completados · En Progreso · Por Hacer en cada épica</div>
<div style="position:relative;height:220px"><canvas id="chartEpic"></canvas></div>
</div>
</div>
<div class="table-card">
<h3 id="table-history-title">SP Completados — Todos los meses</h3>
<table>
<thead><tr><th>Desarrollador</th><th>✅ SP Completados</th><th>% del total del período</th></tr></thead>
<tbody id="tableHistory"></tbody>
</table>
</div>
</div>
</div>
<footer>
Dashboard en tiempo real · Proyecto LABS · invera.atlassian.net ·
Campo: <strong>Desarrollador</strong> (customfield_10622) + SP Finales (customfield_11019) ·
Cache: 5 min · <span id="totalIssuesLabel"></span>
</footer>
<script>
// ── Colores ────────────────────────────────────────────────────────────────
const COLORS = [
'#0052cc','#36b37e','#ff5630','#6554c0','#ff8b00',
'#00b8d9','#57d9a3','#f99cdb','#8777d9','#344563',
'#403294','#00875a','#de350b','#505f79','#42526e',
];
const MONTH_LABELS_MAP = {
'2025-01':'Ene 2025','2025-02':'Feb 2025','2025-03':'Mar 2025',
'2025-04':'Abr 2025','2025-05':'May 2025','2025-06':'Jun 2025',
'2025-07':'Jul 2025','2025-08':'Ago 2025','2025-09':'Sep 2025',
'2025-10':'Oct 2025','2025-11':'Nov 2025','2025-12':'Dic 2025',
'2026-01':'Ene 2026','2026-02':'Feb 2026','2026-03':'Mar 2026',
'2026-04':'Abr 2026','2026-05':'May 2026','2026-06':'Jun 2026',
};
function monthLabel(m) { return MONTH_LABELS_MAP[m] || m; }
// ── Helpers ────────────────────────────────────────────────────────────────
let _peopleOrder = [];
function personColor(name) {
let idx = _peopleOrder.indexOf(name);
if (idx < 0) { _peopleOrder.push(name); idx = _peopleOrder.length - 1; }
return COLORS[idx % COLORS.length];
}
function initials(name) { return name.split(' ').map(w=>w[0]).join('').slice(0,2).toUpperCase(); }
function shortName(name) { const p = name.split(' '); return p[0] + (p[1] ? ' ' + p[1] : ''); }
// ── Chart instances ────────────────────────────────────────────────────────
let charts = {};
function destroyChart(id) { if (charts[id]) { charts[id].destroy(); delete charts[id]; } }
// ── Global data ────────────────────────────────────────────────────────────
let DATA = null;
// ── Load data from API ─────────────────────────────────────────────────────
async function loadData(forceRefresh = false) {
document.getElementById('loadingOverlay').style.display = 'flex';
document.getElementById('errorBanner').style.display = 'none';
document.getElementById('refreshBtn').disabled = true;
try {
const url = '/api/data' + (forceRefresh ? '?refresh=1' : '');
const res = await fetch(url);
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || `HTTP ${res.status}`);
}
DATA = await res.json();
_peopleOrder = Object.keys(DATA.assigneeData);
buildMonthSelector();
renderCurrentSection();
renderHistorySection();
// Last updated label
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)';
if (DATA.jiraTotal > DATA.totalIssues) {
document.getElementById('totalIssuesLabel').textContent =
`Mostrando ${DATA.totalIssues} de ${DATA.jiraTotal} issues totales con SP`;
}
} catch(e) {
const banner = document.getElementById('errorBanner');
banner.style.display = 'block';
banner.textContent = '⚠️ Error al cargar datos: ' + e.message;
console.error(e);
} finally {
document.getElementById('loadingOverlay').style.display = 'none';
document.getElementById('refreshBtn').disabled = false;
}
}
// ── Month selector ─────────────────────────────────────────────────────────
function buildMonthSelector() {
const sel = document.getElementById('monthFilter');
const months = Object.keys(DATA.monthlyDone).sort();
sel.innerHTML = '<option value="all">Todos los meses</option>'
+ months.map(m => `<option value="${m}">${monthLabel(m)}</option>`).join('');
// Default: mostrar el mes más reciente
if (months.length > 0) sel.value = months[months.length - 1];
}
// ── Sección 1: Estado Actual ───────────────────────────────────────────────
function renderCurrentSection() {
const ad = DATA.assigneeData;
const people = Object.keys(ad).filter(p => ad[p].done + ad[p].in_progress + ad[p].todo > 0);
// KPIs
let ip = 0, todo = 0, done = 0, devs = 0;
people.forEach(p => {
ip += ad[p].in_progress;
todo += ad[p].todo;
done += ad[p].done;
devs++;
});
document.getElementById('kpi-ip').textContent = ip;
document.getElementById('kpi-todo').textContent = todo;
document.getElementById('kpi-done-total').textContent = done;
document.getElementById('kpi-members').textContent = devs;
// Chart: solo en progreso
{
destroyChart('inProgress');
const rows = people.filter(p => ad[p].in_progress > 0).sort((a,b) => ad[b].in_progress - ad[a].in_progress);
charts['inProgress'] = new Chart(document.getElementById('chartInProgress'), {
type: 'bar',
data: {
labels: rows.map(shortName),
datasets: [{ label: 'SP en progreso', data: rows.map(p => ad[p].in_progress),
backgroundColor: rows.map(personColor), borderRadius: 5 }]
},
options: { responsive:true, maintainAspectRatio:false,
plugins:{ legend:{ display:false } },
scales:{ x:{ grid:{display:false}, ticks:{font:{size:11}} },
y:{ beginAtZero:true, ticks:{font:{size:11}}, grid:{color:'#f0f2f5'} } } }
});
}
// Chart: estado full
{
destroyChart('currentState');
const rows = [...people].sort((a,b) => (ad[b].done+ad[b].in_progress)-(ad[a].done+ad[a].in_progress));
charts['currentState'] = new Chart(document.getElementById('chartCurrentState'), {
type: 'bar',
data: {
labels: rows.map(shortName),
datasets: [
{ label:'Completados', data:rows.map(p=>ad[p].done), backgroundColor:'#36b37e', borderRadius:3 },
{ label:'En Progreso', data:rows.map(p=>ad[p].in_progress), backgroundColor:'#0065ff', borderRadius:3 },
{ label:'Por Hacer', data:rows.map(p=>ad[p].todo), backgroundColor:'#ff8b00', borderRadius:3 },
]
},
options:{ responsive:true, maintainAspectRatio:false,
plugins:{ legend:{ position:'bottom', labels:{ font:{size:11} } } },
scales:{ x:{stacked:true,grid:{display:false},ticks:{font:{size:11}}},
y:{stacked:true,beginAtZero:true,ticks:{font:{size:11}},grid:{color:'#f0f2f5'}} } }
});
}
// Tabla estado actual
const tbody = document.getElementById('tableCurrentState');
tbody.innerHTML = '';
const sorted = [...people].sort((a,b) => (ad[b].done+ad[b].in_progress)-(ad[a].done+ad[a].in_progress));
sorted.forEach(p => {
const d = ad[p], total = d.done + d.in_progress + d.todo;
const dp = pct(d.done, total), ipp = pct(d.in_progress, total), tp = pct(d.todo, total);
const c = personColor(p);
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">${d.done} SP</span></td>
<td><span class="pill progress">${d.in_progress} SP</span></td>
<td><span class="pill todo">${d.todo} SP</span></td>
<td><strong>${total}</strong></td>
<td><div class="pbar-wrap"><div class="pbar-done" style="width:${dp}%"></div><div class="pbar-ip" style="width:${ipp}%"></div><div class="pbar-todo" style="width:${tp}%"></div></div></td>
</tr>`;
});
}
// ── Sección 2: Historial ───────────────────────────────────────────────────
function renderHistorySection() {
buildVelocityChart();
buildEpicChart();
updateHistory();
}
function buildVelocityChart() {
destroyChart('velocity');
const months = Object.keys(DATA.monthlyDone).sort();
const allDevs = [...new Set(months.flatMap(m => Object.keys(DATA.monthlyDone[m])))];
charts['velocity'] = new Chart(document.getElementById('chartVelocity'), {
type: 'bar',
data: {
labels: months.map(monthLabel),
datasets: allDevs.map((p, i) => ({
label: shortName(p),
data: months.map(m => DATA.monthlyDone[m][p] || 0),
backgroundColor: COLORS[i % COLORS.length],
borderRadius: 3,
}))
},
options:{ responsive:true, maintainAspectRatio:false,
plugins:{ legend:{ position:'bottom', labels:{ font:{size:10}, boxWidth:12 } } },
scales:{ x:{stacked:true,grid:{display:false},ticks:{font:{size:11}}},
y:{stacked:true,beginAtZero:true,ticks:{font:{size:11}},grid:{color:'#f0f2f5'}} } }
});
}
function buildEpicChart() {
destroyChart('epic');
const top = [...DATA.epicData]
.sort((a,b) => (b.done+b.in_progress+b.todo)-(a.done+a.in_progress+a.todo))
.slice(0, 15);
charts['epic'] = new Chart(document.getElementById('chartEpic'), {
type: 'bar',
data: {
labels: top.map(e => e.name.length > 42 ? e.name.slice(0,40)+'…' : e.name),
datasets: [
{ label:'Completados', data:top.map(e=>e.done), backgroundColor:'#36b37e', borderRadius:3 },
{ label:'En Progreso', data:top.map(e=>e.in_progress), backgroundColor:'#0065ff', borderRadius:3 },
{ label:'Por Hacer', data:top.map(e=>e.todo), backgroundColor:'#ff8b00', borderRadius:3 },
]
},
options:{ indexAxis:'y', responsive:true, maintainAspectRatio:false,
plugins:{ legend:{ position:'bottom', labels:{ font:{size:11} } } },
scales:{ x:{stacked:true,beginAtZero:true,ticks:{font:{size:11}},grid:{color:'#f0f2f5'}},
y:{stacked:true,grid:{display:false},ticks:{font:{size:11}}} } }
});
}
function updateHistory() {
if (!DATA) return;
const month = document.getElementById('monthFilter').value;
document.getElementById('filterBadge').textContent =
month === 'all' ? 'Todos los meses' : monthLabel(month);
// Aggregate
let periodData = {};
if (month === 'all') {
Object.values(DATA.monthlyDone).forEach(md => {
Object.entries(md).forEach(([p, sp]) => { periodData[p] = (periodData[p] || 0) + sp; });
});
} else {
periodData = { ...(DATA.monthlyDone[month] || {}) };
}
const totalSP = Object.values(periodData).reduce((a,b) => a+b, 0);
const devCount = Object.keys(periodData).filter(p => periodData[p] > 0).length;
const avgSP = devCount > 0 ? Math.round(totalSP / devCount) : 0;
document.getElementById('kpi-done-period').textContent = totalSP;
document.getElementById('kpi-devs-period').textContent = devCount;
document.getElementById('kpi-avg-period').textContent = avgSP;
const lbl = month === 'all' ? 'todos los meses' : monthLabel(month);
document.getElementById('kpi-done-period-sub').textContent = `SP completados · ${lbl}`;
document.getElementById('subtitle-history-dev').textContent = `SP completados en ${lbl}`;
document.getElementById('table-history-title').textContent =
`SP Completados — ${month === 'all' ? 'Todos los meses' : monthLabel(month)}`;
// Chart done by dev
destroyChart('doneDev');
const sorted = Object.entries(periodData).filter(([,sp]) => sp > 0).sort((a,b) => b[1]-a[1]);
charts['doneDev'] = new Chart(document.getElementById('chartDoneDev'), {
type: 'bar',
data: {
labels: sorted.map(([p]) => shortName(p)),
datasets: [{ label:'SP completados', data:sorted.map(([,sp]) => sp),
backgroundColor: sorted.map(([p]) => personColor(p)), borderRadius:5 }]
},
options:{ responsive:true, maintainAspectRatio:false,
plugins:{ legend:{ display:false } },
scales:{ x:{grid:{display:false},ticks:{font:{size:11}}},
y:{beginAtZero:true,ticks:{font:{size:11}},grid:{color:'#f0f2f5'}} } }
});
// Tabla historial
const tbody = document.getElementById('tableHistory');
tbody.innerHTML = '';
if (sorted.length === 0) {
tbody.innerHTML = '<tr><td colspan="3" class="no-data">No hay datos para el período seleccionado</td></tr>';
return;
}
sorted.forEach(([p, sp]) => {
const p_ = totalSP > 0 ? (sp / totalSP * 100).toFixed(1) : 0;
const c = personColor(p);
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><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>
</tr>`;
});
}
// ── Utils ──────────────────────────────────────────────────────────────────
function pct(val, total) { return total > 0 ? (val / total * 100).toFixed(1) : 0; }
// ── Auto-refresh cada 24 horas ────────────────────────────────────────────
setInterval(() => loadData(false), 24 * 60 * 60 * 1000);
// ── Init ───────────────────────────────────────────────────────────────────
loadData();
</script>
</body>
</html>