From 5467427a08bcfc5dc42a1894ba53e7dcaf9ae43e Mon Sep 17 00:00:00 2001 From: kvilches1410 Date: Tue, 31 Mar 2026 16:57:26 -0300 Subject: [PATCH] nuevo dash --- README.md | 399 ++++++++++++------------------------------- api/data.js | 177 ++++++++++++------- api/webhook.js | 33 ++++ css/styles.css | 126 ++++++++++++++ index.html | 449 ++++++++----------------------------------------- js/api.js | 31 ++++ js/app.js | 282 +++++++++++++++++++++++++++++++ js/charts.js | 47 ++++++ vercel.json | 8 + 9 files changed, 820 insertions(+), 732 deletions(-) create mode 100644 api/webhook.js create mode 100644 css/styles.css create mode 100644 js/api.js create mode 100644 js/app.js create mode 100644 js/charts.js diff --git a/README.md b/README.md index d703efa..d4548d6 100644 --- a/README.md +++ b/README.md @@ -1,300 +1,121 @@ -📊 Análisis del Dashboard JIRA - DashLabs -🏗️ Arquitectura del Proyecto -Stack Tecnológico +# 📊 Dashboard LABS — JIRA Story Points -Frontend: HTML5 + Vanilla JavaScript + Chart.js 4.4.1 -Backend: Node.js Serverless (Vercel Function) -API: JIRA REST API v3 -Autenticación: Basic Auth (Email + Token JIRA) -Caching: En memoria (24hs) + CDN (Vercel) +Un dashboard en tiempo real para visualizar métricas, progreso de Story Points (SP), y la velocidad del equipo utilizando datos extraídos directamente de la API de Jira. -Estructura de Archivos -dashlabs-master/ -├── index.html (25 KB - Frontend completo) +Este proyecto está diseñado con un frontend ligero sin frameworks pesados y un backend *Serverless* preparado para ser desplegado en plataformas como Vercel o funcionar de manera local. + +--- + +## 🏗️ Stack Tecnológico + +- **Frontend:** HTML5 + Vanilla TypeScript/JavaScript + [Chart.js (v4.4.1)](https://www.chartjs.org/) +- **Backend:** Node.js Serverless Function +- **API:** Integración directa con Jira REST API v3 +- **Autenticación:** Basic Auth mediante Email y API Token de Jira +- **Caching:** Caché en memoria (24hs) + CDN-level Cache con "stale-while-revalidate" + +## ✨ Características Principales + +1. **Estado Actual del Equipo:** + - Visualización de KPIS clave: SP en Progreso, SP por Hacer, SP Completados y Desarrolladores Activos. + - Gráficos detallados del progreso por desarrollador. + - Tabla que resume el porcentaje de carga de trabajo de cada uno. + +2. **Histórico por Período:** + - Resumen mensual de los Story Points completados. + - Velocidad histórica del equipo a lo largo del tiempo. + - Gráfico de top Épicas y distribución de SP. + +3. **Performance Optimizado:** + - Consulta eficiente con parámetros JQL directos. + - Sistema de caché dual (Node memory + Vercel Edge) para evitar excesos de *Rate Limiting* en Jira y cargar instantáneamente. + +--- + +## 🚀 Instalación y Configuración Local + +### 1. Clonar el repositorio +Cloná este repositorio y accedé a la carpeta del proyecto: +```bash +git clone https://github.com/kvilches1410/dashlabs.git +cd dashlabs +``` + +### 2. Instalar el CLI de Vercel +Para correr las funciones *serverless* del backend (`api/data.js` de Vercel) localmente, necesitas instalar Vercel CLI de forma global: +```bash +npm install -g vercel +``` + +### 3. Configurar Variables de Entorno +Copiá el archivo de entorno de ejemplo como `.env` en la raíz del proyecto. +```bash +cp ex.env .env +``` +Luego, editá el `.env` e ingresá tus credenciales de Jira: +```env +# Ejemplo de .env +JIRA_EMAIL=tu-email@tudominio.com +JIRA_TOKEN=tu-jira-api-token +``` +> **Nota:** Podés generar un API Token de Jira en tu la configuración de seguridad de tu cuenta de Atlassian. + +### 4. Entorno de Desarrollo +Para levantar el proyecto y la API simulando el entorno de Vercel, ejecutá: +```bash +vercel dev +``` +Luego, abrí `http://localhost:3000` en tu navegador. + +--- + +## 🔧 Configuración del Modelo de Datos de JIRA + +Los campos personalizados (Custom Fields) varían según cada entorno de Jira. Actualmente, el mapeo de campos está de la siguiente manera: + +| Campo / Tipo | Código (Custom Field) | Descripción | +| :--- | :--- | :--- | +| **Story Points Estimate** | `customfield_10016` | La estimación de SP original del ticket | +| **Story Points Finales** | `customfield_11217` | SP finales acordados (Suele tener prioridad) | +| **Desarrollador** | `customfield_10628` | Campo Multi-usuario para asignación de tareas | + +> ⚠️ **Importante**: Verificá en tu instancia de Jira (`https://.atlassian.net/rest/api/3/fields`) que los IDs de estos campos correspondan a los utilizados en la API del dashboard. Si implementaste la nueva arquitectura, vas a pordelo modificar directamente en `api/config.js`. + +--- + +## 📁 Estructura del Proyecto + +```text +dashlabs/ +├── index.html # Frontend central. Contiene todo el layout, CSS y JS con Chart.js +├── package.json # Dependencias y scripts +├── vercel.json # Configuración para despliegue en Vercel +├── .gitignore +├── ex.env # Variables de entorno de ejemplo (TEMPLATE) └── api/ - └── data.js (5.5 KB - Backend serverless) + ├── config.js # [Opcional] Configuración modular para el Request de JIRA + └── data.js # Vercel Serverless Function: Maneja el fetch, transformación de datos y caché +``` -📋 Componentes Principales -Frontend (index.html) -Elementos Visuales +--- -Header +## 🌐 Despliegue en Producción (Vercel) -Título y metadata del proyecto -Botón de actualización manual -Badge de timestamp y estado de caché +El proyecto está diseñado para funcionar nativamente dentro de [Vercel](https://vercel.com/). +1. Conectá el repositorio en el panel de Vercel. +2. Agregá en Vercel **Environment Variables**: + - `JIRA_EMAIL` + - `JIRA_TOKEN` +3. Dale a **Deploy**. -Sección 1: Estado Actual del Equipo +La carpeta `/api` será parseada de manera automática y transformada en endpoints `/api/data` para que el `index.html` los consuma. -4 KPIs principales: +--- -SP En Progreso (color azul) -SP Por Hacer (color naranja) -SP Completados Total (color verde) -Devs Activos (color púrpura) +## 🤝 Contribuciones y Futuras Mejoras - -2 Gráficos: - -SP en Progreso por Desarrollador (barras) -Estado Actual por Desarrollador (barras agrupadas) - - -Tabla Detallada: - -Muestra por cada dev: Completados, En Progreso, Por Hacer -Barra de progreso visual - - - - -Sección 2: Completados por Período - -Filtro por mes (dropdown) -3 KPIs dinámicos: - -SP Completados en el período -Devs que completaron -Promedio SP por dev - - -3 Gráficos: - -Velocity por mes (barras apiladas) -Top 15 Épicas -SP Completados por Desarrollador (período filtrado) - - -Tabla de Historial: - -SP completados con % de participación - - - - - - -Backend (api/data.js) -Configuración JIRA -javascriptJIRA_HOST = 'invera.atlassian.net' -JQL = 'project = LABS AND cf[10016] is not EMPTY ORDER BY updated DESC' -Campos Customizados Mapeados -CampoCodeDescripciónStory Points Estimatecustomfield_10016Story points estimadoStory Points Finalescustomfield_11019SP final (prioridad sobre estimate)Desarrollador (Multi)customfield_10622Campo multi-usuario de asignaciónParentparentÉpicaResolution DateresolutiondateFecha de completado -Flujo de Datos -1. fetchJiraIssues() - ├── Autentica con Basic Auth (JIRA_EMAIL + JIRA_TOKEN) - ├── Ejecuta JQL query contra JIRA API - └── Retorna issues con campos específicos - -2. processIssues() - ├── Mapea developers (customfield_10622 → displayName) - ├── Extrae SP (prioriza customfield_11019) - ├── Categoriza por status (done/indeterminate/todo) - ├── Agrupa: - │ ├── Por desarrollador (assigneeData) - │ ├── Por mes (monthlyDone) - │ └── Por épica (epicData) - └── Retorna DATA object estructurado - -3. Caching - ├── En memoria: 24 horas - ├── CDN (Vercel): s-maxage=86400 - └── Parámetro ?refresh=1 fuerza actualización - -🔑 Características Principales -✅ Fortalezas Actuales - -✨ Diseño limpio - UI coherente con color scheme profesional -📊 Múltiples perspectivas - Estado actual + Histórico -🎯 Enfoque en Story Points - Métrica clara de trabajo -⚡ Rendimiento - Arquitectura serverless + caché eficiente -🔄 Auto-refresh - Actualización automática cada 24hs -📱 Responsive - Funciona en móviles (media queries) -🎨 Charts.js - Gráficos interactivos y claros - -⚠️ Áreas de Mejora -1. Hardcoding de Configuración -javascript// Actualmente: -const JIRA_HOST = 'invera.atlassian.net'; -const JQL = 'project = LABS AND cf[10016] is not EMPTY...'; - -// Debería ser: -// Variables de entorno / archivo config -2. Limitación de Resultados -javascriptconst MAX_RESULTS = 100; // ⚠️ Solo trae primeros 100 tickets -Si LABS tiene >100 tickets, faltan datos en el dashboard. -3. Sin Control de Errores Robusto - -El error banner es genérico -No hay retry automático -La caché nunca se invalida si hay error - -4. Sin API REST moderna - -El data.js es una Vercel Function directa -No hay validación de parámetros -Sin autenticación en el frontend - -5. Estado Global con DATA -javascript// En window.DATA - manejo de estado débil -// Mejor: Usar módulo o estado centralizado - -📈 Propuestas de Mejora -Fase 1: Críticas (Deberías hacer ya) - -Aumentar MAX_RESULTS a 500 (o implementar paginación) - -javascript const MAX_RESULTS = 500; - // O mejor: implementar loop hasta total - -Validar tokens y secrets en variables de entorno - -javascript if (!email || !token) throw new Error('...'); - // ✅ Ya está, pero log más claro - -Separar configuración de código - -javascript // config.js - export const CONFIG = { - jiraHost: process.env.JIRA_HOST || 'invera.atlassian.net', - jiraProject: process.env.JIRA_PROJECT || 'LABS', - customFields: { /* ... */ } - }; -Fase 2: Mejoras UX (Agregar valor) - -Filtros interactivos en "Estado Actual" - -Por desarrollador -Por status -Por épica -Por rango de SP - - -Tabla detallada de tickets - -Mostrar tickets abiertos con links a JIRA -Filtros por dev, status, épica -Buscar por summary - - -Comparativas entre períodos - -Velocidad mes a mes -Trending -Predictibilidad - - -Alertas/Warnings - -Tickets en progreso > X días -SP sin asignar -Devs con carga desequilibrada - - - -Fase 3: Arquitectura (Scaling) - -API REST con autenticación - -javascript // api/issues - GET listar con filtros - // api/devs - GET listar desarrolladores - // api/epics - GET listar épicas - // api/stats - POST calcular período custom - -Frontend modular - -Separar en componentes -State management (Zustand / Pinia) -Mejorar mantenibilidad - - -Database (opcional) - -Guardar snapshots diarios -Querys históricas más rápidas -Trend analysis - - - - -🔧 Guía de Configuración -Variables de Entorno Requeridas (Vercel) -JIRA_EMAIL=tu-email@example.com -JIRA_TOKEN=abc123xyz... # Generar en atlassian.net/account -Campos Customizados a Verificar -Cada Jira tiene IDs diferentes. Verificar en: - -Settings → Custom fields -O ejecutar: curl -u email:token https://invera.atlassian.net/rest/api/3/fields - -Para este proyecto: -cf[10016] → Story points estimate -cf[11019] → Story Points Finales -cf[10622] → Desarrollador (multi-user) -⚠️ IMPORTANTE: Estos IDs pueden cambiar entre instancias JIRA. - -📊 Ejemplos de Datos Procesados -assigneeData -javascript{ - "Juan López": { - done: 34, - in_progress: 8, - todo: 5 - }, - "María García": { - done: 28, - in_progress: 13, - todo: 2 - } -} -monthlyDone -javascript{ - "2024-12": { "Juan López": 15, "María García": 12 }, - "2025-01": { "Juan López": 12, "María García": 18 }, - "2025-02": { "Juan López": 7, "María García": -2 } -} -epicData -javascript[ - { - name: "Refactor Backend API", - done: 45, - in_progress: 21, - todo: 8 - } -] - -🚀 Próximos Pasos Recomendados - -Inmediato: - -Verificar campos customizados en tu instancia JIRA -Aumentar MAX_RESULTS -Revisar que caché funciona (badge de timestamp) - - -Corto plazo (1-2 semanas): - -Agregar tabla de tickets con detalles -Implementar filtros en "Estado Actual" -Mejorar manejo de errores - - -Mediano plazo (1 mes): - -API REST modularizada -Database para históricos -Alertas y predicciones - - - - -📝 Notas Finales -El proyecto es sólido y bien estructurado para un dashboard rápido. La arquitectura serverless de Vercel es ideal para consultas ocasionales. Si la demanda crece (100s de consultas/día) o necesitas más features, considera: - -Agregar backend Node.js con Express -Database (PostgreSQL/MongoDB) -State management moderno -Testing automatizado - -¿Necesitas ayuda implementando alguna de estas mejoras? +Algunos puntos clave (propuestos) a iterar en las siguientes versiones: +- Paginación o control de loop dinámico para extraer más allá de `MAX_RESULTS` sin quemar límites. +- Implementar Zustand o algún state-manager si el frontend de "Vanilla JS" crece en complejidad. +- Posibilidad de tener filtros aplicados mediante *Query Params* o selectores en la UI antes de ejecutar cálculos. diff --git a/api/data.js b/api/data.js index 42d72fa..5274c07 100644 --- a/api/data.js +++ b/api/data.js @@ -9,8 +9,14 @@ import { config } from './config.js'; // ─── Cache en memoria ────────────────────────────────────────────── -let _cache = null; -let _cacheTime = 0; +let _cache = {}; +let _cacheTime = {}; + +export function clearCache() { + _cache = {}; + _cacheTime = {}; + console.log('🧹 Caché en memoria limpiada'); +} // ─── JIRA Fetch ──────────────────────────────────────────────────── /** @@ -19,35 +25,67 @@ let _cacheTime = 0; * @param {string} token - API Token * @returns {Promise<{issues: Array, total: number}>} */ -async function fetchJiraIssues(email, token) { +async function fetchJiraIssues(email, token, period) { const auth = Buffer.from(`${email}:${token}`).toString('base64'); + const maxResultsWanted = 1500; // Límite seguro por usuario + let allIssues = []; + let totalInJira = 0; + let startAt = 0; - const url = `https://${config.jira.host}/rest/api/3/search/jql` - + `?jql=${encodeURIComponent(config.jql)}` - + `&fields=${encodeURIComponent(buildFieldsList())}` - + `&maxResults=${config.api.maxResults}`; - - console.log(`📡 Consultando JIRA: ${config.jira.project} (${config.api.maxResults} resultados max)`); - console.log(`📋 JQL: ${config.jql}`); - - const res = await fetch(url, { - headers: { - 'Authorization': `Basic ${auth}`, - 'Accept': 'application/json', - }, - }); - - if (!res.ok) { - const text = await res.text(); - const errorMsg = `JIRA API ${res.status}: ${text.slice(0, 200)}`; - console.error(`❌ ${errorMsg}`); - throw new Error(errorMsg); + // Inyectar el filtro de fecha en el JQL base + let dynamicJql = config.jql; + if (period !== 'all') { + const timeFilter = period === '1d' ? 'startOfDay()' : `-${period}`; + // Intentamos insertarlo antes del ORDER BY + if (dynamicJql.includes(' ORDER BY ')) { + dynamicJql = dynamicJql.replace(' ORDER BY ', ` AND updated >= ${timeFilter} ORDER BY `); + } else { + dynamicJql += ` AND updated >= ${timeFilter}`; + } } - const data = await res.json(); - console.log(`✅ Se obtuvieron ${data.issues.length} issues (total en JIRA: ${data.total})`); + console.log(`📡 Consultando JIRA: ${config.jira.project} (${maxResultsWanted} resultados max, periodo: ${period})`); + console.log(`📋 JQL: ${dynamicJql}`); + + let nextPageToken = null; + const fieldsParam = encodeURIComponent(buildFieldsList()); + const jqlParam = encodeURIComponent(dynamicJql); + + while (allIssues.length < maxResultsWanted) { + let searchUrl = `https://${config.jira.host}/rest/api/3/search/jql?jql=${jqlParam}&fields=${fieldsParam}&maxResults=100`; + if (nextPageToken) { + searchUrl += `&nextPageToken=${encodeURIComponent(nextPageToken)}`; + } + + const res = await fetch(searchUrl, { + method: 'GET', + headers: { + 'Authorization': `Basic ${auth}`, + 'Accept': 'application/json' + } + }); + + if (!res.ok) { + const text = await res.text(); + throw new Error(`JIRA API ${res.status}: ${text.slice(0, 200)}`); + } + + const data = await res.json(); + const items = data.issues || []; + allIssues = allIssues.concat(items); + nextPageToken = data.nextPageToken; + + if (!nextPageToken || items.length === 0) { + break; // No more pages + } + } + + // Si frenamos la paginación porque superamos el maxResultsWanted pero aún hay nextPageToken, está truncado + const isTruncated = !!nextPageToken; - return data; + console.log(`✅ Se obtuvieron ${allIssues.length} issues (Truncado: ${isTruncated})`); + + return { issues: allIssues, isTruncated: isTruncated }; } // ─── Build Fields List ───────────────────────────────────────────── @@ -62,9 +100,9 @@ function buildFieldsList() { config.fields.spEstimate, // Story points estimate config.fields.spFinal, // Story points finales config.fields.developer, // Desarrollador (multi-user) - 'parent', 'resolutiondate', 'created', + 'updated', ].join(','); } @@ -77,7 +115,10 @@ function buildFieldsList() { function processIssues(issues) { const assigneeData = {}; const monthlyDone = {}; - const epicMap = {}; + const issuesList = []; + const warnings = []; + const now = new Date(); + let processedCount = 0; let skippedCount = 0; @@ -119,9 +160,6 @@ function processIssues(issues) { ? f.created.slice(0, 7) : null; - // ── Extraer Épica ───────────────────────────────────────── - const epicName = f.parent?.fields?.summary ?? 'Sin épica'; - // ── Acumular por Desarrollador ──────────────────────────── for (const dev of devs) { if (!assigneeData[dev]) { @@ -148,17 +186,34 @@ function processIssues(issues) { } } - // ── Acumular por Épica ───────────────────────────────────── - if (!epicMap[epicName]) { - epicMap[epicName] = { name: epicName, done: 0, in_progress: 0, todo: 0 }; + // ── Generar issuesList para Filtros ─────────────────────── + issuesList.push({ + id: issue.id, + key: issue.key, + summary: f.summary, + status: statusKey, + sp: sp, + devs: devs, + creMonth + }); + + // ── Detectar Warnings ─────────────────────────────────────── + // 1. Tickets > 14 días in-progress + if (isInProgress && f.updated) { + const days = (now - new Date(f.updated)) / (1000 * 60 * 60 * 24); + if (days > 14) { + warnings.push({ type: 'stalled', key: issue.key, summary: f.summary, devs, sp, days: Math.round(days) }); + } + } + + // 2. Done sin SP + if (isDone && sp === 0) { + warnings.push({ type: 'done_no_sp', key: issue.key, summary: f.summary, devs, sp: 0 }); } - if (isDone) { - epicMap[epicName].done += sp; - } else if (isInProgress) { - epicMap[epicName].in_progress += sp; - } else { - epicMap[epicName].todo += sp; + // 3. SP sin asignar (todo/in-progress sin dev) + if (!isDone && devs.includes('Sin asignar') && sp > 0) { + warnings.push({ type: 'unassigned_sp', key: issue.key, summary: f.summary, devs, sp }); } processedCount++; @@ -168,7 +223,7 @@ function processIssues(issues) { } } - console.log(`📊 Procesados: ${processedCount} | Saltados: ${skippedCount}`); + console.log(`📊 Procesados: ${processedCount} | Saltados: ${skippedCount} | Warnings: ${warnings.length}`); return { lastUpdated: new Date().toISOString(), @@ -176,10 +231,8 @@ function processIssues(issues) { skippedIssues: skippedCount, assigneeData, monthlyDone, - epicData: Object.values(epicMap) - .sort((a, b) => - (b.done + b.in_progress + b.todo) - (a.done + a.in_progress + a.todo) - ), + issuesList, + warnings, }; } @@ -197,43 +250,47 @@ export default async (req, res) => { res.setHeader('Cache-Control', 's-maxage=86400, stale-while-revalidate=3600'); try { - // Force refresh con parámetro ?refresh=1 + // Force refresh y Periodo temporal const forceRefresh = req.query?.refresh === '1'; + const period = req.query?.period || '90d'; - // Servir desde caché si está fresco - if (!forceRefresh && _cache && (Date.now() - _cacheTime) < config.api.cacheMsec) { - console.log('📦 Sirviendo desde caché (en memoria)'); - return res.status(200).json({ ..._cache, fromCache: true }); + // Servir desde caché segmentado por período si está fresco + if (!forceRefresh && _cache[period] && (Date.now() - _cacheTime[period]) < config.api.cacheMsec) { + console.log(`📦 Sirviendo desde caché para periodo: ${period}`); + return res.status(200).json({ ..._cache[period], fromCache: true }); } // ── Consultar JIRA ───────────────────────────────────────── - const jiraData = await fetchJiraIssues(config.jira.email, config.jira.token); + const jiraData = await fetchJiraIssues(config.jira.email, config.jira.token, period); const data = processIssues(jiraData.issues); // Agregar metadata - data.jiraTotal = jiraData.total; // Total real en JIRA (puede ser > maxResults) + data.isTruncated = jiraData.isTruncated; // Si hay más paginación pendiente del límite + data.jiraTotal = jiraData.isTruncated ? "1500+" : jiraData.issues.length; + data.period = period; data.config = { project: config.jira.project, host: config.jira.host, - maxResults: config.api.maxResults, + maxResults: 1500, cacheMinutes: Math.round(config.api.cacheMsec / 1000 / 60), }; - // Guardar en caché en memoria - _cache = data; - _cacheTime = Date.now(); + // Guardar en caché segmentado + _cache[period] = data; + _cacheTime[period] = Date.now(); - console.log('✅ Datos frescos desde JIRA almacenados en caché'); + console.log(`✅ Datos frescos almacenados en caché para periodo: ${period}`); return res.status(200).json({ ...data, fromCache: false }); } catch (err) { console.error('❌ [dashboard/api/data] Error:', err.message); - // Intentar servir desde caché si hay error - if (_cache) { + // Intentar servir desde caché global si hay error + const period = req.query?.period || '90d'; + if (_cache[period]) { console.log('⚠️ Sirviendo caché anterior debido a error'); return res.status(200).json({ - ..._cache, + ..._cache[period], fromCache: true, error: `Error al consultar JIRA: ${err.message}. Sirviendo datos en caché.`, }); diff --git a/api/webhook.js b/api/webhook.js new file mode 100644 index 0000000..1891263 --- /dev/null +++ b/api/webhook.js @@ -0,0 +1,33 @@ +import { clearCache } from './data.js'; + +export default async (req, res) => { + // CORS + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'POST'); + + if (req.method !== 'POST') { + return res.status(405).json({ error: 'Método no permitido. Usa POST.' }); + } + + // ── Validación de Secret ────────────────────────────────────────────── + const expectedSecret = process.env.WEBHOOK_SECRET; + const providedSecret = req.query.secret; + + // Si hay un secret configurado en .env, exigimos que coincida + if (expectedSecret && providedSecret !== expectedSecret) { + console.warn('⚠️ Intento de Webhook con secret inválido.'); + return res.status(401).json({ error: 'No autorizado / Secret inválido.' }); + } + + try { + console.log('🔔 Webhook recibido desde Jira!'); + + // Invocamos la limpieza de caché del módulo data.js + clearCache(); + + return res.status(200).json({ success: true, message: 'Caché invalidada exitosamente.' }); + } catch (error) { + console.error('❌ Error manejando webhook:', error); + return res.status(500).json({ error: 'Error interno invalidando caché.' }); + } +}; diff --git a/css/styles.css b/css/styles.css new file mode 100644 index 0000000..34011dc --- /dev/null +++ b/css/styles.css @@ -0,0 +1,126 @@ +* { 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: 1000; 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 e Info */ +.error-banner { background: #ffebe6; border: 1px solid #ff5630; color: #bf2600; padding: 14px 32px; font-size: 14px; display: none; } + +/* Headers de sección */ +.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.health { border-color: #ff5630; } +.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.health .dot { background: #ff5630; } + +/* Inputs y selects */ +.section-header label { font-size: 13px; font-weight: 600; color: #5e6c84; margin-left: auto; } +.filter-group { display: flex; align-items: center; gap: 8px; margin-left: auto; } +.filter-group label { margin-left: 0; } +select { padding: 6px 12px; border: 1px solid #dfe1e6; border-radius: 4px; font-size: 14px; color: #172b4d; background: #fafbfc; cursor: pointer; outline: none; } +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; } + +/* KPIs */ +.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); transition: transform .2s; } +.kpi:hover { transform: translateY(-2px); box-shadow: 0 4px 8px rgba(0,0,0,.1); } +.kpi.done { border-color: #36b37e; } +.kpi.progress{ border-color: #0065ff; } +.kpi.todo { border-color: #ff8b00; } +.kpi.members { border-color: #6554c0; } +.kpi.warning { border-color: #ff5630; } + +.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.warning .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 */ +.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; } + +/* Tables */ +.table-card { background: white; border-radius: 8px; padding: 24px; box-shadow: 0 1px 3px rgba(0,0,0,.06); margin-bottom: 24px; overflow-x: auto; } +.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; } + +/* Píldoras y avatares */ +.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; } +.pill.warning { background: #ffebe6; color: #bf2600; } + +.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; } + +/* Health Warnings Panel */ +.warning-list { list-style: none; padding: 0; display: flex; flex-direction: column; gap: 8px; } +.warning-item { display: flex; align-items: center; gap: 12px; padding: 12px; background: #fffcfcf5; border-left: 4px solid #ff5630; border-radius: 4px; } +.warning-item .badge { background: #ffebe6; color: #bf2600; } +.warning-item .title { font-weight: 600; font-size: 13px; color: #172b4d;} +.warning-item .desc { font-size: 12px; color: #5e6c84; } + +/* 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; } + .charts-grid { grid-template-columns: 1fr; } + .chart-card.full { grid-column: 1; } + .main { padding: 16px; } + .section-header { gap: 8px; flex-direction: column; align-items: flex-start;} + .filter-group { margin-left: 0; } +} diff --git a/index.html b/index.html index 74a08b2..8abf5e6 100644 --- a/index.html +++ b/index.html @@ -4,453 +4,136 @@ Dashboard LABS — Story Points + - + +

