tercer commit

This commit is contained in:
kvilches1410
2026-04-01 11:34:43 -03:00
parent 7410b22623
commit a2d49b441e
6 changed files with 400 additions and 26 deletions
+9
View File
@@ -73,6 +73,15 @@ export const config = {
spEstimate: getEnv('JIRA_FIELD_SP_ESTIMATE', 'customfield_10016'),
spFinal: getEnv('JIRA_FIELD_SP_FINAL', 'customfield_11019'),
developer: getEnv('JIRA_FIELD_DEVELOPER', 'customfield_10622'),
// ── QA Fields ────────────────────────────────────────────────────
qaAssignee: 'customfield_10632',
qaEntryRtqa: 'customfield_10821',
qaTotalTimeRtqa: 'customfield_10822',
qaLastDurRtqa: 'customfield_10823',
qaEntryQa: 'customfield_10824',
qaTotalTimeQa: 'customfield_10825',
qaLastDurQa: 'customfield_10826',
timesReturned: 'customfield_11217',
},
// ── JQL Query ──────────────────────────────────────────────────────
+89 -3
View File
@@ -97,12 +97,21 @@ function buildFieldsList() {
'summary',
'status',
'assignee',
config.fields.spEstimate, // Story points estimate
config.fields.spFinal, // Story points finales
config.fields.developer, // Desarrollador (multi-user)
config.fields.spEstimate, // Story points estimate
config.fields.spFinal, // Story points finales
config.fields.developer, // Desarrollador (multi-user)
'resolutiondate',
'created',
'updated',
// ── QA Fields ──────────────────────────────────────
config.fields.qaAssignee, // QA asignado
config.fields.qaEntryRtqa, // Fecha entrada READY TO QA
config.fields.qaTotalTimeRtqa, // Tiempo total en READY TO QA (min)
config.fields.qaLastDurRtqa, // Última duración en READY TO QA (min)
config.fields.qaEntryQa, // Fecha entrada QA
config.fields.qaTotalTimeQa, // Tiempo total en QA (min)
config.fields.qaLastDurQa, // Última duración en QA (min)
config.fields.timesReturned, // Veces que volvió a En Curso
].join(',');
}
@@ -112,12 +121,31 @@ function buildFieldsList() {
* @param {Array} issues - Array de issues de JIRA
* @returns {Object} Datos procesados
*/
// Helper: convierte minutos a horas redondeadas a 1 decimal
function minsToHrs(mins) {
if (mins == null || mins === 0) return 0;
return Math.round((mins / 60) * 10) / 10;
}
function processIssues(issues) {
const assigneeData = {};
const monthlyDone = {};
const issuesList = [];
const warnings = [];
const now = new Date();
// ── QA Aggregation ──────────────────────────────────────────────────
const qaData = {
tickets: 0,
ticketsReturned: 0,
totalTimeQa: 0, // minutos
totalTimeRtqa: 0, // minutos
monthlyTotalQa: {}, // { 'YYYY-MM': totalMin }
monthlyCountQa: {}, // { 'YYYY-MM': count }
monthlyTotalRtqa: {},
monthlyCountRtqa: {},
issues: [],
};
let processedCount = 0;
let skippedCount = 0;
@@ -197,6 +225,41 @@ function processIssues(issues) {
creMonth
});
// ── Acumular datos de QA ────────────────────────────────────
const timeQaMin = f[config.fields.qaTotalTimeQa] ?? 0;
const timeRtqaMin = f[config.fields.qaTotalTimeRtqa] ?? 0;
const lastDurQa = f[config.fields.qaLastDurQa] ?? 0;
const timesRet = f[config.fields.timesReturned] ?? 0;
const qaMonth = resMonth || creMonth;
if (timeQaMin > 0) {
qaData.tickets++;
qaData.totalTimeQa += timeQaMin;
if (timesRet > 1) qaData.ticketsReturned++;
if (qaMonth) {
qaData.monthlyTotalQa[qaMonth] = (qaData.monthlyTotalQa[qaMonth] || 0) + timeQaMin;
qaData.monthlyCountQa[qaMonth] = (qaData.monthlyCountQa[qaMonth] || 0) + 1;
}
qaData.issues.push({
key: issue.key,
summary: f.summary,
timeQa: minsToHrs(timeQaMin),
timeRtqa: minsToHrs(timeRtqaMin),
lastDurQa: minsToHrs(lastDurQa),
timesReturned: timesRet,
status: statusKey,
resMonth: qaMonth,
});
}
if (timeRtqaMin > 0 && qaMonth) {
qaData.totalTimeRtqa += timeRtqaMin;
qaData.monthlyTotalRtqa[qaMonth] = (qaData.monthlyTotalRtqa[qaMonth] || 0) + timeRtqaMin;
qaData.monthlyCountRtqa[qaMonth] = (qaData.monthlyCountRtqa[qaMonth] || 0) + 1;
}
// ── Detectar Warnings ───────────────────────────────────────
// 1. Tickets > 14 días in-progress
if (isInProgress && f.updated) {
@@ -225,6 +288,28 @@ function processIssues(issues) {
console.log(`📊 Procesados: ${processedCount} | Saltados: ${skippedCount} | Warnings: ${warnings.length}`);
// ── Calcular promedios mensuales QA ─────────────────────────────────
const monthlyAvgQa = {};
for (const m of Object.keys(qaData.monthlyTotalQa)) {
monthlyAvgQa[m] = minsToHrs(qaData.monthlyTotalQa[m] / qaData.monthlyCountQa[m]);
}
const monthlyAvgRtqa = {};
for (const m of Object.keys(qaData.monthlyTotalRtqa)) {
monthlyAvgRtqa[m] = minsToHrs(qaData.monthlyTotalRtqa[m] / qaData.monthlyCountRtqa[m]);
}
qaData.monthlyAvgQa = monthlyAvgQa;
qaData.monthlyAvgRtqa = monthlyAvgRtqa;
qaData.avgTimeQa = qaData.tickets > 0 ? minsToHrs(qaData.totalTimeQa / qaData.tickets) : 0;
qaData.avgTimeRtqa = qaData.tickets > 0 ? minsToHrs(qaData.totalTimeRtqa / qaData.tickets) : 0;
// Limpiar campos internos de acumulación
delete qaData.monthlyTotalQa;
delete qaData.monthlyCountQa;
delete qaData.monthlyTotalRtqa;
delete qaData.monthlyCountRtqa;
console.log(`🧪 QA: ${qaData.tickets} tickets | ${qaData.ticketsReturned} devueltos | Avg QA: ${qaData.avgTimeQa}hs`);
return {
lastUpdated: new Date().toISOString(),
totalIssues: processedCount,
@@ -233,6 +318,7 @@ function processIssues(issues) {
monthlyDone,
issuesList,
warnings,
qaData,
};
}
+44 -9
View File
@@ -51,6 +51,10 @@ select:focus { border-color: #0052cc; }
.kpi.todo { border-color: #ff8b00; }
.kpi.members { border-color: #6554c0; }
.kpi.warning { border-color: #ff5630; }
.kpi.qa { border-color: #6554c0; }
.kpi.qa-time { border-color: #00b8d9; }
.kpi.qa-queue { border-color: #36b37e; }
.kpi.returned { border-color: #ff5630; }
.kpi-value { font-size: 36px; font-weight: 700; line-height: 1; }
.kpi.done .kpi-value { color: #36b37e; }
@@ -58,11 +62,23 @@ select:focus { border-color: #0052cc; }
.kpi.todo .kpi-value { color: #ff8b00; }
.kpi.members .kpi-value { color: #6554c0; }
.kpi.warning .kpi-value { color: #ff5630; }
.kpi.qa .kpi-value { color: #6554c0; }
.kpi.qa-time .kpi-value { color: #00b8d9; }
.kpi.qa-queue .kpi-value { color: #36b37e; }
.kpi.returned .kpi-value { color: #ff5630; }
.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 */
/* Section header QA */
.section-header.qa { border-color: #6554c0; }
.section-header.qa .dot { background: #6554c0; }
/* Pill y row QA */
.pill.qa { background: #f3f0ff; color: #403294; }
.pill.returned { background: #ffebe6; color: #bf2600; }
.row-critical td { background: #fff3f0; }
.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; }
@@ -103,18 +119,37 @@ footer { text-align: center; padding: 24px; font-size: 11px; color: #97a0af; }
.warning-item .title { font-weight: 600; font-size: 13px; color: #172b4d;}
.warning-item .desc { font-size: 12px; color: #5e6c84; }
/* Popover Tooltips */
.pop-trigger { position: relative; display: inline-block; cursor: help; }
.pop-content {
display: none;
position: absolute;
bottom: 120%;
left: 50%;
transform: translateX(-50%);
background: white;
border-radius: 8px;
box-shadow: 0 10px 30px rgba(23,43,77,.25);
border: 1px solid #dfe1e6;
padding: 12px;
width: 280px;
z-index: 100;
}
.pop-trigger:hover .pop-content { display: block; animation: popIn .2s ease-out; }
@keyframes popIn {
from { opacity: 0; transform: translateX(-50%) translateY(10px) scale(.95); }
to { opacity: 1; transform: translateX(-50%) translateY(0) scale(1); }
}
.pop-item { font-size: 11px; padding: 6px 0; border-bottom: 1px solid #f4f5f7; line-height: 1.4; color: #5e6c84; }
.pop-item:last-child { border-bottom: none; }
.pop-item a { color: #0052cc; text-decoration: none; font-weight: 600; margin-right: 4px; }
.pop-item a:hover { text-decoration: underline; }
/* Jira Links */
.jira-link { color: #0052cc; text-decoration: none; font-weight: bold; border-bottom: 1px dashed rgba(0,82,204,.3); transition: color .2s; }
.jira-link:hover { color: #0747a6; border-bottom-style: solid; text-decoration: underline; }
.jira-link-small { color: #5e6c84; text-decoration: none; font-size: 10px; background: #f4f5f7; padding: 1px 4px; border-radius: 3px; border: 1px solid #dfe1e6; transition: all .2s; margin-right: 4px; display: inline-block; margin-top: 4px; }
.jira-link-small:hover { background: #ebecf0; color: #172b4d; border-color: #c1c7d0; text-decoration: none; }
.ticket-list-inline { margin-top: 6px; display: flex; flex-wrap: wrap; gap: 2px; }
/* Tooltip improvements (native title) */
[title] { cursor: help; }
/* Responsive */
@media (max-width: 768px) {
.kpi-row, .kpi-row-3 { grid-template-columns: 1fr 1fr; }
+85 -1
View File
@@ -124,13 +124,97 @@
<div class="table-card">
<h3>SP Completados — Tabla Resumen</h3>
<table>
<thead><tr><th>Desarrollador</th><th>✅ SP Completados</th><th>% del impacto total</th></tr></thead>
<thead><tr><th>Desarrollador</th><th>✅ SP Completados</th><th>🎟️ Tickets</th><th>% del impacto total</th></tr></thead>
<tbody id="tableHistory"></tbody>
</table>
</div>
</div>
</div>
<!-- ── SECCIÓN 3: Control de Calidad (QA) ── -->
<div class="section-header qa">
<div class="dot qa"></div>
<h2>Control de Calidad (QA)</h2>
<span style="font-size:12px;color:#5e6c84;margin-left:8px;">Métricas del proceso de QA · Tiempos, colas y devoluciones</span>
</div>
<div class="main">
<div class="section-block">
<!-- KPIs -->
<div class="kpi-row">
<div class="kpi qa">
<div class="kpi-value" id="kpi-qa-tickets">—</div>
<div class="kpi-label">Tickets QA</div>
<div class="kpi-sub">Procesados en el período</div>
</div>
<div class="kpi qa-time">
<div class="kpi-value" id="kpi-qa-avg">—</div>
<div class="kpi-label">Tiempo Prom. QA</div>
<div class="kpi-sub">Horas promedio por ticket</div>
</div>
<div class="kpi qa-queue">
<div class="kpi-value" id="kpi-qa-rtqa">—</div>
<div class="kpi-label">Tiempo Prom. en Cola</div>
<div class="kpi-sub">Horas esperando READY TO QA</div>
</div>
<div class="kpi returned">
<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>
</div>
<!-- Gráficos fila 1 -->
<div class="charts-grid">
<div class="chart-card">
<h3>📊 Distribución de Tiempos en QA</h3>
<div class="sub">Rangos: &lt;30min · 30min–2hs · 2–8hs · &gt;8hs</div>
<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>
<div style="position:relative;height:240px"><canvas id="chartQaReturned"></canvas></div>
</div>
</div>
<!-- Gráficos fila 2 -->
<div class="charts-grid">
<div class="chart-card">
<h3>📈 Tiempo Promedio en QA — Mes a Mes</h3>
<div class="sub">Evolución de la velocidad de revisión (horas)</div>
<div style="position:relative;height:240px"><canvas id="chartQaMonthly"></canvas></div>
</div>
<div class="chart-card">
<h3>⏳ Tiempo en Cola RTQA — Mes a Mes</h3>
<div class="sub">Horas promedio esperando ser tomado por QA</div>
<div style="position:relative;height:240px"><canvas id="chartRtqaMonthly"></canvas></div>
</div>
</div>
<!-- Tabla de tickets QA -->
<div class="table-card">
<h3>🔍 Detalle de Tickets — QA</h3>
<table>
<thead>
<tr>
<th>Ticket</th>
<th>Resumen</th>
<th>⏱️ Tiempo en QA (hs)</th>
<th>⏳ Cola RTQA (hs)</th>
<th>🔄 Veces devuelto</th>
<th>Estado</th>
</tr>
</thead>
<tbody id="tableQA"></tbody>
</table>
</div>
</div>
</div>
<footer>
Dashboard en tiempo real · Arquitectura Modular Serverless · Realizado para Vercel & Jira
</footer>
+135 -12
View File
@@ -70,6 +70,7 @@ async function loadAndRender(forceRefresh = false) {
renderCurrentSection();
renderHistorySection();
renderQASection();
} catch(e) {
errorBanner.style.display = 'block';
@@ -197,19 +198,25 @@ function renderCurrentSection() {
const d = ad[p], total = d.in_progress + d.todo;
const c = chartsManager.personColor(p);
const tooltipIP = d.issues_ip.map(i => `${i.key} (${i.sp} SP)`).join('\n');
const tooltipTodo = d.issues_todo.map(i => `${i.key} (${i.sp} SP)`).join('\n');
const allKeys = [...d.issues_ip, ...d.issues_todo].map(i =>
`<a href="${getJiraUrl(i.key)}" target="_blank" class="jira-link-small" title="${i.summary}">${i.key}</a>`
).join(' ');
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('');
tbody.innerHTML += `<tr>
<td>
<div class="name-cell"><div class="avatar" style="background:${c}">${initials(p)}</div>${p}</div>
<div class="ticket-list-inline">${allKeys}</div>
</td>
<td><span class="pill progress" title="${tooltipIP}">${d.in_progress} SP</span></td>
<td><span class="pill todo" title="${tooltipTodo}">${d.todo} SP</span></td>
<td>
<div class="pop-trigger">
<span class="pill progress">${d.in_progress} SP</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>
<div class="pop-content">${popoverTodo || 'No hay tickets'}</div>
</div>
</td>
<td><strong>${total} SP</strong></td>
<td><div class="pbar-wrap">
<div class="pbar-ip" style="width:${pct(d.in_progress, total)}%"></div>
@@ -226,10 +233,13 @@ function renderHistorySection() {
// Consolidar todos los meses del rango actual
let periodData = {};
Object.values(data.monthlyDone).forEach(md => {
Object.entries(md).forEach(([p, sp]) => {
let periodTicketCount = {};
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) + sp;
periodData[p] = (periodData[p] || 0) + i.sp;
periodTicketCount[p] = (periodTicketCount[p] || 0) + 1;
});
});
@@ -251,14 +261,16 @@ function renderHistorySection() {
const tbody = document.getElementById('tableHistory');
tbody.innerHTML = '';
if (sorted.length === 0) {
tbody.innerHTML = '<tr><td colspan="3" class="no-data">No hay entregas en este período</td></tr>';
tbody.innerHTML = '<tr><td colspan="4" 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;
const c = chartsManager.personColor(p);
const tickets = periodTicketCount[p] || 0;
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>
@@ -280,3 +292,114 @@ function renderHistorySection() {
backgroundColor: chartsManager.personColor(p), borderRadius: 3
})), { plugins:{ legend:{ position:'bottom'} }, scales:{ x:{stacked:true,grid:{display:false}}, y:{stacked:true} } });
}
// ── QA SECTION ─────────────────────────────────────────────────────────────
function renderQASection() {
const data = api.data;
if (!data || !data.qaData) return;
const qa = data.qaData;
// ── KPIs ──────────────────────────────────────────────────────────────────
document.getElementById('kpi-qa-tickets').textContent = qa.tickets ?? '—';
document.getElementById('kpi-qa-avg').textContent = qa.avgTimeQa != null ? qa.avgTimeQa + 'hs' : '—';
document.getElementById('kpi-qa-rtqa').textContent = qa.avgTimeRtqa != null ? qa.avgTimeRtqa + 'hs' : '—';
document.getElementById('kpi-qa-returned').textContent = qa.ticketsReturned ?? '—';
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`;
// ── 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',
['✅ Aprobados directo', '🔄 Devueltos (>1 vez en curso)'],
[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;
}
sorted.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><strong style="color:${isCritical ? '#ff5630' : '#172b4d'}">${i.timeQa}hs</strong>${isCritical ? ' ⚠️' : ''}</td>
<td>${i.timeRtqa > 0 ? i.timeRtqa + 'hs' : '—'}</td>
<td>${returnBadge}</td>
<td>${statusLabel}</td>
</tr>`;
});
}
+38 -1
View File
@@ -43,5 +43,42 @@ export const chartsManager = {
...options,
}
});
}
},
drawDoughnutChart(id, canvasId, labels, data, backgroundColors, options = {}) {
this.destroyChart(id);
const ctx = document.getElementById(canvasId);
if (!ctx) return;
this.instances[id] = new Chart(ctx, {
type: 'doughnut',
data: {
labels,
datasets: [{ data, backgroundColor: backgroundColors, borderWidth: 2, borderColor: '#fff' }],
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { position: 'bottom', labels: { font: { size: 11 } } } },
...options,
}
});
},
drawLineChart(id, canvasId, labels, datasets, options = {}) {
this.destroyChart(id);
const ctx = document.getElementById(canvasId);
if (!ctx) return;
this.instances[id] = new Chart(ctx, {
type: 'line',
data: { labels, datasets },
options: {
responsive: true,
maintainAspectRatio: false,
tension: 0.4,
...options,
}
});
},
};