Consultando Jira en tiempo real…

+
+
+ +

📊 Dashboard LABS — Story Points

- Proyecto: LABS · invera.atlassian.net · Campo: Desarrollador + Proyecto: LABS · invera.atlassian.net
— - + + +
- + +
-
-

Estado Actual del Equipo

- Tickets activos en el tablero ahora mismo +
+

Carga de Trabajo Actual

+ Tickets pendientes en el tablero + +
+ + +
+
-
—
SP En Progreso
En curso · Code Review · QA
-
—
SP Por Hacer
Backlog asignado
-
—
SP Completados (total)
Histórico acumulado
-
—
Devs activos
Con SP asignados
+
—
SP En Progreso
Trabajo en curso
+
—
SP Por Hacer
Backlog del periodo
+
—
Devs con Carga
Con tickets pendientes
+
-

🔵 SP en Progreso — por Desarrollador

-
Estado actual · En curso / Code Review / QA
-
-
-
-

📦 Estado actual por Desarrollador

-
Completados · En Progreso · Por Hacer (acumulado)
+

🔵 Pendientes por Desarrollador

+
Story Points con trabajo pendiente
+
+

📢 Alertas de Salud

+
Tickets estancados o sin estimación
+ + +
+
-

Detalle por Desarrollador — Estado Actual

+

Detalle de Carga de Trabajo

- +
Desarrollador✅ Completados🔵 En Progreso⏳ Por HacerTotal SPProgreso
Desarrollador🔵 En Progreso⏳ Por HacerTotal PendienteCarga Visual
- + +
-
-

Completados por Período

- Filtrar por mes de resolución - - - Todos los meses +
+

Resultados del Período

+ Story Points finalizados y velocidad +
+
-
—
SP Completados
en el período
+
—
SP Completados
en el período
—
Desarrolladores
que completaron SP
—
SP promedio / dev
en el período
+

✅ SP Completados — por Desarrollador

-
Período seleccionado
+
Período seleccionado
-

📈 Velocidad del Equipo

-
SP completados por mes · cada color = un dev
+

📈 Velocidad Histórica

+
SP completados mes a mes
-
-
-

🗂 SP por Épica — Estado Actual

-
Completados · En Progreso · Por Hacer en cada épica
-
-
-
+
-

SP Completados — Todos los meses

+

SP Completados — Tabla Resumen

- +
Desarrollador✅ SP Completados% del total del período
Desarrollador✅ SP Completados% del impacto total
+
- Dashboard en tiempo real · Proyecto LABS · invera.atlassian.net · - Campo: Desarrollador (customfield_10622) + SP Finales (customfield_11019) · - Cache: 5 min · + Dashboard en tiempo real · Arquitectura Modular Serverless · Realizado para Vercel & Jira
- + diff --git a/js/api.js b/js/api.js new file mode 100644 index 0000000..64fe272 --- /dev/null +++ b/js/api.js @@ -0,0 +1,31 @@ +/** + * js/api.js + * Centraliza la comunicación con el Backend (Vercel Serverless Function) + */ + +export const api = { + data: null, // Guarda la última respuesta en memoria del cliente + + /** + * Obtiene los datos de JIRA desde nuestro backend + * @param {boolean} forceRefresh - Forzar bypassing de la caché del backend + */ + async fetchDashboardData(forceRefresh = false, period = '90d') { + let url = `/api/data?period=${period}`; + if (forceRefresh) url += '&refresh=1'; + + try { + const res = await fetch(url); + if (!res.ok) { + const err = await res.json().catch(() => ({ error: `HTTP ${res.status}` })); + throw new Error(err.error || `HTTP ${res.status}`); + } + const json = await res.json(); + this.data = json; // Guardar en memoria global de la app + return json; + } catch (e) { + console.error('Error al cargar datos:', e); + throw e; + } + } +}; diff --git a/js/app.js b/js/app.js new file mode 100644 index 0000000..693e7a0 --- /dev/null +++ b/js/app.js @@ -0,0 +1,282 @@ +/** + * 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(); + + } 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}`; + + if(w.type === 'stalled') { badge = 'ESTANCADO'; text = `🎟️ [${link}] ${w.summary.slice(0,50)}... (${w.devs.join(', ')})`; } + 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)`; } + + 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 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 => + `${i.key}` + ).join(' '); + + tbody.innerHTML += ` + +
    ${initials(p)}
    ${p}
    +
    ${allKeys}
    + + ${d.in_progress} SP + ${d.todo} SP + ${total} SP +
    +
    +
    +
    + `; + }); +} + +// ── HISTORY SECTION ──────────────────────────────────────────────────────── +function renderHistorySection() { + const data = api.data; + if(!data) return; + + // Consolidar todos los meses del rango actual + let periodData = {}; + Object.values(data.monthlyDone).forEach(md => { + Object.entries(md).forEach(([p, sp]) => { + if (state.selectedDev !== 'all' && p !== state.selectedDev) return; + periodData[p] = (periodData[p] || 0) + sp; + }); + }); + + const totalSP = Object.values(periodData).reduce((a,b) => a+b, 0); + const devCount = Object.keys(periodData).filter(p => periodData[p] > 0).length; + + 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; + + // 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 + }], { plugins:{ legend:{ display:false } }, scales:{ x:{grid:{display:false}} } }); + + // Tabla de Resultados + const tbody = document.getElementById('tableHistory'); + tbody.innerHTML = ''; + if (sorted.length === 0) { + tbody.innerHTML = 'No hay entregas en este período'; + } else { + sorted.forEach(([p, sp]) => { + const p_ = totalSP > 0 ? (sp / totalSP * 100).toFixed(1) : 0; + const c = chartsManager.personColor(p); + tbody.innerHTML += ` +
    ${initials(p)}
    ${p}
    + ${sp} SP entregados +
    +
    +
    +
    ${p_}%
    + `; + }); + } + + // 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} } }); +} diff --git a/js/charts.js b/js/charts.js new file mode 100644 index 0000000..e58ba85 --- /dev/null +++ b/js/charts.js @@ -0,0 +1,47 @@ +/** + * js/charts.js + * Encargado de instanciar, dibujar y destruir gráficos de Chart.js + */ + +const COLORS = [ + '#0052cc','#36b37e','#ff5630','#6554c0','#ff8b00', + '#00b8d9','#57d9a3','#f99cdb','#8777d9','#344563', + '#403294','#00875a','#de350b','#505f79','#42526e', +]; + +export const chartsManager = { + instances: {}, + _peopleOrder: [], + + personColor(name) { + let idx = this._peopleOrder.indexOf(name); + if (idx < 0) { + this._peopleOrder.push(name); + idx = this._peopleOrder.length - 1; + } + return COLORS[idx % COLORS.length]; + }, + + destroyChart(id) { + if (this.instances[id]) { + this.instances[id].destroy(); + delete this.instances[id]; + } + }, + + drawBarChart(id, canvasId, labels, datasets, options = {}) { + this.destroyChart(id); + const ctx = document.getElementById(canvasId); + if (!ctx) return; + + this.instances[id] = new Chart(ctx, { + type: 'bar', + data: { labels, datasets }, + options: { + responsive: true, + maintainAspectRatio: false, + ...options, + } + }); + } +}; diff --git a/vercel.json b/vercel.json index dabfde0..96d3323 100644 --- a/vercel.json +++ b/vercel.json @@ -5,6 +5,14 @@ "src": "/api/(.*)", "dest": "/api/$1" }, + { + "src": "/css/(.*)", + "dest": "/css/$1" + }, + { + "src": "/js/(.*)", + "dest": "/js/$1" + }, { "src": "/(.*)", "dest": "/index.html"